OpenGL 片段处理和帧缓存A[WIP]

知识点:

  • 数据时如何被传递到片段着色器中,如果控制传递的方式,以及在片段着色器中如来处理数据。
  • 如何创建帧缓存并控制其内部数据的存储格式。
  • 如何使单个片段着色器有多个输出。

本篇文章主要讲的是OpenGL管道后端(back end)的相关知识,也就是位于光栅化阶段之后的知识。我们将深入了解片段着色器能帮我们完成的有意思的事情,当数据离开片段着色器后都发生了什么,以及它们是如何回到应用中的。我们也会详细分析如何提升应用生成的图片质量,如抗锯齿技术(通过画面模糊化效果实现)。

1. 片段着色器(Fragment Shaders)

光栅化阶段执行完毕后会通过插值计算生成大量的片段,每个片段都会对应一次片段着色器的调用,它在每个片段在被存储到帧缓存之前确定它们的颜色。默认情况下,在经过OpenGL渲染管道前端部分的最后一个阶段(可能是顶点、曲面细分或者集合着色器)后,输入到片段着色器的所有变量都会在被光栅化的图元内部经历光滑的插值计算。然而,对于插值计算的执行方式,甚至是是否会被执行你都有一定的控制权。

1.1 插值和存储修饰词(Interpolation and Storage Qualifiers)

在前面的文章中已经介绍过一些GLSL支持的存储修饰词,这里还有一些可以控制插值过程的关键词,它们包括flat和noperspective。

1.1.1 禁用插值(Disabling Interpolation)

点那个你在片段着色器中默认声明一个输入变量是,OpenGL会默认对它在需要渲染的图元之间进行插值计算。然而,无论在什么时候如果你从渲染管道的前端向后端传递了一个整形变量,插值计算都必须被默认禁用,这是因为OpenGL不支持对整数进行光滑的插值计算。对于浮点型变量,OpenGL也允许显示的禁用它们的插值运算。通过在片段着色器输入变量前面使用修饰词flat可以禁用它的插值计算。默认的修饰词是smooth,但通常不需要显示声明,OpenGL会默认使用这种类型。当整个闭包变量都声明为flat类型时,可以将部分变量声明为smooth类型,使用方法如下。

flat in vec4 foo;
flat in int bar;
flat in mat3 baz;

flat in INPUT_BLOCK {
  vec4  foo;
  int  bar;
  smooth  mat3 baz;
}

上面闭包示例中,foo继承了闭包的类型flat类型,其插值计算被禁用。由于bar是整形变量,其默认为flat类型,而baz为smooth类型,会被插值计算。

需要注意的是我们在片段着色器中描述的输入变量的插值和存锤修饰词必须和其对应的位于渲染管道前端着色器中的输出变量相匹配。

当输入变量使用修饰词flat后,其值只来源于图元的某一个顶点。当渲染的图元类型是点图元时,仅有一个顶点能提供数据。而当渲染的图元是线图元或者三角形图元时,第一个和最后一个顶点都能为片段着色器提供数据。为片段着色器提供数据的顶点称为关键顶点(provoking vertex),可以通过函数void glProvokingVertex (GLenum provokeMode);设置第一个或者最后一个顶点成为关键顶点。

在该函数中,参数provokeMode指定关键顶点的位置,有效的值为GL _FIRST _VERTEX _CONVENTION和GL _LAST _VERTEX _CONVENTION,后者为默认值。

1.1.2 禁用透视投影插值校正(Interpolating without Perspective Correction)

前文讲过OpenGL将会在三角形等图元内部对输入到片段着色器的每个属性进行插值运算,使得每次片段着色器调用的时候都会生成新的值。默认情况下,OpenGL在裁剪空间内进行插值运算。这意味着当你俯视一个三角形时,光栅器生成的相邻片段映射到裁剪空间内时,它们的间距相等。然而很少情况下三角形图元会正面对观察者,因此透视收缩(perspective foreshortening)使得相临片段映射到裁剪空间的距离偏移量并不相同(对于三角形图元,由于是在裁剪空间内进行线性插值,因此经历透视投影及光栅化阶段后,其相邻片段的属性变化值也不相同,也就是说它们在屏幕空间内并不是线性的)。

OpenGL使用透视修正插值(perspective-correct interpolation)来解决这个问题,为了实现这个方案,它对那些在屏幕空间内表现为线性连续的值进行插值运算,并使用它们修正每次片段着色器调用时其输入值。

对于一组在三角形内部插值(光栅化插值时使用线扫描算法)的纹理坐标uv,相邻片段的u值和v值偏移量都不是线性的。然而u/w、v/w和1/w在屏幕空间内是连续的,对于每个片段通过1/w为线性这一特征计算出该片段的w值,再通过u/w和v/w为连续的这一特征分别计算出u和v的值。这样便能计算出每个片段的插值属性经过投影修正后正确的值(实际上三角形内的属性插值应该是依靠重心坐标插值计算,而此处应该是首先计算出片段在裁剪空间内的坐标会使用到上述技术,从而计算其对应的点在裁剪空间内相应的重心坐标,最后进行插值运算,但此处需要验证)。

通常情况下这是我们想要的效果,然而有些时候我们可能不需要进行投影修正,此时可以使用关键字noperspective修饰着渲染管道前端部分最后一个着色器的输出变量以及片段着色器相应的输入变量。例如。

// 渲染管道前端部分最后一个着色器的输出变量
noperspective out vec2 texcoord;
// 片段着色器相应的输入变量
noperspective in vec2 texcoord;

一个观察该修饰词的效果Demo渲染效果如下图。其中左图是禁用透视投影校正插值结果(noperspective),右图是使用透视投影校正的插值结果。源码地址见Chapter 9-1/Noperspective。

OpenGL 片段处理和帧缓存A[WIP]_第1张图片

2 片段测试(Per_Fragment Tests)

在片段着色器工作之前,图元已经被裁剪并投影到标准设备坐标空间内,光栅化器对投影到屏幕空间内的图元进行光栅化计算,为每个像素点生成一个片段。默认情况下(尽管逻辑上它们的顺序位于片段着色器之后,下文早期测试内容会详细讲这个点)在执行片段着色器之前,OpenGL接着对每个片段会执行一系列的测试,从而确定该片段能否以及该如何写到帧缓存内。这些测试操作按逻辑上的顺序分别为剪切测试(scissor test)、模版测试(stencil test)和深度测试(depth test)。下面按照他们在渲染管道内的顺序逐个介绍。

2.1 剪切测试(Scissor Testing)

你可以用屏幕坐标指定任意一个矩形作为裁剪矩形,通过该矩形进一步裁剪渲染图形。和设置观察点不同(viewport),几何形图元不会直接和裁剪矩形做比较,而是在光栅化阶段(实际上不同的OpenGL实现略有不同,部分实现会在在几何阶段结束后开始执行剪切测试,部分实现会在光栅化阶段早期,这里只讨论后者)直接测试单独的片段。和视口矩阵(viewpoint rectangle)一样,OpenGL支持一个裁剪矩阵数组。调用函数为glScissorIndexed()或者glScissorIndexedv()。其原型如下。

void glScissorIndexed(GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height);
void glScissorIndexed(GLuint index, const GLint *v);

上面两个函数中,参数index表示要指定的裁剪矩形的索引值,需要注意的是要设置多个视口的裁剪矩阵需要开启多视口模式,请参考几何着色器中的详细解释。left、bottom、width、height使用窗口坐标描述了一个裁剪矩形,对于第二个函数,v表示的是一个四元数组的指针,其中的元素依次为left、bottom、width、height。

选择需要使用的裁剪矩形索引需要在几何着色器中给内建变量gl_ViewportIndex赋值,这和几何着色器指定视口索引使用的是同样一个内建变量,视口数组和裁剪矩形数组是对应的。启用和禁用裁剪测试分别调用如下连个函数。需要注意的是OpenGL的程序默认禁用裁剪测试。

glEnable(GL_SISSOR_TEST);
glDisable(GL_SISSOE_TEST);

下面是如何设置裁剪矩阵的简单代码块。

// 开启裁剪测试
glEnable(GL_SISSOR_TEST);
// 将每个裁剪矩形的宽度都设置为屏幕宽度的7/16
int scissor_width = (7 * info.windowWidth) / 16;
int scissor_height = (7 * info.windowHeight) / 16;

// Four rectangles - lower left first...
glScissorIndexed(0, 0, 0, scissor_width, scissor_height);
// Lower right...
glScissorIndexed(1, NSWidth(bounds) - scissor_width, 0, scissor_width, scissor_height);
// Upper left...
glScissorIndexed(2, 0, NSHeight(bounds) - scissor_height, scissor_width, scissor_height);
// Upper right...
glScissorIndexed(3, NSWidth(bounds) - scissor_width, NSHeight(bounds) - scissor_height, scissor_width, scissor_height);

完整的Demo地址见9.2-multisissor。下图为Demo运行的效果。

OpenGL 片段处理和帧缓存A[WIP]_第2张图片

一个需要注意的点是,当调用函数glClear()和glClearBufferfv()时,第一个裁剪矩形仍然是生效的,这意味着你可以使用裁剪矩形去清除帧缓存中的任意矩形区域。但是这也可能导致一些错误,如当某帧画面绘制完成后,如果不禁用裁剪矩形,那么在尝试清楚下一个准备绘制的帧缓存时并不能清除掉所有数据。

另外,设置多个裁剪矩形和设置多个视图窗口不同,前着尽快绘制也会多次调用几何着色器进行图形绘制,但是每次仅仅绘制位于裁剪框内的部分。而后者却会在每个窗口内部绘制一个完整的模型。即对于后者,每个视图窗口都会映射到标准的设备空间即(-1到1),而前者并不会。

2.2 模版测试(Stencil Testing)

图元处理管道的下一阶段是模板测试。可以将模板测试的过程想象为在一个硬纸板上裁剪一个图案,然后将镂空的硬纸板放在墙壁上,然后在上面绘制,移开硬纸板后就能得到想要的图案。要得到类似的效果,需要在帧缓存的像素格式中添加模板缓存。通过调用函数glEnable()并传入参数GL_STENCIL_TESTING可以开启模板测试,OpenGL大多数实现所支持的模板缓存为8位,但是某些配置可能支持更多或者更少位数的真缓存,当然,这并不常见。

绘图命令能够直接影响模板缓存,模板缓存的值能够直接作用在绘制的像素之上。OpenGL提供两个函数操作模板缓存,分别为glStencilFuncSeperate()和glStencilOpSeperate()。

void glStencilFuncSeparate(GLenum face, GLenum func, GLint ref, GLuint mask);
void glStencilOpSeparate(GLenum face, GLenum sfail, GLenum dpfail, GLenum dppass);

对于函数glStencilFuncSeparate(),它控制了模板测试通过和失败的条件。测试分别用于面向屏幕和背离屏幕的图元,它们有着独立的状态,可以通过在参数face中传入GL_FRONT或者GL_BACK或者GL_FRONT_AND_BACK指定函数控制的是何种朝向的图元。参数func的取值见下表,它指定了何种条件下片段能够通过模板测试。

函数 通过条件
GL_NEVER 永远不会通过测试
GL_ALWAYS 总是通过测试
GL_LESS 引用的值小于缓存中的值Reference value is less than buffer value.
GL_LEQUAL 引用的值小于或者等于缓存中的值
GL_EQUAL 引用的值等于缓存中的值
GL_GEQUAL 引用的值大于或者等于缓存中的值
GL_GREATER 引用的值大于缓存中的值refValue > (bufferValue & mask)
GL_NOTEQUAL 引用的值不等于于缓存中的值

参数ref是用于计算模板测试能否通过的引用值,参数mask用于控制引用值的哪些位和缓存值进行比较(the mask parameter lets you control which bits of the reference and the buffer are compared)。下面的伪代码演示了模板测试的原理。(还需要继续研究各个参数含义)。

GLuint current = GetCurrentStencilContent(x, y);
if (compare(current & mask, ref & mask, front_facing ? front_op : back_op)) {
  passed = true;
} else {
  passed = false;
}

