顶点(vertexs) 图元(primitives) 片元(fragments,又叫片断) 像素(pixels)
阶段1. 顶点 ->图元
几何顶点被组合为图元(点,线段或多边形),然后图元被合成片元,最后片元被转换为帧缓存中的象素数据。
阶段2. 图元 ->片元
图元被分几步转换为片元:图元被适当的裁剪,颜色和纹理数据也相应作出必要的调整,相关的坐标被转换为窗口坐标。最后,光栅化将裁剪好的图元转换为片元。
1) 裁剪
在裁剪时点,线段和多边形处理略微不同。对于点,要么保留原始状态(在裁剪体内部),要么被裁掉(在裁剪体外部)。对于线段和多边形来说,如果部分在裁剪体外部,则需要在裁剪点生成新的几何顶点。对于多边形,还需要在新增的顶点间增加完整的边。不论裁剪了线段还是多边形,都需要给新增几何点赋予边界标志、法线、颜色和纹理坐标信息。
裁剪过程时两步:
a 应用程序指定裁剪(Application-specific clipping),一旦组合图元完成后,如果在程序中用glClipPlane()函数定义了任意的裁剪面,就进行裁剪。
b 视景体裁剪(View volume clipping),随后,图元被投影矩阵投影(进入裁剪坐标系),被相应的视景体裁剪。投影矩阵可以由glFrustum() 或者glOrtho()定义,投影矩阵的操作和上面其他矩阵变换的操作相同。
2) 转换到窗口坐标
裁剪坐标在转换为窗口坐标之前,要除以规格化设备坐标(normalized device coordinates)的w值进行规范化。然后对这些规范化数据进行视口变换(viewport)计算生成窗口坐标。可以用glDepthRange()和glViewport()控制视口大小,决定屏幕上显示图象的区域。
3) 光栅化
光栅化是将一个图元转变为一个二维图象(其实只是布满平面,没有真正的替换帧缓存区)的过程。二维图象上每个点都包含了颜色、深度和纹理数据。将该点和相关信息叫做一个片元(fragment)。(yuyu注:这就是片元和像素之间的关键区别,虽然两者的直观印象都是的像素,但是片元比像素多了许多信息,在光栅化中纹理映射之后图元信息转化为了像素)在这个阶段,对象素绘制和位图进行操作需要用到当前栅格位置(用glRasterPos*()定义)。正如上面讨论的,三种图元的光栅化方法是不同的,另外,象素块和位图也需要光栅化。
a)图元
采用glPointSize(), glLineWidth(), glLineStipple()和 glPolygonStipple()函数可以选择图元的光栅化维数和模式。另外,还可以用glCullFace(), glFrontFace()和glPolygonMode()控制多边形正反面不同的光栅化效果。
b)象素
有几个函数实现象素保存和转换。函数glPixelStore*()用于内存中的象素是如何保存的。glPixelTransfer*() and glPixelMap*()用于象素在写入帧缓冲区前是如何处理的。glDrawPixels()定义了一个象素矩形。用glPixelZoom()实现象素的缩放。
c)位图
位图是具有特定片元模式的0和1的矩形。每个片元有相同的相关数据。可以用glBitmap()定义。
d)纹理存储
纹理贴图是将指定的部分纹理图象映射到每个图元上。每个片元(fragment)具有的纹理坐标属性,该坐标与纹理图象坐标对应,得到纹理图象该位置的颜色值来修改片元的RGBA颜色,从而完成这个映射过程。用glTexImage2D()或glTexImage1D()来定义纹理图象。glTexParameter*()和glTexEnv*()来控制纹理如何解释和应用到一个片元上。
e)雾
已经光栅化的片元具有纹理贴图修正后颜色,可以采用融合因子再融合雾颜色,该融合因子大小根据视点和片元间的距离来定。用glFog*()指定雾化颜色和融合因子。
阶段3. 片元->像素
OpenGL允许光栅化生成一个片元,只要该片元通过一系列检测就可以修改帧缓冲区中对应象素。如果它通过测试,片元数据可以直接替换帧缓冲区中的已有值,或者和已有值合并,这取决于设置的模式。
1)象素所有权(ownership)检测
第一个测试是判断在帧缓冲区中的象素所对应的某个片元是否属于当前OpenGL上下文。如果属于,片元进行下一个测试。如果不属于,窗口系统决定是否忽略该片元,或者是否进行下一步片元操作。
2)裁剪检测
用glScissor()函数,可以定义一个任意屏幕校准矩形,在该矩形外的片元将被忽略。
3)Alpha检测
Alpha测试只能在RGBA模式下进行,如果片元的alpha值超出一个固定参照值,片元将被忽略,这个比较函数可以用glAlphaFunc()实现并设定参考值。
4)模版检测
当模版缓冲区的值超出一个参照值,模版测试将有条件的忽略该片元。这个比较函数和固定值可以用glStencilFunc()实现。不论图元通过或没有通过模版测试,模版缓冲区中的值会根据glStencilOp()函数进行修改。
5)深度检测
当深度缓冲区的值与参照值的比较失败,深度测试忽略该片元。GlDepthFuc()用来执行这个比较命令。如果模版启用,深度比较的结果会影响模版缓冲区值的更新。
6)融合
融合合并了一个片元R、G、B和A值和存储在帧缓冲区对应位置的这些值。融合只能在RGBA模式下实现,它的实现需要片元的alpha值和对应当前存储象素,还需要RGB值。用glBendFun()控制,可以修改融合因子的源和目标。
7)抖动
如果启动抖动,片元的颜色或者颜色索引采用抖动算法。这个算法只需要片元的颜色值和它的x和y坐标。
8)逻辑操作
最后,在片元和帧缓冲区对应值之间要进行一个逻辑操作,结果将替换当前帧缓冲区的值。用glLogicOp定义想要的逻辑操作。这个逻辑操作只能在颜色索引模式下运行,而不能在RGBA模式运行。
象素
在OpenGL流水线的上个阶段,片元转换为帧缓冲区中的象素。帧缓冲区实际上是一组逻辑缓冲区——包括颜色缓冲区、深度缓冲区、模版缓冲区和累积缓冲区。颜色缓冲区包括左、前右、后左、后右和一些辅助缓存值(auxiliary buffers)。可以直接从中读取或者复制。对于OpenGL不同上下文,这些缓冲区可能不全
1)帧缓冲区操作
用glDrawBuffer为绘图选择一个颜色缓冲区。另外在预片元化(per-fragment)操作后,可以用四个不同函数保留写入这些逻辑缓冲区的操作,glIndexMask(), glColorMask(), glDepthMask(), and glStencilMask()。glAccum()对累积缓冲区进行操作。最后glClearColor(), glClearIndex(), glClearDepth(), glClearStencil()和glClearAccum().对不同缓冲区中指定相对应的颜色值、颜色索引值、深度值、模板值和累积值。
2)读取和复制象素
用glReadPixel()从帧缓冲区中把象素读到内存中,进行各种操作,保存处理结果。另外,可以用glCopyPixel()从帧缓冲区中复制一块象素到另一个帧混存。glReadBuffer()可以读取和复制颜色缓冲区中的象素。
写在前面
本节内容翻译和整理自《Learning Modern 3D Graphics Programming》Chapter1内容。作为学习目的,本文内容上不会完全遵从原文,有删节。另外原文示例代码有它独有的框架组织方式,为了保持自己的一贯风格,这里重写了示例程序代码,如果发现错误,请纠正我。转载需经过作者同意。
通过本节,你可以了解到:
把握两点:
我们要在哪儿分配OpenGL可见的内存(给它数据buffer object)?
我们怎么告诉OpenGL如何解释分配的内存(给它附加描述信息glVertexAttribPointer)?
绘制管线的第一阶段是将顶点数据映射到裁剪空间。在OpenGL这样处理前,它必须接受一个顶点列表。因此,管线的最初阶段是发送三角形数据到OpenGL。这是我们要发送的数据:
这些数据每行的4个值代表一个4D顶点坐标,因为裁剪坐标系是4维的。
这些坐标已经在裁剪坐标系范围内了。我们想要OpenGL做得就是根据这些数据绘制三角形。
尽管我们已经有了数据,OpenGL并不能直接使用它们。OpenGL对它能读取的内存有些限制。你可以按需分配你的顶点数据,但是这些内存对OpenGL并不直接可见。因此,第一步就是分配OpenGL可见的内存,并填充我们的数据。这是通过缓存对象(buffer object,以下简称BO)来实现的。
一个缓存对象,是一个线性数组形式的内存,由OpenGL根据用户请求管理和分配。这块内存的内容可由用户控制,但是用户也仅能间接地控制。可以把buffer object当做GPU内存中的数组。
GPU可以快速读取它,因此在它里面存储数据有性能优势。在本节中,缓存对象是这样来创建的:
代码片段:
第一行,创建了buffer object,并将句柄存储在全局变量中。尽管对象已经存在,它没有任何空间。因为我们还未给他分配任何空间。glBindBuffer函数将新建的BO绑定到GL_ARRAY_BUFFER上下文中。
glBufferData 函数执行两个操作。它分配了当前绑定到glBufferData 的缓存的空间,这就是我们刚刚创建和绑定的BO。我们已经有了顶点数据,问题是它在我们的RAM中而不是OpenGL的内存中。sizeof(vertexPositions) 计算顶点数组的字节大小。我们向glBufferData 传递此值来表明分配空间的大小。这样在GPU内存中就有足够空间来保存顶点数据。
glBufferData 执行的另一个操作是从我们的数组内存RAM中拷贝数据到BO中。第三个参数控制了这个复制。如果这个参数不是NULL,正如此例,glBufferData 会将指针所指数据拷贝到BO中。当这个函数执行完后,BO中就有了顶点数据了。
第四个参数,稍后解释。
第二次调用glBufferData ,执行的是清理任务。通过绑定0值到GL_ARRAY_BUFFER,我们使之前绑定到这个目标的BO从该目标解除绑定。0在这里充当了NULL指针的作用。这并不是必须的,因为之后的绑定会自动解除已有的绑定。但是除非你严格的控制着你的渲染,通常解除你绑定的对象是个好的想法。
这样完成了发送顶点数据到GPU的任务。但是BO中的数据时未格式化的,但这是OpenGL关心的。我们只是分配了BO,并填充了些随机二进制数据。现在我们需要告诉OpenGL,BO中有顶点数据,并告诉他顶点数据的格式。我们通过下面这样的代码来完成这一任务:
glBindBuffer(GL_ARRAY_BUFFER, positionBufferObject);glEnableVertexAttribArray(0);glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);
第一个函数声明使用BO。第二个函数启动顶点属性数组,这个稍后解释。
第三个函数是关键的。glVertexAttribPointer,尽管,包含”Pointer”一词,但是实际上它处理的并不是指针,而是BO。
在渲染时,OpenGL从BO中提取顶点数据。我们要做的就是通告OpenGL存储在BO中的顶点数组中数据格式。也就是要告诉OpenGL如何解释BO中的数组。
在我们的案例中,数据格式如下
glVertexAttribPointer 函数告诉了OpenGL所有这些情况。第三个参数制定了值得基本类型,即GL_FLOAT,对应32位浮点数据。第二个参数,指定多少个这样的值组成一个位置,即一个点。在这里,即4个值组成一个点。第5个参数指定数据间间隙,第6个参数指定BO中数据偏移量,0代表从BO的开始处算起。
第四个参数以后再做解释。
还有一件事好像没做,那就是指定从哪个BO中获取数据。这是一个隐含的而不是显示的关系。glVertexAttribPointer 总是指向它调用是绑定到GL_ARRAY_BUFFER 的缓存。因此,这里不需要传递BO的句柄。
关于这个函数,更多的解释,此处不再展开。
一旦OpenGL知道从哪里获取顶点数据,就可以渲染三角形了:
glDrawArrays(GL_TRIANGLES, 0, 3);
把握两点:
顶点和片元着色器的输入输出
顶点数据的流向
既然我们能够告诉OpenGL定点数据了,我们转到绘制管线的下一阶段:顶点处理。
在这个阶段中涉及到使用着色器(Shader)。
着色器就是运行在GPU上的一个程序而已。在绘制管线中有几个可能的着色器阶段,每个阶段都有它自己的输入输出。着色器的目的是将输入包括潜在的其他类型数据,转换到输出集中。
每个着色器都在输入集上执行。值得注意的是,在任何阶段,一个着色器都完全独立于那一阶段的其他着色器。独立执行的着色器之间不会有交叉。每个输入集的处理从着色器开始到结束阶段。着色器定义了它的输入输出,通常,没有完成输出数据任务的着色器是非法的。
顶点着色器,从名字上来看,操作的就是顶点。具体来说,每次触发顶点着色器都作用于单个顶点。这些着色器除了用户定义的输出外,必须输出顶点的裁剪坐标系中的位置。如何计算这个裁剪坐标系位置,完全取决于着色器。
在OpenGL中,着色器使用GLSL( OpenGL Shading Language )语言书写。看起来好像C语言,但实际上受到的限制很大,例如不能使用递归。我们的顶点着色器看起来是这样:
看起来很简单。第一行定义版本3.30。所有GLSL着色器都必须声明版本。
下一行,定义了顶点着色器的输入。这个输入变量是position,类型是4维的浮点型的vec4。Layout location 0稍后解释。
就像C语言一样,以main函数开始。这个着色器很简单,它的任务,就是将输入position拷贝到输出gl_position。这个变量是着色器内置变量,如果你看到”gl_”开头的变量,那么一定是内置变量。”gl_”变量,不允许自己定义,你只能使用已经存在的。
gl_Position定义如下
out vec4 gl_Position;
刚刚说过,顶点着色器的基本任务是产生裁剪坐标系中顶点位置。这就是gl_Position,它就是裁剪坐标系的坐标。因为我们定义的顶点数据已经在裁剪坐标系下了,因此直接输出它即可。
顶点属性
着色器有着输入输出,就好比一个有参数和返回值的函数一样。
输入和输出来自和转到一些地方去了。因此,输入position 肯定在某处被填充了数据。那么这些数据来自哪里呢?顶点着色器的输入被称为顶点属性(vertex attributes)。
你可能认得一些类似的顶点属性术语,例如,“glEnableVertexAttribArray” 或者 “glVertexAttribPointer.”
这就是数据如何从管线中流动下来的。在渲染开始时,BO中的顶点数据,在glVertexAttribPointer初始化工作的基础上来读取。
这个函数描述了属性中数据的来源。glVertexAttribPointer和顶点着色器中某个字符串名字对应的输入之间的连接时有些复杂的。
每个顶点着色器的输入有一个索引位置称作属性索引(attribute index.)。在上例中输入定义为:
layout(location = 0) in vec4 position;
Layout location部分将属性索引0赋给position了。属性索引必须不小于0.并且受到硬件限制。
在代码中,当引用属性时,总是有属性索引来解引用。glEnableVertexAttribArray、glDisableVertexAttribArray和glVertexAttribPointer函数都将属性索引作为第一个参数。我们将属性索引0赋给positon,因此在代码中调用时也是如此。glEnableVertexAttribArray(0) 启用了指向position属性的索引。下图的图解释了数据时如何流到着色器中的:
如果没有调用glEnableVertexAttribArray, 在glVertexAttribPointer按索引调用就没什么意思。
这个启用属性的调用不一定非得在顶点属性指针之前调用,但是在渲染前必须调用。如果属性没有开启,在渲染阶段就不会被使用。
光栅化
现在所有完成的任务是,3个顶点被发送到OpenGL,并且由顶点着色器转换为裁剪坐标系中的3个位置。接下来,顶点位置将会通过把xyz三个分量除以W分量而转换为规格化设备坐标系。在我们的例子中,W都是1.0,因此我们的坐标已经都是有效地规则化设备坐标了。
在这之后,顶点位置被转换为屏幕坐标。这是通过视口转换(viewport transform.)来完成的。这样称呼是因为完成它的glViewport这个函数。本例中当窗口改变大小是,每次都调用这个函数。当窗口大小改变时总是调用reshape 函数,该函数如下:
这个告诉OpenGL那个可用区域将用来渲染。在本例中,我们使用全部的可用区域。如果没有这个函数调用,调整窗口大小将不会影响渲染。同时,记住我们没有保持宽高比为常量,这样会导致三角形变形。
回忆一下,屏幕坐标(0,0)在左下角。这个函数把左下角位置作为头两个坐标,而把视口的宽度和高度作为另外两个坐标。
一旦在屏幕坐标中了,OpenGL将会取这3个坐标,扫描转换为一些列的片元。要完成这项任务,OpenGL必须决定这个顶点列表代表什么。OpenGL解析一个定点列表的方式各有不同。使用命令:
glDrawArrays(GL_TRIANGLES, 0, 3);告诉它绘制三角形。
流向光栅器中数据如下图所示:
片元着色器用于计算输出片元的颜色。它的输入包括屏幕坐标下片元的XYZ坐标,也可以包括用户定义数据,这里就不做介绍。
我们的片元着色器定义如下:
首行同样是版本声明。
下一行指定片元着色器的输出变量,类型为vec4。
主函数中仅仅使用了4维向量,来表达颜色。
尽管为片元着色器提供了屏幕坐标下的坐标,但是这里不需要它,因而也就没有使用它。在片元着色器执行完后,片元的输出颜色就被输出到图像中了。
注意:
在顶点着色器中使用layout(location = #)来表明属性索引,在顶点数组和顶点着色器之间建立关联。但是片元着色器的输出基本上都是到当前渲染的图像,在我们的例子中是屏幕,因此如果你在片元着色器中只定义了一个输出变量,那么这个变量将自动写入到当前的目标图像。
着色器的生成可以参见下图:
一个着色器字符串被编译后成为着色器对象Shader Object.一个或者多个着色器对象链接成为着色器程序program Object.
注意在生成着色器对象和程序时,要访问他们被创建的状态,如果出错了,要进行出错处理。
这里面没什么需要细讲的东西,下面给出他们的一个实现版本:
shader.h 着色器辅助类头文件
shader.cpp 着色器辅助类实现文件
本节的完整示例,利用VBO传送顶点数据,利用顶点着色器和片元着色器处理顶点,他们的字符串都以std::string形式写在代码中了(当然,也可以由文件读取)。
着色器shader.h和实现见上文代码,示例完整代码如下: