上一篇我们已经建立了OpenGL ES Android 环境并用C/C++实现了一个简单的三角形,我们还知道了用GLSL语言所写的Vertex Shader和Fragment Shader的使用流程,简单地说,我们通过操作顶点着色器来描述图的形状,通过片段着色器来描述图上每个像素点的颜色。本篇文章呢,我们来解释上一篇遗留下来的问题。
坐标系
我们从前面的总结知道,使用OpenGL引擎的第一个程序入口就是从编写顶点着色器开始的。既然要去描述图形的坐标信息,我们就有必要知道如何去构建坐标系给OpenGL,如何转换成我们看到的图形坐标的。
将坐标变换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步进行的,也就是类似于流水线那样子。在流水线中,物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统(Coordinate System)。将物体的坐标变换到几个过渡坐标系(Intermediate Coordinate System)的优点在于,在这些特定的坐标系统中,一些操作或运算更加方便和容易,这一点很快就会变得很明显。对我们来说比较重要的总共有5个不同的坐标系统:
- 局部空间(Local Space,或者称为物体空间(Object Space))
- 世界空间(World Space)
- 观察空间(View Space,或者称为视觉空间(Eye Space))
- 裁剪空间(Clip Space)
- 屏幕空间(Screen Space)
这就是一个顶点在最终被转化为片段之前需要经历的所有不同状态。
你现在可能会对什么是坐标空间,什么是坐标系统感到非常困惑,所以我们将用一种更加通俗的方式来解释它们。下面,我们将显示一个整体的图片,之后我们会讲解每个空间的具体功能。
为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection)三个矩阵。我们的顶点坐标起始于局部空间(Local Space),在这里它称为局部坐标(Local Coordinate),它在之后会变为世界坐标(World Coordinate),观察坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)的形式结束。下面的这张图展示了整个流程以及各个变换过程做了什么:
- 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。
- 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。
- 接下来我们将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的。
- 坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。
- 最后,我们将裁剪坐标变换为屏幕坐标,我们将使用一个叫做视口变换(Viewport Transform)的过程。视口变换将位于-1.0到1.0范围的坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。
你可能已经大致了解了每个坐标空间的作用。我们之所以将顶点变换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。
上述流程中我们通过模型(Model)、观察(View)、投影(Projection)这三个矩阵来实现不同坐标系之间的转换。
我们知道矩阵是可以相乘的并且不满足交换律的(有方向的),而上面的全部流程我们可以通过下面的左乘矩阵来一步到位实现坐标转换。
Vclip= Mprojection ⋅ Mview ⋅ Mmodel ⋅ Vlocal
注意矩阵运算的顺序是相反的(记住我们需要从右往左阅读矩阵的乘法)。最后的顶点应该被赋值到顶点着色器中的gl_Position,OpenGL将会自动进行透视除法和裁剪。
在OpenGL里面它希望传入顶点着色器的值是标准化设备的坐标(Normalized Device Coordinate, NDC)即裁剪空间的坐标。
在目前我所接触到2D图像处理中,都是从单一观察角度去描述一个图像的。所以我所面临的构建坐标系统一般有两种情况:
- 直接描述裁剪空间中的坐标并传给OpenGL。
例如,在描述一张图像渲染的时候直接描述它的裁剪空间坐标然后交由OpenGL渲染就行了。
下图就是OpenGL中裁剪空间示意:
例如我们上例中的三角形的坐标就是裁剪坐标Vclip,直接把裁剪坐标交给glViewport将裁剪坐标变换为屏幕坐标。最后变换出来的坐标将会送到光栅器,将其转化为片段。
const GLfloat m_vertex_coors[9] = {
-0.5f, -0.5f, 0.0f,//左下
0.5f, -0.5f, 0.0f,//右下
0.0f, 0.5f, 0.0f//上
};
- 在观察者空间中构建真实的坐标系然后通过投影变换转换到裁剪空间再传给OpenGL。
假如我要描述它是如何运动的(旋转、平移、缩放)就先在观察空间描述完它的坐标运动然后通过投影变换映射到裁剪空间再交由OpenGL渲染。
投影
为了将顶点坐标从观察变换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix),它指定了一个范围的坐标,比如在每个维度上的-1000到1000。投影矩阵接着会将在这个指定的范围内的坐标变换为标准化设备坐标的范围(-1.0, 1.0)。所有在范围外的坐标不会被映射到在-1.0到1.0的范围之间,所以会被裁剪掉。
如果只是图元(Primitive),例如三角形,的一部分超出了裁剪体积(Clipping Volume),则OpenGL会重新构建这个三角形为一个或多个三角形让其能够适合这个裁剪范围。
由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影(Projection),
正射投影
正射投影矩阵定义了一个类似立方体的平截头箱,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。在使用正射投影矩阵变换至裁剪空间之后处于这个平截头体内的所有坐标将不会被裁剪掉。它的平截头体看起来像一个容器:
上面的平截头体定义了可见的坐标,它由由宽、高、近(Near)平面和远(Far)平面所指定。任何出现在近平面之前或远平面之后的坐标都会被裁剪掉。正射平截头体直接将平截头体内部的所有坐标映射为标准化设备坐标.
透视投影
如果你曾经体验过实际生活给你带来的景象,你就会注意到离你越远的东西看起来更小。这个奇怪的效果称之为透视(Perspective)。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样:
正如你看到的那样,由于透视,这两条线在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。这个投影矩阵将给定的平截头体范围映射到裁剪空间,除此之外还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被变换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的坐标都会被裁剪掉)。OpenGL要求所有可见的坐标都落在-1.0到1.0范围内,作为顶点着色器最后的输出,因此,一旦坐标在裁剪空间内之后,透视除法就会被应用到裁剪空间坐标上:
顶点坐标的每个分量都会除以它的w分量,距离观察者越远顶点坐标就会越小。这是也是w分量非常重要的另一个原因,它能够帮助我们进行透视投影。最后的结果坐标就是处于标准化设备空间中的。
正射投影与透视投影的比较
当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视除法(它仍然会进行透视除法,只是w分量没有被改变(它保持为1),因此没有起作用)。因为正射投影没有使用透视,远处的物体不会显得更小,所以产生奇怪的视觉效果。由于这个原因,正射投影主要用于二维渲染以及一些建筑或工程的程序,在这些场景中我们更希望顶点不会被透视所干扰。
你可以看到,使用透视投影的话,远处的顶点看起来比较小,而在正射投影中每个顶点距离观察者的距离都是一样的。
小结
如果你对上面的投影一知半解的话,先不用纠结,后面还有内容来详细解释,现在我们还是先来解释我们上面的代码:对于一个简单的三角形绘制的话,我们是直接使用裁剪空间的坐标,
const GLfloat m_vertex_coors[9] = {
-0.5f, -0.5f, 0.0f,//左下
0.5f, -0.5f, 0.0f,//右下
0.0f, 0.5f, 0.0f//上
};
其几何图形如下所示
链接顶点属性
我们上面的代码是用c语言所写的,变量m_vertex_coors是被存储在CPU中的,在开篇里我们就提到了OpenGL是运行在GPU上的,那么我们如何把CPU中的数据传给GPU呢,OpenGL当然提供了一些API来完成这件事。OpenGL API在更新迭代中,也渐渐优化顶点数据传输的效率,具体可参考OpenGL传输形式对比
顶点着色器允许我们指定任何以顶点属性为形式的输入。这使其具有很强的灵活性的同时,它还的确意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。
glVertexAttribPointer方法
glVertexAttribPointer(m_vertex_pos_handler, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), m_vertex_coors);
glVertexAttribPointer函数的参数非常多,所以我会逐一介绍它们:
- 第一个参数指定我们要配置的顶点属性。还记得我们的
m_vertex_pos_handler
吗,m_vertex_pos_handler
使我们在glLinkProgram 之后使用glGetAttribLocation方法从OpenGL程序中获取指定名字
的位置,这个名字
必须与Vertext Shader中定义的名字相同
glLinkProgram(m_program_id);
m_vertex_pos_handler = glGetAttribLocation(m_program_id, "aPosition");
const char *TriangleDrawer::GetVertexShader() {
return "attribute vec4 aPosition; \n"//此处的aPosition对应上面的 "aPosition"
"void main() \n"
"{ \n"
" gl_Position = aPosition; \n"
"} \n";
}
。
- 第二个参数指定顶点属性的大小。顶点属性是一个
vec3
,它由3个值组成,所以大小是3。 - 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中
vec*
都是由浮点数值组成的)。 - 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
- 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个
float
之后,我们把步长设置为3 * sizeof(float)
。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。 - 最后一个参数的类型是
void*
,它表示位置数据在缓冲中起始位置的偏移量(Offset)。我们传入m_vertex_coors
数组首地址。
之后我们的顶点缓冲数据会被解析为下面这样子:
- 位置数据被储存为32位(4字节)浮点值。
- 每个位置包含3个这样的值。
- 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
- 数据中第一个值在缓冲开始的位置。
有了这些信息我们就可以使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)了:
现在我们已经定义了OpenGL该如何解释顶点数据,我们现在应该使用glEnableVertexAttribArray,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的。
glEnableVertexAttribArray(m_vertex_pos_handler);
至此我们的顶点数据已经传入了GPU,在Vertex Shader 中,aPosition已经被上面的操作填充数据了,然后 gl_Position = aPosition,关于gl_Position
,我们现在只需要知道,它是Vertex Shade裁切空间输出的位置向量,是GLSL语言中的内建变量。如果你想让屏幕上渲染出东西gl_Position
必须使用。否则我们什么都看不到。
剩下的就是绘制与输出了
glDrawArrays方法
//开始绘制
/**
* @param mode 渲染的图元模式,有:GL_POINTS、GL_LINES、GL_LINE_LOOP、GL_LINE_STRIP、GL_TRIANGLES、GL_TRIANGLE_STRIP、GL_TRIANGLE_FAN
* @param first 起始位置
* @param count 顶点数量
*/
glDrawArrays(GL_TRIANGLE_STRIP, 0, 3);
这里我们只讨论GL_TRIANGLES、GL_TRIANGLE_STRIP、GL_TRIANGLE_FAN
- GL_TRIANGLES:独立顶点的构成三角形
- GL_TRIANGLE_STRIP:复用顶点构成三角形
- GL_TRIANGLE_FAN:复用第一个顶点构成三角形
片段着色器
有了几何图形之后经过光栅化把图形转化为一个个的片段(Fragment),这个时候我们终于来到了片段着色器,片段着色器的作用就是给一个个片段上颜色,我们本例中的Frament Shader也很简单
const char *TriangleDrawer::GetFragmentShader() {
return "precision mediump float; \n"
"void main() \n"
"{ \n"
" gl_FragColor = vec4 ( 1.0, 0.0, 0.0, 1.0 ); \n"
"} \n";
}
这里的gl_FragColor
跟上面的gl_Position
一样是GLSL语言中的内建变量,不过它是Frament Shader中输出的颜色向量,在OpenGL中颜色向量用vec4(r,g,b,a)表示,在Android中我们的rgba通常是0~255的整数,不过在这里是0.0f~1.0f的浮点数,上面的代码意思就是给每一个片段都涂上红色
,输出结果就是心心念念的三角形了
小结
本篇我们介绍了坐标系以及三角形是如何展示出来的,完整代码请看OpenGL demo1,不过我们同样留下的很多问题,除了gl_FragColor
和gl_Position
之外GLSL还有哪些内建变量,有什么作用,以及向量还有Vertex Shader以及Fragment Shader中一些语句到底什么意思等等,我们后文会慢慢来。
课后习题
本章我们已经有了一个三角形了,虽然我们还有很多不明白的地方,但是我们已经有了OpenGL中的一个 Hello World
,那下面留下几个小练习供读者思考一下。
- 给三角形换个颜色呗,不想要红色了
- 不画三角形了,画个矩形呗
- 我是用C/C++实现的,不想用C/C++用java或者kotlin实现一下呗
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。