当得到模板测试结果后,下一步需要调用函数glStencilOpSeparate()告诉OpenGL在模版测试和深度测试后对模版缓存如何更新。该函数有4个参数,参数face指定本次调用施加在哪一个面上。参数sfail,dpfail,dppass控制各种模版测试的结果如何被处理,它们的取值可以是下表中的任意值。sfail控制模版测试失败时的执行逻辑,dpfail指定深度测试失败时的执行逻辑,dppass控制深度测试通过时的逻辑。需要注意的是模版测试的时间早于深度测试,如果一个片段在模版测试时失败,那么该片段会被立即丢弃,也就不会进入后续的深度测试。

函数 处理结果
GL_KEEP 不修改模版缓存的值
GL_ZERO 将模版缓存的值设置为0
GL_REPLACE 使用参考值替换模版缓存的值
GL_INCR 使用饱和度递增(Increment stencil with saturation)
GL_DECR 使用饱和度递减
GL_INVERT 将模版缓存值按位取反
GL_INCR_WRAP 不使用饱和度递增(Increment stencil without saturation)
GL_DECR_WARP 不使用饱和度递减

模版测试、和深度测试的流程如下。第一步调用函数glClearBufferiv()将模版缓存重置为0,参数buffer传入GL_STENCIL,drawBuffer传入0,value指向值为0的变量。第二步,绘制一个包含诸如玩家分数和统计信息的窗口边界,调用函数glStencilFuncSeparate,将参考值设置为1,并设置测试函数为GL_ALWAYS。第三步,在几何边界被绘制后,调用函数glStencilOpSeparate指定当深度测试通过时使用参考值替换模版缓存中的值。

经过上述处理后,除边界区域内的值为1,其余值都为0。最后设置模版状态,使当模版缓存为0的时候模版测试通过,再渲染图像。这样即可使得重写边界的像素点都不能通过模版测试,就不会被绘制到帧缓存中。下面的伪代码演示了模版测试如何使用。

// Clear stencil buffer to 0
const GLint zero; 
glClearBufferiv(GL_STENCIL, 0, &zero);
// Setup stencil state for border rendering
glStencilFuncSeparate(GL_FRONT, GL_ALWAYS, 1, 0xff); 
glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_ZERO, GL_REPLACE);
// Render border decorations
...
// Now, border decoration pixels have a stencil value of 1
// All other pixels have a stencil value of 0.
// Setup stencil state for regular rendering,
// fail if pixel would overwrite border
glStencilFuncSeparate(GL_FRONT_AND_BACK, GL_LESS, 1, 0xff); 
glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_KEEP, GL_KEEP);
// Render the rest of the scene, will not render over stenciled border content
...
(这里参考值是1,GL_LESS应该使得参考值无法比任何模版值小,应该无法通过测试吧)
这里似乎应该使用GL_GREATER。

当想要设置的图元朝向为GL_FRONT_AND_BACK可以使用函数glStencilFunc()和glStencilOp()替代函数glStencilFuncSeparate()和glStencilOpSeparate()。

控制模版缓存的更新(Controlling Updates to the Stencil Buffer)
巧妙的控制模版缓存操作的模式(如将所有片段的模版缓存值设置为同一个值,)可以让模版缓存的操作变得更加灵活。此外我们还可以更新模版缓存值二进制表示的某一位的值。函数glStencilMaskSeparate()可以指定模版缓存值的二进制表示中哪些部分会被更新,哪些部分会被保留。

void glStencilMaskSeparate(GLenum face, GLuint mask);

和函数glStencilFuncSeparate()一样,参数face指定对何种朝向的图元生效,参数mask的位域和模版缓存值的位对应(The mask parameter is a bitfield that maps to the bits in the stencil buffer)。如果模版缓存的位小于32(大多数OpenGL的实现支持的最大模板缓存位数为8),只有蒙版的最低有效位才被使用(only that many of the least significant bits of mask are used)。如果mask的某个位被设置为1,模板缓存中对应的位会被更新,反之则不会被更新。

GLuint mask = 0x000f;
glStencilMaskSeparate(GL_FRONT, mask);
glStencilMaskSeparate(GL_BACK, ~mask);

在上面的示例代码中,将前向和图像图元的处理逻辑打包在了一个变量mask中。第一个函数设置了朝向前面的图元,只允许模板缓存的低4位可以被更新。第二个函数作用于朝向后的图元,只允许模板缓存的高12位被更新。

2.3 深度测试(Depth Testing)

模板操作后的逻辑比较复杂,如果深度测试被启用,OpenGL将会使用深度缓存中的值对每个片段进行深度测试。如果深度写入也被启用并且片段通过了深度测试,该片段的深度值将会覆盖起对应的深度缓存值。如果深度测试失败,该片段将会被丢弃,就不会进入到后续的片段处理。

图元由一组顶点组成,每个顶点坐标都包含z轴分量,在深度测试阶段该值已经经历缩放(可见性检测可以被关闭,即使片段对应的深度缓存值位于0和1之外),并且只有0到1之间的片段才是可见的(标准设备空间的轴范围是0到1,还是-1到1,它们之间有什么联系)。The input to primitive assembly is a set of vertex positions that make up primitives. Each has a z coordinate. This coordinate is scaled and biased such that the normal visible range of values lies between zero and one。

这个值通常被存储在深度缓存中。在深度测试时,OpenGL将会从深度缓存中读取当前片段深度值,将它和从当前处理的片段计算出的深度值比较。如果测试通过,会使用该片段的深度值覆盖对应的深度缓存中的值。

void glDepthFunc(GLenum func);

你可以设置在哪种情况下片段能够通过深度测试,只需要调用函数glDepthFunc(),函数的原型如上。参数func为能够通过深度测试的函数。可用的枚举值及其含义如下。

函数 含义
GL_ALWAYS 总是通过,所有的片段都能进入后续的管线处理阶段
GL_NEVER 永远不会通过,所有的片段都被丢弃
GL_LESS 如果片段计算出的深度值比深度缓存中的值低,测试通过
GL_LEQUAL 如果片段计算出的深度值比深度缓存中的值低,或者相等,测试通过
GL_EQUAL 如果片段计算出的深度值和深度缓存中的值相等,测试通过
GL_NOTEQUAL 如果片段计算出的深度值和深度缓存中的值不想等,测试通过
GL_GREATER 如果片段计算出的深度值比深度缓存中的值大,测试通过
GL_GEQUAL 如果片段计算出的深度值比深度缓存中的值大或者相等,测试通过

如果深度测试被禁用,则所有片段的深度测试默认通过。需要注意的是,只有当深度测试被启用时,深度缓存的值才会被更新。如果想要几何型被无条件的写入到深度缓存中,必须启用深度缓存,并将函数类型设置为GL_ALWAYS 。默认情况下,深度缓存被禁用,通过调用函数glEnable(GL_DEPTH_TEST)可以开启深度测试。如果想要再次禁用深度测试,调用函数glDisable(GL_DEPTH_TEST)即可。

控制深度缓存的更新 Controlling Updates of the Depth Buffer

函数glDepthMask(GL_TRUE)和函数glDepthMask(GL_FALSE)可以开启或关闭更新深度缓存的行为,但是需要注意的是如果深度测试没有开启,即时允许更新深度缓存也不会生效。默认情况下,深度缓存允许被更新。

深度夹逼 Depth Clamping

OpenGL使用一个位于0和1之间的值来表示每个片段的深度,深度值为0的片段位于近平面上,深度值为1表示该片段达到远裁剪面,但不是无限远。为了一处远平面,并且能够绘制任意远处的片段,我们需要在深度缓存中存储任意大的数值,尽管这些数值几乎不存在。为了绕过这个现象,OpenGL允许关闭以近平面和远平面为基础的图元裁剪特性,反之将所有的图元深度夹逼至0和1之间。这就意味着位于近平面之前和远平面之后的图元都会被投影到对应的平面上。

开启深度夹逼,同时禁用图元投影空间裁剪调用函数glEnable(GL_DEPTH_CLAMP),反之调用函数 glDisable(GL_DEPTH_CLAMP)。下图举例说明了当开启深度逼近后,位于裁剪空间外表的图元绘制结果。

OpenGL 片段处理和帧缓存A[WIP]_第3张图片

图中在未开启深度逼近时,左图为视截锥体的横截面,图中黑色的线表示被裁剪空间截取留下的图元,而虚线部分表示被裁剪掉的图元。但深度逼近被开启后,所有图元的深度值都会被映射至0和1之间,小于0的值会直接被置为0,同样大于1的值会被直接置为1。中间的图演示了这种平行投影效果。最后图元的绘制效果如右图。黑线表示了最终被写入到深度缓存中的值。一个深度逼近的例子如下。Demo传送门

OpenGL 片段处理和帧缓存A[WIP]_第4张图片

在上图中,模型由于太靠近观察点导致部分图元位于近平面外,从而被裁剪掉。因此在近平面之前的几何图形将不会被渲染,导致模型上留下一个空洞。在右图中,深度逼近被开启。左图中由于图元裁剪形成的空洞被填上了。尽管严格的讲,我们在深度缓存中保存了错误的值,但是这并未造成视觉异常,并且最终渲染出的图形看上去也比左图好很多。

2.4 早期测试(Early Testing)

从逻辑上看,深度测试和模版测试会晚于片段着色器执行,但是大多数图形处理硬件都默认在片段着色器运行之前执行深度和模版测试,这样可以避免对于测试失败图元处理时造成的性能浪费。但是当着色器有负面作用(side effects)(如直接绘制到纹理特性时),或者其他能够影响测试结果的特性时,OpenGL不能先执行模版测试和深度测试。此时,深度测试或者更新模版缓存的行为都会在片段着色器运行完成后才能执行。

一个能够使得深度测试晚于片段着色器执行的例子是在片段着色器内部为内建变量gl_FragDepth赋值。

内建变量gl_FragDepth用于写入更新后的深度值。如果片段着色器内部未对该变量赋值,那么OpenGL会通过插值的方式生成对应片段的深度值。你可以完成生成一个新的深度值,或者根据片段的标准设备坐标的z轴分量gl_FragCoord.z计算出深度值。在深度测试时,该值用作参考值,并且如果深度测试通过,会被写入到深度缓存中。你可以使用这个功能进行一些特殊操作,如通过轻微的改变深度缓存中的值从而创造出崎岖不平的平面。当然你也需要创建类似的平面确保它们看上去崎岖不平,但是当使用该深度缓存对新的片段进行测试时,测试的结果将会和创建出的平面相匹配。

OpenGL提供了一些关键字用于控制内部如何处理不同的深度测试结果。首先再确定深度缓存的值为0到1之间,深度测试的比较运算符为GL_LESS、GL_GREATER等。如果将比较运算符设置为GL_LESS(即当被测试的片段深度比深度缓存中对应位置的值小时,深度测试通过),只要赋给gl_FragDepth的值比对应的深度缓存中的值小,那么深度测试一定会通过。

如果片段着色器无论运行的结果如何,都能够确定深度测试是否会顺利通过,那么OpenGL就可以先执行深度测试,再运行片段着色器,即时这和逻辑管线的处理顺序并不一致。

通过重定义内建变量gl_FragDepth可以声明片段着色器内部深度值计算值和OpenGL内部通过该片段的z值插值运算计算出值的预期比较结果。可用的重定义格式如下。参考资料。需要注意的是,这里只是声明预期结果,被写入深度缓存的值还是我们赋值给变量gl_FragDepth的值,OpenGL内部并不会修改我们的赋值以满足约束条件。

layout (depth_any) out float gl_FragDepth; 
layout (depth_less) out float gl_FragDepth; 
layout (depth_greater) out float gl_FragDepth; 
layout (depth_unchanged) out float gl_FragDepth;

关键字depth_any是默认的实现,表示生成的深度缓存可以是任意值,此时OpenGL并不知道深度测试的结果,深度测试的时机晚于着色器运行。关键字depth_less表示生成的深度缓存一定小于当前被测试片段通过对z轴进行插值而计算出的值,同样的关键字depth_greater表示生成的深度缓存值一定大于当前被测试片段通过对z轴进行插值而计算出的值。

在使用关键字depth_less和depth_greater时,如果OpenGL能够通过关键字的定义和深度测试函数的定义判定出第一次深度测试结果失败(使用插值而计算出的深度值),则深度测试一定失败时(如定义depth_less,函数设置为GL_GREATER或者GL_GEQUAL),对于这些片段就不会运行片段着色器,从而提升处理效率。

关键字depth_unchanged表示生成的深度缓存值和插值计算出的深度值相同。You might choose to use this if you plan to perturb the fragment’s depth slightly, but not in a way that would make it intersect any other geometry in the scene (or if you don’t care if it does).(该使用场景需要确定如何使用)。

无论在片段着色器中重定义变量gl_FragDepth时使用了何种关键字,写入到其中的值都会被夹逼至0到1之间。

3 颜色输出(Color Output)

在OpenGL渲染管线中,颜色输出是片段被写入到帧缓存中的最后一个阶段。它决定了颜色数据在离开片段着色器到最终呈现到桌面上之间的逻辑操作。

3.1 混合(Blending)

颜色混合执行的时机在片段测试和片段着色器运行之后,片段着色器处理的结果可以称为原始颜色,颜色混合可以将原始颜色和颜色缓存中的值相互混合从而得到最终的颜色。如果绘制的缓存是一个固定的点If the buffer you are drawing to is fixed point,OpenGL会在混合操作之前自动将元素颜色值截取至0~1内。调用函数 glEnable(GL_BLEND)可以启用颜色混合,反之调用函数glDisable(GL_BLEND)可以禁用颜色混合。

OpenGL的颜色混合功能非常强大并且具有很强的可装配性。首先分别使用source factor和原始颜色相乘,使用destination factor和帧缓存内的颜色相乘,再通过定义的操作符blend equation对上述两个结果进行计算得到最终的值。

混合函数(Blend Functions)

通过调用函数glBlendFunc()或者glBlendFuncSeparate()可以设置颜色混合函数。函数glBlendFunc可以设置RGBA4个颜色的原始颜色,和帧缓存颜色系数,而函数glBlendFuncSeparate在此基础上可以为RGB3个颜色通道和A透明通道分别设置系数。这两个函数的原型如下。

glBlendFunc(GLenum src, GLenum dst);
glBlendFuncSeparate(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstaAlpha);

上述两个函数中,颜色系数的可选枚举值如下。

混合函数 RGB值 Alpha值
GL_ZERO (0, 0, 0) 0
GL_ONE (1, 1, 1) 1
GL_SRC_COLOR (Rs0, Gs0, Bs0) As0
GL_ONE_MINUS_SRC_COLOR (1, 1, 1) - (Rs0, Gs0, Bs0) 1-As0
GL_DST_COLOR (Rd, Gd, Bd) Ad
GL_ONE_MINUS_DST_COLOR (1, 1, 1) - (Rd, Gd, Bd) 1− Ad
GL_SRC_ALPHA (As0, As0, As0) As0
GL_ONE_MINUS_SRC_ALPHA (1, 1, 1) - (As0, As0, As0) 1− As0
GL_DST_ALPHA (Ad, Ad, Ad) Ad
GL_ONE_MINUS_DST_ALPHA (1, 1, 1) - (Ad, Ad, Ad) 1− Ad
GL_CONSTANT_COLOR (Rc, Gc, Bc) Ac
GL_ONE_MINUS_CONSTANT_COLOR (1, 1, 1) - (Rc, Gc, Bc) 1− Ac
GL_CONSTANT_ALPHA (Ac, Ac, Ac) Ac
GL_ONE_MINUS_CONSTANT_ALPHA (1, 1, 1) - (Ac, Ac, Ac) 1− Ac
GL_ALPHA_SATURATE (f, f, f) f = min(As0, 1 - Ad) 1
GL_SRC1_COLOR (Rs1, Gs1, Bs1) As1
GL_ONE_MINUS_SRC1_COLOR (1, 1, 1) - (Rs1, Gs1, Bs1) 1− As1
GL_SRC1_ALPHA (As1, As1, As1) As1
GL_ONE_MINUS_SRC1_ALPHA (1, 1, 1) - (As1, As1, As1) 1− As1

其中表示第一个原始颜色RGBA值的变量分别为Rs0,Gs0,Bs0,和As0,表示第二个原始颜色RGBA值的变量分别为Rs1,Gs1,Bs1,和As1,表示目标颜色(当前帧缓存内部颜色)RGBA值的变量分别为Rd,Gd,Bd,和Ad。颜色混合常量的RGBA值的变量分别表示为Rc,Gc,Bc,和Ac。颜色混合常量可以通函数glBlendColor()设置。

glBlendColor(GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha);

下面是一个简单的例子,对应的部分代码如下。在该段代码中将颜色缓存区重置为橘色,开启颜色混合,并将颜色混合常数设置为蓝色。然后使用不同的颜色混合函数绘制了一系列的立方体。Demo地址

static const GLfloat orange[] = { 0.6f, 0.4f, 0.1f, 1.0f }; 
glClearBufferfv(GL_COLOR, 0, orange);
static const GLenum blend_func[] = {
    GL_ZERO,
    GL_ONE,
    GL_SRC_COLOR,
    GL_ONE_MINUS_SRC_COLOR,
    GL_DST_COLOR,
    GL_ONE_MINUS_DST_COLOR,
    GL_SRC_ALPHA,
    GL_ONE_MINUS_SRC_ALPHA,
    GL_DST_ALPHA,
    GL_ONE_MINUS_DST_ALPHA,
    GL_CONSTANT_COLOR,
    GL_ONE_MINUS_CONSTANT_COLOR,
    GL_CONSTANT_ALPHA,
    GL_ONE_MINUS_CONSTANT_ALPHA,
    GL_SRC_ALPHA_SATURATE,
    GL_SRC1_COLOR,
    GL_ONE_MINUS_SRC1_COLOR,
    GL_SRC1_ALPHA,
    GL_ONE_MINUS_SRC1_ALPHA
};

static const int num_blend_funcs = sizeof(blend_func) / sizeof(blend_func[0]);
static const float x_scale = 20.0f / float(num_blend_funcs); 
static const float y_scale = 16.0f / float(num_blend_funcs); 
const float t = (float)currentTime;
glEnable(GL_BLEND); 
glBlendColor(0.2f, 0.5f, 0.7f, 0.5f); 
for (j = 0; j < num_blend_funcs; j++) {
    for (i = 0; i < num_blend_funcs; i++) {
        vmath::mat4 mv_matrix =
        vmath::translate(9.5f - x_scale * float(i), 7.5f - y_scale * float(j),
                             -50.0f) *
        vmath::rotate(t * -45.0f, 0.0f, 1.0f, 0.0f) *
        vmath::rotate(t * -21.0f, 1.0f, 0.0f, 0.0f);
        glUniformMatrix4fv(mv_location, 1, GL_FALSE, mv_matrix);
        glBlendFunc(blend_func[i], blend_func[j]);
        glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0); 
    }
}

程序运行的结果如下图。

OpenGL 片段处理和帧缓存A[WIP]_第5张图片
双源混合(Dual-Source Blending)

在前面颜色混合系数枚举值的表格中有列出两个源颜色的选项。实际上在片段着色器中可以使用声明索引关键字的方式为某个颜色缓存生成多个最终颜色。使用方式如下。

layout (location = 0, index = 0) out vec4 color0; 
layout (location = 0, index = 1) out vec4 color1;

clolor0_0被用于系数GL_SRC_COLOR,color0_1用于系数GL_SRC1_COLOR。当使用双源混合函数时,颜色缓存的数量可能被限制,可以通过查询GL_MAX_DUAL_SOURCE_DEAW_BUFFERS查询当前在多源颜色混合时支持的颜色缓存数。

混合公式

源颜色、目标颜色分别和源、目标系数相乘后得到的值回再经过一次计算得到最终的颜色值。计算的方式可以通过调用函数glBlendEquation()或者glBlendEquationSeparate()设置。和颜色混合函数一样,函数glBlendEquation对RBGA通道统一设置,而函数glBlendEquationSeparate可以分别对RGB、和A通道设置不同的计算方式。

glBlendEquation(GLenum mode);
glBlendEquationSeparate(GLenum modeRGB, GLenum modeAlpha);

上述两个函数的参数可选枚举值如下。

公式 RGB通道 Alpha通道
GL_FUNC_ADD Srgb ∗ RGBs+ Drgb ∗ RGBd Sa∗As+ Da ∗ Ad
GL_FUNC_SUBTRACT Srgb ∗ RGBs− Drgb ∗ RGBd Sa∗As− Da ∗ Ad
GL_FUNC_REVERSE_SUBTRACT Drgb ∗ RGBd− Srgb ∗ RGBs Da∗Ad− Sa∗As
GL_MIN min(RGBs, RGBd) min(As , Ad )
GL_MAX max(RGBs, RGBd) min(As , Ad )

上表中,RGBs和RGBd分别表示源颜色和目标颜色的红色、绿色和蓝色分量,As和Ad分别表示源颜色和目标颜色的透明度分量。Srgb和Drgb分别表示源颜色和目标颜色在红色、绿色和蓝色上的混合因子,Sa和Da分别表示源颜色和目标颜色在透明的通道上的混合因子。

3.2 逻辑操作(Logical Operations)

如果片段的颜色值和帧缓存内的颜色值具有相同的格式和位深度,还有两个方法可以影响到最终结果的步骤。第一个方式允许对颜色值进行额外的逻辑操作。当该功能开启时,颜色混合的结果将会被忽略。逻辑操作不会影响到浮点数缓存(Logic operations do not affect floating-point buffers.),该功能可以通过调用函数glEnable(GL_COLOR_LOGIC_OP)开启,通过glDisable(GL_COLOR_LOGIC_OP)关闭。

逻辑操作通过输入像素的值和帧缓存内的值计算出一个颜色值。通过函数glLogicOp()可以选择计算的公式。函数原型如下。

glLogicOp(GLenum op);

其中颜色计算公式参数op可选的枚举值如下。其中位或为位运算符|。

公式 计算结果
GL_CLEAR Set all values to 0
GL_AND Source & Destination
GL_AND_REVERSE Source & ~Destination
GL_COPY Source
GL_AND_INVERTED ~Source & Destination
GL_NOOP Destination
GL_XOR Source ^Destination
GL_OR Source 位或 Destination
GL_NOR ~(Source 位或 Destination)
GL_EQUIV ~(Source ^Destination)
GL_INVERT ~Destination
GL_OR_REVERSE Source 位或 ~Destination
GL_COPY_INVERTED ~Source
GL_OR_INVERTED ~Source 位或 Destination
GL_NAND ~(Source & Destination)
GL_SET Set all values to 1

逻辑操作可以作用在单个颜色通道上,并且逻辑操作的计算过程都是按位计算的。现代图像程序已经不常用逻辑计算,但是由于通常GPU都支持该特性,因此OpenGL保留了该功能。

3.3 颜色蒙版(Color Masking)

片段的颜色值最终被写入到帧缓存前到另一个可以修改颜色的方法是启用颜色蒙版功能。到目前为止,我们了解到片段着色器可以写入三种不同类型的数据,分别是颜色、深度和模版数据。正如你可以屏蔽mask off模版缓存和深度缓存的更新,你也可以对颜色缓存的更新应用遮罩。Just as you can mask off updates to the stencil and depth buffers, you can also apply a mask to the updates of the color buffer.

调用函数glColorMask()可以glColorMaski()可以屏蔽或者阻止颜色写入。其函数原型如下。其中参数red, green, blue, alpha为四个Boolean类型的变量,分别控制颜色缓存中对应的四个通道是否能够被更新。第一个函数对当前渲染环境的所有颜色缓存都有效,而第二函数中参数index控制生效的颜色缓存索引值(如果使用离屏渲染的话,可以使用多个颜色缓存)。

glColorMask(GLboolean red, GLboolean green, GLboolean blue, GLboolean alpha);
glColorMaski(GLuint index, GLboolean red, GLboolean green, GLboolean blue, GLboolean alpha);
屏蔽使用Mask Usage

对于很多操作而言,写入屏蔽都是非常有效的。例如在使用深度信息填充阴影体的时候,由于只有深度信息是重要的,因此你可以屏蔽掉所有的颜色写入。或者当你只想要在屏幕上绘制一张平面的贴纸,你可以禁用深度写入,从而避免深度数据被破坏。使用屏蔽功能要注意的一点是,你可以在设置该功能后立即调用渲染函数而不需要知道遮罩的状态,这会设置必要的缓存状态,以及输出所有的颜色、深度和模版数据。你不需要改变你的着色器使其不会写入任何数据,将缓存分离,或者改变已经启用的绘制缓存You don’t have to alter your shaders to not write some value, detach some set of buffers, or change the enabled draw buffers。剩余的渲染路径会被完全忽视,而此时仍能得到正确的结果。

4 离屏渲染(Off-Screen Rendering)

到目前为止,文中所有的例子都直接将模型渲染至一个窗口中或者计算机的主显示屏上。片段着色器的输出都会被写入到一个备用的缓存(back buffer)中,该缓存被操作系统或者程序正在运行的窗口系统所持有,最终再被呈现给用户。当你为渲染上下文选择一个格式时,它的参数即被设置。对于特定的操作平台,这意味这你能够在一定程度上控制底层的存储格式。为了使得本文的例子能够在大多数平台上运行,文中的程序框架都已经设置好了缓存的格式(the book’s application framework takes care of setting this up for you),省略了很多细节。

然而OpenGL包括咯很多特性允许你使用自己的帧缓存,从而直接渲染到纹理中。这些纹理接下来可以用于进一步的渲染或者处理。你仍可以对帧缓存的布局和格式进行一定的控制。例如,当你使用默认的帧缓存是,其尺寸默认和窗口或者显示器的尺寸一致,当渲染的逻辑不能立即在屏幕上响应时and rendering outside the display(例如窗口被遮蔽或者被拖离屏幕时),这些渲染逻辑是未定义的,相应的像素片段着色器可能不会运行。然而,使用用户提供的帧缓存(user-supplied framebuffers),你想要渲染的纹理的最大尺寸受限于你正在运行的OpenGL实现支持的最大尺寸,另外OpenGL的实现也会定义渲染的位置。

OpenGL提供帧缓存对象(framebuffer objects)用于自定义帧缓存。和OpenGL中的大多数对象一样,每个帧缓存对象在被创建之前必须为其保留一个名字,实际上当该对象第一次被绑定时才会被创建。因此,首先需要做的事为一个帧缓存对象保存一个名字,然后将其绑定至上下文从而初始化该对象。生成帧缓存对象名字调用函数glGenFramebuffers(),将其绑定至当前上下文调用函数glBindFramebuffer()。

void glGenFramebuffers(GLsizei n, GLuint * ids);
void glBindFramebuffer(GLenum target, GLuint framebuffer);

函数glGenFramebuffers为n个帧缓存对象生成标识符,并将生成的结果写入ids地址中。函数glBindFramebuffer将你定义的帧缓存设置为当前缓存(而不是默认的缓存),参数framebuffer需要传入之前已经生成的帧缓存对象的标识符,参数target通常设置为GL_FRAMEBUFFER。当然,你也可以同时绑定两个帧缓存,一个用于读,而另外一个用于写数据。

在绑定帧缓存时,target参数设置为GL_READ_FRAMEBUFFER可以指定该帧缓存为只读类型,同样的GL_DRAW_FRAMEBUFFER可以指定只写类型。绑定的只写属性帧缓存被用作所有渲染命令的终端,其中包括在模版和深度测试时用到的模版值和深度缓存值,以及颜色混合时读入的颜色值and colors read during blending。如果你想要读入备用的像素数据或者想将帧缓存的数据拷贝到纹理中,绑定的只读帧缓存可以作为这些操作的数据源,关于这点接下来会简要介绍。通常,我们在绑定帧缓存时参数选择GL_FRAMEBUFFER将帧缓存设置为可读可写类型。

一旦你创建了一个帧缓存对象并将其绑定至当前的上下文中,你可以为其附着纹理对象,使其能够成为你接下来的渲染命令的存储媒介。帧缓存对象支持三种不同的附着类型,深度、模版和颜色附着类型,它们分别用于深度、模版和颜色缓存。为帧缓存对象附着纹理对象,需要调用函数glFramebufferTexture(),其原型为。

void glFramebufferTexture(GLenum target, GLenum attachment, GLuint texture, GLint level);

其中参数target为你想要附着纹理的帧缓存对象的绑定点,可以是GL_READ_FRAMEBUFFER、GL_DRAW_FRAMEBUFFER和GL_FRAMEBUFFER。在该函数中,参数GL_FRAMEBUFFER和参数GL_READ_FRAMEBUFFER等价,如果你使用了这种类型,OpenGL将会将纹理对象附着至GL_DRAW_FRAMEBUFFER绑定点下的帧缓存对象。

参数attachment指定了纹理附着类型,当需要将纹理附着至深度缓存附件时传入GL_DEPTH_ATTACHMENT、附着至模版缓存附件时GL_STENCIL_ATTACHMENT。因为多种纹理格式都同时包含深度和模版值,因此OpenGL提供了额外的参数GL_DEPTH_STENCIL_ATTACHMENT,当深度缓存和模版缓存用到了同一个纹理时可以设置该参数。

当需要将纹理附着至颜色缓存时,参数attachment可以设置为GL_COLOR_ATTACHMENT0。实际上在需要附着多个纹理用于渲染时,该参数还可以指定为GL_COLOR_ATTACHMENT1和GL_COLOR_ATTACHMENT2等。稍后会介绍多颜色纹理附着的细节,这里首先介绍一个例子说明如何设置帧缓存对象用于图像渲染。

参数texture是你想要附着到帧缓存上的纹理索引,参数level是渲染纹理时使用到的mipmap特性等级(就是有多个不同级别的分辨率)。下面的代码演示了一个如何使用深度缓存和纹理初始化一个帧缓存对象用于渲染图像。

// Create a framebuffer object and bind it
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// Create a texture for our color buffer
glGenTextures(1, &color_texture); 
glBindTexture(GL_TEXTURE_2D, color_texture); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 512, 512);
// We’re going to read from this, but it won’t have mipmaps,
// so turn off mipmaps for this texture. 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// Create a texture that will be our FBO’s depth buffer
glGenTextures(1, &depth_texture);
glBindTexture(GL_TEXTURE_2D, depth_texture); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_DEPTH_COMPONENT32F, 512, 512);
// Now, attach the color and depth textures to the FBO
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, color_texture, 0);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depth_texture, 0);
// Tell OpenGL that we want to draw into the framebuffer’s color attachment
static const GLenum draw_buffers[] = { GL_COLOR_ATTACHMENT0 }; 
glDrawBuffers(1, draw_buffers);

上面的代码块执行完毕后,我们需要做的事情时调用函数glBindFramebuffer再次绑定我们创建的帧缓存对象,后面所有的渲染指令都会将图像渲染到附着至该帧缓存=存对象的纹理中。一旦渲染逻辑完成,我们可以在着色器中将其看成是一个普通纹理,从中读取数据。示例代码如下。

// Bind our off-screen FBO
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// Set the viewport and clear the depth and color buffers
glViewport(0, 0, 512, 512);
glClearBufferfv(GL_COLOR, 0, green);
glClearBufferfv(GL_DEPTH, 0, &one);
// Activate our first, non-textured program
glUseProgram(program1);
// Set our uniforms and draw the cube.
glUniformMatrix4fv(proj_location, 1, GL_FALSE, proj_matrix); 
glUniformMatrix4fv(mv_location, 1, GL_FALSE, mv_matrix); 
glDrawArrays(GL_TRIANGLES, 0, 36);
// Now return to the default framebuffer
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// Reset our viewport to the window width and height, clear the depth and color buffers.
glViewport(0, 0, info.windowWidth, info.windowHeight); 
glClearBufferfv(GL_COLOR, 0, blue);
glClearBufferfv(GL_DEPTH, 0, &one);
// Bind the texture we just rendered to for reading
glBindTexture(GL_TEXTURE_2D, color_texture);
// Activate a program that will read from the texture
glUseProgram(program2);
// Set uniforms and draw
glUniformMatrix4fv(proj_location2, 1, GL_FALSE, proj_matrix); 
glUniformMatrix4fv(mv_location2, 1, GL_FALSE, mv_matrix); 
glDrawArrays(GL_TRIANGLES, 0, 36);
// Unbind the texture and we’re done.
glBindTexture(GL_TEXTURE_2D, 0);

上面的代码段摘自示例basicfbo,首先绑定了我们自己定义的帧缓存对象,将视口设置为该帧缓存对象的尺寸,使用暗绿色覆颜色缓存。接下来绘制一个简单的立方体模型。绘制的结果将会被保存在我们之前在帧缓存对象附着点GL_COLOR_ATTACHMENT0上附着的纹理中。然后,我们解除了帧缓存对象的绑定,将和窗口关联的默认帧缓存对象重新绑定至当前上下文中。我们再次渲染立方体,这一次我们在着色器中使用之前生成好的纹理对象。第一次绘制的立方体作为一张图片就会显示在新绘制的立方体的每个面上。Demo源码传送门。绘制结果如下图。

OpenGL 片段处理和帧缓存A[WIP]_第6张图片

4.1 多重帧缓存附着(Multiple Framebuffer Attachments)

用户自定义的帧缓存通常也被称为FBOs。一个FBO允许你将模型绘制到你在程序中创建的纹理中。由于纹理被OpenGL所持有并创建,它们不和操作系统耦合,因此使用起来非常灵活。例如纹理的尺寸上限取决于OpenGL,和当前的显示设备无关。同样的你也能够完全控制它们的格式。

用户定义的帧缓存对象另外一个非常有用的特性是它们支持多重附着,这意味着你可以将多个纹理对象附着至同一个帧缓存对象上,这样你可以只使用一个片段着色器将渲染结果同时输入到这些纹理对象中。回忆之前说过使用函数glFramebufferTexture()和参数GL_COLOR_ATTACHMENT0可以附着纹理时,我们说过这里参数也可以传入GL_COLOR_ATTACHMENT1、GL_COLOR_ATTACHMENT2等。实际上,OpenGL至少允许在单个帧缓存对象上附着8个纹理。下面的代码演示了如何为一个FBO添加三个颜色纹理附件。

static const GLenum draw_buffers[] = {
    GL_COLOR_ATTACHMENT0,
    GL_COLOR_ATTACHMENT1,
    GL_COLOR_ATTACHMENT2
};
// First, generate and bind our framebuffer object
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// Generate three texture names
glGenTextures(3, &color_texture[0]);
// For each one...
for (int i = 0; i < 3; i++) {
    // Bind and allocate storage for it
    glBindTexture(GL_TEXTURE_2D, color_texture[i]); 
    glTexStorage2D(GL_TEXTURE_2D, 9, GL_RGBA8, 512, 512);
    // Set its default filter parameters
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    // Attach it to our framebuffer object as color attachments
    glFramebufferTexture(GL_FRAMEBUFFER, draw_buffers[i], color_texture[i], 0);
}
// Now create a depth texture
glGenTextures(1, &depth_texture);
glBindTexture(GL_TEXTURE_2D, depth_texture); 
glTexStorage2D(GL_TEXTURE_2D, 9, GL_DEPTH_COMPONENT32F, 512, 512);
// Attach the depth texture to the framebuffer
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depth_texture, 0);
// Set the draw buffers for the FBO to point to the color attachments
glDrawBuffers(3, draw_buffers);

为了能将渲染结果写入来自于同一个帧缓存对象的多个附件中,在着色器内部我们必须声明多个输出结果,并且将它们和附着点相关联。具体方法是使用布局限定符(layout qualifier)指定每一个输出的位置,输出位置变量由OpenGL提供,它指向了该结果将会输出到的附件的索引值。其使用示例如下。

layout (location = 0) out vec4 color0; 
layout (location = 1) out vec4 color1; 
layout (location = 2) out vec4 color2;

一旦你在片段着色器中声明了多重输出结果,那么你可以为每个输出结果准备不同的数据,这些数据将会被输入到帧缓存对象对应的颜色附件中,其索引值课你为输出结果指定的索引值项对应。需要注意的是,片段着色器仅为每个光栅化阶段产生的片段运行一次,写入到着色器每个输出的数据将会被写入到相应的颜色附件的同一个位置。

你可能感兴趣的:(OpenGL 片段处理和帧缓存A[WIP])