4.2 分层渲染(Layered Rendering)
在纹理章节中我们介绍了数组纹理的概念Array Textures,它表示了一组二维纹理,我们可以在着色器中通过索引获取其中的任意一个。同样的通过将一组纹理附着至一个帧缓存对象上,我们可以在几何着色器中指定生成的图元绘制到的纹理索引。下面的代码块节选自例子gslayered,演示了如何使用二维纹理作为颜色附件配置一个帧缓存对象。除了创建一个纹理数组并将其作为颜色附件外,你还可以创建一个保存深度或者模版数据的纹理数组并将其附着至同一个帧缓存对象上。这些纹理数组就可以作为深度和模版缓存,允许你在分层的帧缓存对象中执行深度和模版测试。
// Create a texture for our color attachment, bind it, and allocate
// storage for it. This will be 512 x 512 with 16 layers.
GLuint color_attachment;
glGenTextures(1, &color_attachment);
glBindTexture(GL_TEXTURE_2D_ARRAY, color_attachment);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, 512, 512, 16);
// Do the same thing with a depth buffer attachment.
GLuint depth_attachment;
glGenTextures(1, &depth_attachment);
glBindTexture(GL_TEXTURE_2D_ARRAY, depth_attachment);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_DEPTH_COMPONENT, 512, 512, 16);
// Now create a framebuffer object, and bind our textures to it
GLuint fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, color_attachment, 0);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depth_attachment, 0);
// Finally, tell OpenGL that we plan to render to the color attachment
static const GLuint draw_buffers[] = { GL_COLOR_ATTACHMENT0 };
glDrawBuffers(1, draw_buffers);
一旦纹理被创建并且附着至帧缓存对象上,就可以调用平常的渲染命令将结果输出到纹理内。如果不使用几何着色器,那么所有的绘制结果都会被输入到纹理中的第一层,即索引为0的层。当启用几何着色器时,OpenGL提供内建变量gl_Layer用于控制图元输出的目的地,对该变量的赋值将会被用作图元在分层帧缓存对象中输出的颜色附件中层的索引值。下面的代码演示了在几何着色器中如何处理16份几何图元。每份图元都有不同的模型视图矩阵,它们被保存在一个纹理数组中,同时每次几何着色器的调用都会输出一个颜色值到片段着色器中。
#version 430 core
// 16 invocations of the geometry shader, triangles in and triangles out
layout (invocations = 16, triangles) in;
layout (triangle_strip, max_vertices = 3) out;
in VS_OUT {
vec4 color;
vec3 normal;
} gs_in[];
out GS_OUT {
vec4 color;
vec3 normal;
} gs_out;
// Declare a uniform block with one projection matrix and 16 model-view matrices
layout (binding = 0) uniform BLOCK {
mat4 proj_matrix;
mat4 mv_matrix[16];
};
void main(void) {
int I;
// 16 colors to render our geometry
const vec4 colors[16] = vec4[16](
vec4(0.0, 0.0, 1.0, 1.0), vec4(0.0, 1.0, 0.0, 1.0),
vec4(0.0, 1.0, 1.0, 1.0), vec4(1.0, 0.0, 1.0, 1.0),
vec4(1.0, 1.0, 0.0, 1.0), vec4(1.0, 1.0, 1.0, 1.0),
vec4(0.0, 0.0, 0.5, 1.0), vec4(0.0, 0.5, 0.0, 1.0),
vec4(0.0, 0.5, 0.5, 1.0), vec4(0.5, 0.0, 0.0, 1.0),
vec4(0.5, 0.0, 0.5, 1.0), vec4(0.5, 0.5, 0.0, 1.0),
vec4(0.5, 0.5, 0.5, 1.0), vec4(1.0, 0.5, 0.5, 1.0),
vec4(0.5, 1.0, 0.5, 1.0), vec4(0.5, 0.5, 1.0, 1.0)
);
for (i = 0; i < gl_in.length(); i++) {
// Pass through all the geometry
gs_out.color = colors[gl_InvocationID];
gs_out.normal = mat3(mv_matrix[gl_InvocationID]) * gs_in[i].normal;
gl_Position = proj_matrix * mv_matrix[gl_InvocationID] * gl_in[i].gl_Position;
// Assign gl_InvocationID to gl_Layer to direct rendering
// to the appropriate layer
gl_Layer = gl_InvocationID;
EmitVertex();
}
EndPrimitive();
}
上面的代码运行后可以得到一个数组纹理,其中的每个切片都表示同一个模型的不同角度的视图。明显的,我们不能直接展示一个数组纹理,因此我们将该纹理作为数据源输入到另外一个着色器中。展示该纹理的OpenGL程序的顶点和片段着色器的源码如下。
#version 430 core
out VS_OUT {
vec3 tc;
} vs_out;
void main(void) {
int vid = gl_VertexID;
int iid = gl_InstanceID;
float inst_x = float(iid % 4) / 2.0;
float inst_y = float(iid >> 2) / 2.0;
const vec4 vertices[] = vec4[](vec4(-0.5, -0.5, 0.0, 1.0),
vec4( 0.5, -0.5, 0.0, 1.0),
vec4( 0.5, 0.5, 0.0, 1.0),
vec4(-0.5, 0.5, 0.0, 1.0));
vec4 offs = vec4(inst_x - 0.75, inst_y - 0.75, 0.0, 0.0);
gl_Position = vertices[vid] * vec4(0.25, 0.25, 1.0, 1.0) + offs;
vs_out.tc = vec3(vertices[vid].xy + vec2(0.5), float(iid));
}
#version 430 core
layout (binding = 0) uniform sampler2DArray tex_array;
layout (location = 0) out vec4 color;
in VS_OUT {
vec3 tc;
} fs_in;
void main(void) {
color = texture(tex_array, fs_in.tc);
}
上面的顶点着色器基于顶点的索引值生成一个四边形,并根据当前的调用批次索引值偏移该四边形使得16个实例能够形成一个4乘4的网格。最后使用顶点的x和y值计算出纹理坐标的前两个分量,并使用当前的调用批次索引值作为第第三个分量。因为我们将从数组纹理中获取颜色数据,因此该分量在查询数组纹理颜色时用做纹理层的索引值。片段着色器使用顶点着色器传入的纹理坐标查询颜色值,并将结果绘制到颜色缓存中。
绘制的结果如下图(这里法向量的计算有误,因此暂未应用光照效果)。Demo源码传送们
绘制3D纹理的方法和数组纹理几乎一致。你仅仅需要将整个3D纹理作为颜色附件附着至帧缓存对象上,并且在几何着色器中像平常一样为内建变量gl_Layer赋值。片段着色器的输出会被写入到3D纹理中,而gl_Layer保存的值将会被用做其输入到3D纹理内切片的z轴坐标。你甚至可以同时为同一个纹理(数组或者3D纹理)内部的多个切片渲染图像。该功能可以通过调用函数glFramebufferTextureLayer()实现,其原型如下。
void glFramebufferTextureLayer(GLenum target, GLenum attachment, GLuint texture, GLint level, GLint layer);
函数glFramebufferTextureLayer和函数glFramebufferTexture的使用方式类似,但是它需要额外传入一个参数layer,该参数指定了你希望将纹理的哪一层附着至帧缓存对象之上。下面的代码块创建了一个包含8个层的数组问了,并将每一层都附着到帧缓存对象相应的颜色附件上。
GLuint tex;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D_ARRAY, tex);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, 256, 256, 8);
GLuint fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
int I;
for (i = 0; i < 8; i++) {
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, tex, 0, i);
}
static const GLenum draw_buffers[] = {
GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1,
GL_COLOR_ATTACHMENT2, GL_COLOR_ATTACHMENT3,
GL_COLOR_ATTACHMENT4, GL_COLOR_ATTACHMENT5,
GL_COLOR_ATTACHMENT6, GL_COLOR_ATTACHMENT7 };
glDrawBuffers(8, &draw_buffers[0]);
运行上面的代码块后,片段着色器会有8个输出,每个输出都会被写入到该纹理的不同层之中。
在立方体地图中渲染(Rendering to Cube Maps)
OpenGL将立方体地图认为时一个特殊的数组纹理。一个简单的立方体地图就是一个包含6个切片的纹理数组,而立方体地图数组纹理就是一个包含6个整数倍切片的纹理数组。将立方体地图纹理附着至帧缓存对象的方法和处理2D数组纹理的方式类似,不同的是现在需要创建的是立方体地图纹理而不是2D数组纹理。立方体地图有6个面,用x、y、z轴的正负来表示,立方体出现的顺序和数组纹理中的顺序一致。在几何着色器中为gl_Layer赋值0~5分别表示需要将最终的渲染结果输入至正x轴面、负x轴面、正y轴面、负y轴面、正z轴面和负z轴面。
如果你创建另一个立方体地图数组纹理并将其附着至帧缓存对象之上,那么前6个写入的层将会构建出第一个立方体,紧接着写入的6层将会构建出第二个立方体,并以此类推。这就是说当你向gl_Layer赋值为6时,表示你希望将渲染的结果写入第二个立方体的正x轴面。如果你将gl_Layer赋值为1234,意味着你希望渲染的结果被输入到第205个立方体的正z轴面。
就像对待2D数组纹理一样,我们也可以将一个立方体纹理的不同面附着在同一个帧缓存对象的不同附着点上。要实现该功能需要调用函数glFramebufferTexture2D(),其原型如下。
void glFramebufferTexture2D(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level);
该函数和glFramebufferTexture()类似,只是多了一个额外的参数textarget,该参数指定了立方体的哪一个面需要被附着至参数attachment指向的附件上。其可选的值和6个立方体的面相对应,如GL_CUBE_MAP_POSITIVE_X和GL_CUBE_MAP_NEGATIVE_X。(需要注意的是其使用场景有一定的限制,While this is certainly possible, rendering the same thing to all faces of a cube map has limited utility.)
4.3 帧缓存的完整性(Framebuffer Completeness)
在我们能够完全熟悉使用帧缓存对象之前,这里还有最后一个重要的话题。正如配置了FBO并不意味着你的OpenGL实现就准备好渲染图像这种特性是你满意一样。确定FBO是否正确的被配置并且OpenGL实现能够使用它的唯一方式是检查帧缓存的完整性。帧缓存完整性的概念和纹理完整性的概念类似。如果一个纹理没有包含所有需要等级的分级纹理 If a texture doesn’t have all required mipmap levels,并且他们都没有被指定正确的大小、格式等必要属性,那么这个纹理就是不完整的,它不能够被使用。
帧缓存对象有两种完整性,附件完整性和整体完整性。
附件完整性(Attachment Completeness)
FBO内部的每个附件都必须满足一定条件才能被认为是完整的,只有所有附着点的附件都是完整的,整个帧缓存才可能被认为是完整的。使得附件不完整的一些原因如下。
- 附件并没有和图像相关联,没有分配内存空间。
- 关联的图片的长度或者宽度为0。
- 附着的颜色附件格式不支持写入颜色数据。
- 附着的深度附件格式不支持写入深度数据。
- 附着的模版附件格式不支持写入模版数据。
整体完整性(Whole Framebuffer Completeness)
默认的帧缓存对象如果存在,那么其一定是完整的。使得帧缓存对象整体不完整的一些原因如下。
- glDrawBuffers()函数为一个没有关联图像的FBO附件映射了一个输出。glDrawBuffers() has mapped an output to an FBO attachment where no image is attached.
- OpenGL驱动器不支持器内部的组合格式。The combination of internal formats is not supported by the OpenGL driver.
检查帧缓存(Checking the framebuffer)
当你配置好帧缓存对象后,你可以调用函数glCheckFramebufferStatus判断该对象是否完整。其原型如下。
GLenum fboStatus = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER);
如果该函数返回的是GL_FRAMEBUFFER_COMPLETE,表示当前绑定的帧缓存对象是完整的,可以正常使用。如果帧缓存对象不是完整的,该函数的其他返回值将会给我们一些提示。下表列举了所有可能的返回结果以及它们的含义。
返回值(GL_FRAMEBUFFER_*) | 含义 |
---|---|
UNDEFINED | 当前绑定的FBO索引值为0,但是不存在默认的帧缓存 |
COMPLETE | 当前绑定了一个用户自定义的FBO,它是完整的 |
INCOMPLETE_ATTACHMENT | 某个附件是不完整的 |
INCOMPLETE_MISSING_ATTACHMENT | 当前的FBO不包含任何附件 |
UNSUPPORTED | 不支持当前FBO内部附件的格式组合 |
INCOMPLETE_LAYER_TARGETS | 并非所有的颜色附件都是分层纹理,或者所有的颜色附件并未绑定至同一个目标Not all color attachments are layered textures or bound to the same target. |
在程序的Debug阶段大多数返回值都是很有用的,但是当程序交付后它们就没有什么帮助了。尽管如此,下面的例子仍然检查了帧缓存的完整性,确保上述情况都不会发生。在使用FBO对象的程序中检查完整性是有性能成本的,在使用时需要确保该检查不会对性能产生影响。It pays to do this check in applications that use FBOs, making sure your use case hasn’t hit some implementation-dependent limitation。一个检查完整性的例子如下(这里的类型为什么比表里的多)。
GLenum fboStatus = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER);
if(fboStatus != GL_FRAMEBUFFER_COMPLETE) {
switch (fboStatus) {
case GL_FRAMEBUFFER_UNDEFINED:
// Oops, no window exists?
break;
case GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT:
// Check the status of each attachment
break;
case GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:
// Attach at least one buffer to the FBO
break;
case GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER:
// Check that all attachments enabled via glDrawBuffers exist in FBO
case GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER:
// Check that the buffer specified via glReadBuffer exists in FBO
break;
case GL_FRAMEBUFFER_UNSUPPORTED:
// Reconsider formats used for attached buffers break;
case GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE:
// Make sure the number of samples for each attachment is the same
break;
case GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS:
// Make sure the number of layers for each attachment is the same
break;
}
}
如果当前绑定的帧缓存对象是非完整的,那么你对该帧缓存执行的任何绘制或者读取命令将不会有任何作用,并且OpenGL会抛出GL_INVALID_FRAMEBUFFER_OPERATION类型的错误,你可以通过函数glGetError()检索该错误。
从帧缓存对象中读取数据也需要确保其完整性Read Framebuffers Need to Be Complete, Too!
在前面的例子中我们对绑定至只写属性缓存绑定点GL_DRAW_FRAMEBUFFER上的FBO进行了完整性检查,同样的对于绑定至只读属性缓存绑定点GL_READ_FRAMEBUFFER上的帧缓存对象也需要附件和整体具有完整性才能够正常使用并从中读取数据。因为同一时刻绑定至只读属性缓存绑定点上的帧缓存对象只能有每个类型附件只能启用一个,因此它的完整性检查更简单。
4.4 立体渲染(Rendering in Stereo)
大多数时候我们都使用两只眼睛观察物体,它们分别观察到的两幅画面之间的轻微差异被称为视差位移(Parallax shift),这种差异能够向我们提供额外的景深信息,使得看上去的物体更有立体感。影响景深的因素有很多,包括焦点的深度,光照环境的不同,以及当我们移动视点时物体的移动。OpenGL可以生成一组图片,如果你使用的显示设备允许,这组图像可以分别用于呈现给你的左眼和右眼以提供图片的立体感。
有很多显示设备都可以支持该特性,如双镜筒显示设备binocular displays(包含两个独立的物理显示设备,分别用于双眼观察),百叶窗和偏振显示设备 shutter and polarized displays,这两种设备需要镜片支持,以及自由3D显示设备,这种设备可以直接显示3D图像,用户不需要在脸上穿戴额外的设备。实际上OpenGL并不关注图像是如何被显示的,它只负责生成一组分别用于呈现给左眼和右眼的图像。
显示立体图像需要窗口系统或者操作系统的配合,因此该特性只在部分平台上有效。在后面介绍不同系统中的OpenGL时会介绍该特性的细节。现在我们使用sb6框架中的api来创建一个立体窗口。在自己的实现中,需要重写函数sb6::application::init,在内部需要调用父类方法,并且需要将info.flags.stereo设置为1。由于部分OpenGL的实现要求你的应用布满整个屏幕(称为全屏渲染),你可以将info.flags.fullscreen设置为1以使得应用使用一个全屏幕窗口。代码块如下。
void my_application::init() {
info.flags.stereo = 1;
//Set this if your OpenGL implementation requires fullscreen for stereo rendering.
info.flags.fullscreen = 1;
}
需要注意的时,并非所有的显示设备都支持立体输出,也并非所有的OpenGL实现允许你创建一个立体窗口。当你的显示设备和OpenGL实现都满足条件时,你才可以使用该特性。最简单的渲染立体图像的方式是将整个场景绘制两次。在绘制呈现给左眼的图像之前调用函数glDrawBuffer(GL_BACK_LEFT),在绘制呈现给右眼的图像之前调用函数glDrawBuffer(GL_BACK_RIGHT)。
为了能够生成一组能够组合出立体效果的图像,你需要为左眼和右眼视图分别构建仿射矩阵。需要注意的是我们的模型矩阵将物体转换到了世界空间内,它是一个全局的空间,物体在世界空间内的位置和观察着的位置无关。然而观察矩阵将整个世界转换至以观察者为中心的空间内。由于两只眼睛所处的位置不同,因此他们的观察矩阵一定是不同的。因此当我们渲染左试图时,使用左观察矩阵,渲染右试图时,使用右观察矩阵。
最简单的立体观察矩阵对仅仅将左右两个视图在x轴上做了平行变换,将两个观察点隔开。当然,你也可以向内或者向外做旋转变换以使得两个图像偏离观察中心。你可以使用函数vmath::lookat生成观察矩阵。将你的眼睛放在向左偏离观察者位置中心稍微远一点的位置,看向模型的中心构建出左视图矩阵,类似的构建出右试图矩阵。下面的代码演示了如何构建左右两个视图矩阵。
void my_application::render(double currentTime) {
static const vmath::vec3 origin(0.0f);
static const vmath::vec3 up_vector(0.0f, 1.0f, 0.0f);
static const vmath::vec3 eye_separation(0.01f, 0.0f, 0.0f);
vmath::mat4 left_view_matrix =
vmath::lookat(eye_location - eye_separation, origin, up_vector);
vmath::mat4 right_view_matrix =
vmath::lookat(eye_location + eye_separation, origin, up_vector);
static const GLfloat black[] = { 0.0f, 0.0f ,0.0f, 0.0f };
static const GLfloat one = 1.0f;
// Setting the draw buffer to GL_BACK ends up drawing in
// both the back left and back right buffers. Clear both
glDrawBuffer(GL_BACK);
glClearBufferfv(GL_COLOR, 0, black);
glClearBufferfv(GL_DEPTH, 0, &one);
// Now, set the draw buffer to back left
glDrawBuffer(GL_BACK_LEFT);
// Set our left model-view matrix product
glUniformMatrix4fv(model_view_loc, 1, left_view_matrix * model_matrix);
// Draw the scene
draw_scene();
// Set the draw buffer to back right
glDrawBuffer(GL_BACK_RIGHT);
// Set the right model-view matrix product
glUniformMatrix4fv(model_view_loc, 1, right_view_matrix * model_matrix);
// Draw the scene... again.
draw_scene();
}
上面的代码将同一个场景渲染了两次,如果你绘制的场景非常负责,这个特性非常耗费性能,简单的说它会使得绘制成本翻倍。一个可能的策略是每绘制场景中的一个模型就调用函数切换GL_BACK_LEFT缓存和 GL_BACK_RIGHT缓存。这样更新状态的OpenGL命令(如绑定纹理或者切换程序)就可以只调用一次,但是切换左右绘制缓存的函数性能消耗可能比任何其他状态切换函数更耗费性能。回想前面讲到的多缓存写入特性,这意味着我们可以通过在片段着色器中输出两个向来,从而在同时向两个缓存中写入数据。启用该特性的方法是在设置帧缓存对象的下入缓存时,调用如下函数。
static const GLenum buffers[] = { GL_BACK_LEFT, GL_BACK_RIGHT }
glDrawBuffers(2, buffers);
上面的代码执行后,片段着色器的第一个输出将会被写入到左眼视图的缓存中,第二个输出将会被写入到右眼视图的缓存中。现在我们可以同时渲染左眼和右眼视图。这里我们还没有做好全部的准备工作。即使片段着色器能够输入到两个不同的绘制缓存中,但是默认的帧缓存对象只有1个绘制缓存。
我们可以通过几何着色器将场景绘制到包含两个层的分层帧缓存对象中,一个层为左侧图像准备,另外一个层为右侧图像准备。我们需要运行几何着色器两次,然后使用调用索引作为输出的层索引从而使得数据能够输入到帧缓存对象的两个层中。在每次几何着色器调用时,我们可以两个模型视图仿射矩阵中的某一个来处理所有的顶点着色器中的逻辑。一旦我们完成渲染,帧缓存对象的两个层将会分别包含场景的左右眼视图图像。现在剩下的工作就是渲染一个全屏幕的四边形,并在片段着色器中读取数组纹理的两个层中的数据,将他们写入到两个输出变量中,这样他们将会被写入到左眼和右眼视图中。
下面的代码演示了如何使用几何着色器在一次绘制循环中渲染立体场景的两个视图。
#version 430 core
layout (triangles, invocations = 2) in;
layout (triangle_strip, max_vertices = 3) out;
uniform matrices {
mat4 model_matrix;
mat4 view_matrix[2];
mat4 projection_matrix;
};
in VS_OUT {
vec4 color;
vec3 normal;
vec2 texture_coord }
gs_in[];
out GS_OUT {
vec4 color;
vec3 normal;
vec2 texture_coord;
} gs_out;
void main(void) {
// Calculate a model-view matrix for the current eye
mat4 model_view_matrix = view_matrix[gl_InvocationID] * model_matrix;
for (int i = 0; i < gl_in.length(); i++) {
// Output layer is invocation ID
gl_Layer = gl_InvocationID;
// Multiply by the model matrix, view matrix for the
// appropriate eye and then the projection matrix.
gl_Position = projection_matrix * model_view_matrix * gl_in[i].gl_Position;
gs_out.color = gs_in[i].color;
// Don’t forget to transform the normals...
gs_out.normal = mat3(model_view_matrix) * gs_in[i].normal;
gs_out.texcoord = gs_in[i].texcoord;
EmitVertex();
}
EndPrimitive();
}
下面的代码块演示了一个简单的片段着色器,它从之前准备好的数组纹理中读取两个层的数据,直接将结果写入到左右两个备用缓存中。
#version 430 core
layout (location = 0) out vec4 color_left;
layout (location = 1) out vec4 color_right;
in vec2 tex_coord;
uniform sampler2DArray back_buffer;
void main(void) {
color_left = texture(back_buffer, vec3(tex_coord, 0.0));
color_right = texture(back_buffer, vec3(tex_coord, 1.0));
}
上面的示例程序的运行结果如下图。尽管下面的屏幕截图并不能在立体场景下显示左右眼的视图,然而在该图中我们仍然能够清晰的看见立体渲染特性输出的两张不同的图片。Demo源码传送门。
需要注意的是上图为理想的绘制结果,由于笔者的MackBookPro的使用的是Intel Iris Pro,该型号GPU不支持立体场景的OpenGL上下文,因此Demo运行的效果会因为其环境的不同与上图存在一定的差异。关于Apple设备支持的扩展请移步至该系列文章的首篇会有详细介绍。
5 抗锯齿(Antialiasing)
欠采样噪声时对数据采样率不足的表现,该术语通常用于信号处理领域。当音频信号出现该噪声时,我们会听到尖锐的声音以及吱吱声。你可能已经在早期的视频游戏,音乐贺卡或者包含低成本音频播放器的儿童玩具中有过这样的体验。在处理信号时,如果采样的频率相对于整个内容而言太低就会出现欠采样噪声。能够不失真的保留样本细节的最低频率被称为尼奎斯特频率,它是整个采样内容中最高频率分量的两倍。对于图像而言,欠采样噪声表现为锯齿状的边缘,这些地方会有很强的视觉反差,这些边缘有时被称为锯齿(jaggies)。
处理欠采样噪声(Aliasing)的方式主要有两种。第一种方式是过滤处理,即在采样阶段之前或者采样时移除信号中的高频部分。第二种方式是加大采样率,这样能够记录下更高频率的信息。减少或者移除欠采样噪声的技术称为抗锯齿技术。OpenGL提供了多数方式使得我们在渲染场景时可以使用该技术。它们包括几何形渲染时的过滤,以及多种形式的过采样技术 These include filtering geometry as it is rendered, and various forms of over-sampling。
5.1 过滤(Antialiasing by Filtering)
处理欠采样噪声的最简单方式是在图元绘制的时候对其过滤。OpenGL会计算该图元的像素数量,通过该值为每个片段生成一个alpha值。这个alpha值将会和片段着色器中计算出的alpha值相乘,在最终片段的混合阶段如果源像素和目标像素包含alpha通道时该值将会发挥作用。当片段被绘制到屏幕上时,OpenGL将会使用一个函数混合片段的颜色值以及颜色缓存中当前位置的值。
为了开启这种形式的抗锯齿功能,首先我们需要启用颜色混合并且指定一个合适的颜色混合函数。第二我们需要启用GL_LINE_SMOOTH对线图元进行过滤处理,或者启用GL_POLYGON_SMOOTH对三角形图元进行过滤处理。下图显示了开启线图元过滤特性后的渲染效果。源码传送门
左图中,我们使用线图元模式绘制了一个旋转的立方体,并局部放大了立方体的边连接处。在放大的缩略图中我们可以明显看到斜边上有锯齿状的边缘,这就是欠采样噪声。在右图中我们开启了线图元的过滤特性和颜色混合特性。我们能够明显的感受到在这张图片中线条更加平衡,锯齿状的边缘更少。注意观察放大的部分,我们能够看到线条都有轻微的模糊。这就是过滤的效果,它通过计算线条的覆盖范围,并将线条的颜色和背景色相混合。启用该特性的代码如下。
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_LINE_SMOOTH);
上面的代码看上去只是这么简单吗?当然不是,如果真的这么简单,那么我们应该能够为任何我们喜欢的几何形开启该特性,使得所有的事情看上去都那么完美。实际上,这种形式的抗锯齿技术只能在如上面的例子一样的有限场景内使用。
下面让我们观察一组图片。
左边的图使用纯白色渲染了一个立方体,能够在三角形连接的地方看见不明显的锯齿,但是在立方体的边缘能够看见明显的欠采样噪声。右边的图绘制时使用了类似前面的代码块开启了几何平滑,将其中的GL_LINE_SMOOTH替换为GL_POLYGON_SMOOTH。我们能够看见,尽管立方体的边缘更加平滑,并且大部分的锯齿都消失了,但是仔细观察立方体内部的这些边,他们的锯齿现象变得更严重了。GL_POLYGON_SMOOTH在某些型号GPU上不能使用,笔者的开发环境就不支持该特性,这可能是更推荐接下来我们将要讲到的多重采样特性相关。
对于两个相邻的三角形而言,他们各自的斜边相接部分逻辑上将单个像素切割成了两部分并各自分得一半。首先我们的应用清空了帧缓存,将背景色设置为黑色,然后第一个白色的三角形计算会处理该三角形。OpenGL计算出该像素有一半位于三角形内部,使用0.5作为颜色混合时的alpha参数。此时按照该比例混合黑色和白色,得到了灰度值为128的像素。在绘制相邻三角形的时候,上面的逻辑会被再次执行。此时仍然使用了0.5作为混合因子,但此时颜色缓存区内当前像素的灰度值为128,因此混合后的灰度值为192,这就是我们看见的那些灰色的线。
当一个多边形内部的边缘剪切单个像素时,在这个像素被绘制到屏幕上的时候,OpenGL没有办法知道哪部分已经处理过,哪部分没有被处理过。这样就会形成上图的现象。另外一个问题是,每个像素包含的深度缓存和颜色缓存的数量相等,即在绘制单层纹理时只能使用1个深度缓存,这意味着当某个三角形图元覆盖了一个像素的部分区域时,如果这里已经有一个离观察者更近的三角形图元覆盖了该像素的另外一部分区域时,过滤特性可能会导致后者的深度测试失败,这样会影响整个绘制效果。
围绕这些问题,我们需要更加高级的抗锯齿技术,这些技术主要是通过增加样本的数量来实现。
5.2 多重采样抗锯齿 (Muti-sample Antialiasing)
为了增加图像的采样率,OpenGL可以为屏幕上的每个像素存储多个样本,这个技术称为多重采样抗锯齿技术(multi-sample antialiasing)或者MSAA。OpenGL会对每个每个像素内部的多个位置进行采样,如果该样本位于需要绘制的图元内部,那么便会运行着色器。每次着色器计算出的颜色值都会被写道该像素所有位于图元内部的样本中。每个样本中样本的位置可能会因为OpenGL的实现而不同。下图的例子演示了当1个像素包含1、2、4和8个样本时,一种可能的样本排列。
只有在特定的平台上,默认的帧缓存对象MSAA是默认开启的。在大多数情况下,当你创建渲染窗口时,你需要为默认的帧缓存对象指定一个多重采样格式。在原书的示例程序中,application框架已经处理了这部分逻辑。在b6::application框架下,开启多重纹理只需要重写方法sb6::application::init,在调用父类方法后,你需要将info.samples设置为你想要单个像素中的样本数量。使用方式如下。
virtual void init() {
sb6::application::init();
info.samples = 8;
}
在选择了8重采样抗锯齿后,我们再次渲染一个选择的立方体,其渲染效果如下。源码传送门
首先看最左侧的图片,这里没有使用抗锯齿技术,我们仍能像平常一样看待有很多锯齿。后面的两张图像都选择了8重采样技术,中间的图像和前面简单启用过滤技术处理后的图像一样,锯齿已经明显减弱了,再看最右侧的图片,处理立方体的边缘取得了较好的抗锯齿效果外,我们已经看不到相邻三角形之间的边缘。
对于我们自己创建的多重采样帧缓存对象,其多重采样是默认启用的。但是当你想禁用含有多重采样格式的当前帧缓存对象的多重采样功能,你可以调用函数glDisable(GL_MULTISAMPLE),当然你可以再次调用函数glEnable(GL_MULTISAMPLE)从而开启该特性。当该特性被禁用时,OpenGL将当前的帧缓存对象看作是一个普通的帧缓存对象,每个片段仅采样一次。
5.3 多重采样纹理 (Muti-sample Textures)
前面已经讲过如何使用一个帧缓存对象进行离屏渲染绘制纹理,也讲到了如何使用多重采样帧缓存对象来实现抗锯齿。实际上,窗口系统包含多重采样颜色缓存,因此我们可以结合上面的特性创建一个离屏的多重采样颜色缓存,并将图像渲染到该缓存中。为了实现该特性,我们需要创建一个多重采样纹理并将其附着至用于渲染场景的帧缓存对象中。
首先我们和创建普通纹理一样创建一个纹理标识,并将其绑定至一个多重纹理的绑定点上,如GL_TEXTURE_2D_MULTISAMPLE或者GL_TEXTURE_2D_MULTISAMPLE_ARRAY,其1原型如下。
void glTexStorage2DMultisample(GLenum target, GLsizei samples, GLenum internalformat,
GLsizei width, GLsizei height, GLboolean fixedsamplelocations);
void glTexStorage3DMultisample(GLenum target, GLsizei samples, GLenum internalformat,
GLsizei width, GLsizei height, GLsizei depth, GLboolean fixedsamplelocations);
上面的两个函数的功能和glTexStorage2D()以及glTexStorage3D()类似,但是多了一组额外的参数。第一个多出的参数samples设置该纹理内部总共的样本数。第二个参数fixedsamplelocations决定是否为所有的纹素使用标准的样本位置,还是允许它们在纹理内部的空间位置有变化。通常情况下允许OpenGL变化样本的位置能够提高图像的质量,但是当你的应用绘制模型的方式一样,并不关心它在帧缓存对象内部的位置时,这种特性可能降低一致性甚至产生假象。
当为纹理分配了存储空间后,你可以像处理平常纹理一样调用函数glFramebufferTexture将其附着至帧缓存对象上。下面的例子演示了如何创建深度和演示多重采样纹理。
GLuint color_ms_tex;
GLuint depth_ms_tex;
glGenTextures(1, &color_ms_tex);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, color_ms_tex);
glTexStorage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, 8, GL_RGBA8, 1024, 1024, GL_TRUE);
glGenTextures(1, &depth_ms_tex);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, depth_ms_tex);
glTexStorage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, 8, GL_DEPTH_COMPONENT, 1024, 1024, GL_TRUE);
GLuint fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, color_ms_tex, 0);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depth_ms_tex, 0);
多重纹理有很多限制,首先OpenGL中没有一维或者三维多重采样纹理,其次多重采样纹理不能包含分级贴图。函数glTexStorage3DMultisample()仅仅用于给二维的多重采样数组纹理分配存储空间,该函数和glTexStorage2DMultisample函数都不包含levels参数。因此你在调用函数glFramebufferTexture将纹理附着至帧缓存对象上的时候,参数level直接传入0。也就是说你不能完全像使用其他纹理一样使用多重采样纹理,它们也不支持图元过滤功能。此外你需要在着色器中明确的指定一个多重采样类型从而从多重采样纹理中读取数据。GLSL中支持的多重采样纹理类型有sampler2DMS和sampler2DMSArray,它们分别表示二维多重采样纹理和二维多重采样数组纹理。除此之外,还有isampler2DMS和usampler2DMS 类型,它们表示的纹理类型其数据格式分别为有符号和无符号的整型,isampler2DMSArray和usampler2DMSArray是它们对应的数组纹理类型。
一个典型的多重采样纹理的使用场景是,我们需要在着色器内定制单个纹理内部样本的演示混合方式。当我们使用窗口系统持有的多重采样后缓存渲染场景时When you render into a window-system-owned multi-sampled back buffer,我们并不能完全控制OpenGL处理一个像素内多个样本颜色值混合并计算出最终颜色的方式。然而,如果我们将场景绘制到一个多重采样纹理内部,然后再使用一个自定义的着色器将该纹理绘制到一个全屏的四边形内部时,我们可以在着色器内部自定义一个片段内所有样本的颜色值混合方式。下面的代码演示了如何使用颜色值最亮的样本作为片段的颜色值。
#version 430 core
uniform sampler2DMS input_image;
out vec4 color;
void main(void) {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 result = vec4(0.0);
int I;
for (i = 0; i < 8; i++) {
result = max(result, texelFetch(input_image, coord, i));
}
color = result;
}
样本覆盖率(Sample Coverage)
覆盖率指的是一个片段或者说一个像素被有效图元覆盖的范围。一个片段的覆盖率通常是由OpenGL在光栅化处理时计算(生成一个二进制的值,其中0表示对应的亚像素未被覆盖,1表示被覆盖),但是我们仍能够在一定程度上控制这个过程,并且能够在片段着色器中得到新的片段覆盖信息,有三种方式可供选择。
第一种,你可以命令OpenGL使用片段的alpha值表示该片段中样本的覆盖率,从而计算出需要更新的样本个数。例如如果片段的alpha值为0.4,则表示该片段的样本覆盖率为40%。想要实现该效果,需要调用函数glEnable()并且将参数指定为GL_SAMPLE_ALPHA_TO_COVERAGE。当启用该功能后,OpenGL将会首先会为每个像素内部的每个样本计算覆盖面积,生成一个采样蒙版(sample mask)。接下来会使用你在片段着色器中计算出的alpha值计算出第二个蒙版,然后对这两个蒙版进行逻辑与处理(logically ANDs it with the incoming sample mask)。例如OpenGL计算出某个像素的原始样本覆盖率为66%,而我们计算出某个像素的透明度为40%,则OpenGL会重新计算出纹理的覆盖率为40%乘以66%,结果约等于25%。因此对于8重采样抗锯齿缓存而言,该像素只有两个样本会被写入。
因为我们已经使用每个像素的alpha值计算需要更新的样本数量,因此使用这个Alpha值来混合这些样本和其在帧缓存中对应的样本就没有任何意义了。为了避免当启用混合模式时这些子像素进行不必要的混合,我们可以调用函数 glEnable并传入参数GL_SAMPLE_ALPHA_TO_ONE强制将这些子像素的alpha值设置为1。
使用alpha值从新计算像素的样本覆盖率在样本混合时具有很多优势。当渲染多重采样纹理的时候,整个像素内部alpha混合是平均的,即默认会应用整个片段的alpha值会和需要更新的子样本和其在帧缓存中对应的子样本。当启用 alpha-to-coverage后,实际上位于图元边缘的alpha值相当于经历了抗锯齿处理,这样绘制出的图像看上去会更加自然,更加平滑。在在绘制灌木丛、树林或者有大量半透明刷毛的刷子时该特性非常实用。
第二种,OpenGL同样允许我们调用函数glSampleCoverage在上面的步骤后,再次调整单个片段的样本覆盖率,其原型如下。需要注意这种方式只有在alpha-to-coverage特性被正常启用后才会生效。
void glSampleCoverage(GLfloat value, GLboolean invert)
启用样本覆盖率调整功能方式如下。
glEnable(GL_SAMPLE_COVERAGE);
glSampleCoverage(value, invert);
value的取值范围从0到1,该值会生产一个临时的片段覆盖率蒙版b,在开启透明度覆盖率混合后,会计算出覆盖率蒙版a,最终的片段覆盖率等于蒙版a和蒙版b相乘,实际上它们都会被处理为二进制数据,最后的结果是它们进行位与运算的结果。参数invert决定蒙版a的值是否需要按位取反,可以近似的理解为1-value。但是需要注意的是,glSampleCoverage(value, GL_FALSE)和glSampleCoverage(1-value, GL_TRUE)并不一定会产生同样的效果。
例如,当我们想要渲染两颗重叠的树,对于某个重叠的片段,其中一颗的片段覆盖率为40%,另外一颗的片段覆盖率为60%。很明显我们想使用两个不同的蒙版来进一步计算片段覆盖率。此时我们可以使用如下的方式绘制两个模型。需要注意的是,刚说过这里是使用value生成一个二进制码,而invert是按位取反。因此下面是使用的两个不同的蒙版。
glSampleCoverage(0.5, GL_FALSE);
// Draw first geometry set...
glSampleCoverage(0.5, GL_TRUE);
// Draw second geometry set ...
第三种方式,我们甚至可以直接在片段着色器内部指定片段覆盖率。在片段着色器中,OpenGL提供了两个变量gl_SampleMaskIn[]和gl_SampleMask[]用于实现该特性。第一个是输入变量,它包含OpenGL在光栅化阶段计算出的片段覆盖率,第二个变量是输出变量,用于我们在着色器内部重新设置片段覆盖率。这两个数组中的每一个成员都表示了一个对应的样本是否被该片段覆盖,数组的第一个元素是整个二进制码的最低有效位。如果当前OpenGL的实现支持高于32重采样,那么数字的第一个元素包含前32个样本的覆盖信息,数组的第二个元素包含后32个样本的覆盖信息。
gl_SampleMaskIn中对应的元素如果是1,那么表示对应的样本被该片段覆盖,反之则未被覆盖。如果想使用GPU计算出的覆盖率,我们可以直接将该变量赋值给gl_SampleMask。当然我们使用该特性一定是想自定义片段覆盖率的计算逻辑,如果我们设置给gl_SampleMask中的每个元素都是0的话,这些样本都会被直接丢弃。尽管我们可以将gl_SampleMaskIn中的0改为1,但是这并没有用,因为OpenGL会再次将该元素重置为0。一个简单绕过这个限制的方式是先调用函数glDisable(),并传入参数GL_MULTISAMPLE。这样当着色器运行时,gl_SampleMaskIn中的每一个元素都是打开状态,这样我们就能选择我们想要关闭的样本了。here’s a simple work-around for this. Just disable multi-sampling by calling glDisable() and passing GL_MULTISAMPLE as described earlier.
5.4 样本着色 (Sample Rate Shading)
多重采样抗锯齿技术结局了由几何体过疏采样引发的大多数问题。特别是它捕获了足够的几何细节,并且正确的处理了仅部分区域被图元片段覆盖的像素,重叠的图元和其他一些在线或者三角形边缘造成图像走形的情况。然而它并不能优雅的处理好所有着色器抛出的数据。记住,在一般情况下,一旦OpenGL确定了某个像素和三角形有重叠,它只会调用一次片段着色器,并且将计算的结果写入到所有命中的样本中。这样就不能改准确的捕获能够一个有着高频率输出的着色器的结果。例如,假如有如下的片段着色器。
#version 430 core out vec4 color;
in VS_OUT {
vec2 tc; }
fs_in;
void main(void) {
float val = abs(fs_in.tc.x + fs_in.tc.y) * 20.0f;
color = vec4(fract(val) >= 0.5 ? 1.0 : 0.25);
}
这个极端的例子绘制了一个边缘平直的条带,这样能够产生一个高频信号。对于任意一次片段着色器的调用,都会根据输入的纹理坐标计算并输出一个亮白或者暗灰的颜色。仔细观察左边的图像,锯齿又出现了,尽管立方体的边缘仍然十分平滑,但是在三角形的内部,着色器生成的条带含有大量的锯齿并且严重走形。源码传送门
为了得到右侧的图像,我们开启了采样率着色特性(sample-rate shading)。在这种模式下,OpenGL将会为图元命中的所有样本依次调用片段着色器。但是在使用该功能时需要注意,这个特性性能耗费非常严重。对于8倍采样缓存,着色器的时间开销将会是默认模式下的8倍。开启该特性调用函数glEnable(GL_SAMPLE_SHADING),禁用该功能调用函数glDisable(GL_SAMPLE_SHADING)。
一旦启用了样本着色,你还需要让OpenGL知道哪部分样本需要被单独运行着色器。默认情况下,OpenGL此时的渲染逻辑和未开启该特性时并无不同,对于每个像素,顶点着色器仍然只会运行一次。设置需要独立运行片段着色器的样本需要调用函数glMinSampleShading,其原型如下。
void glMinSampleShading(GLfloat value);
如果想要为帧缓存中一半的样本单独调用片段着色器,将参数value设置为0.5f,想要为每一个命中的样本都执行一次片段着色器,则将value设置为1。上图图右侧渲染好的场景就是在value设置为1时的渲染结果,可以看到在立方体内部的锯齿也消失了。
5.5 重心采样 (Centroid Sampling)
存储限定词centroid控制了在单个像素内部,OpenGL对输入到片段着色器的值进行插值运算的方式。只有在渲染多重采样帧缓存时,该限定词才会生效。想要创建一个centroid类型的变量,需要在顶点、曲面细分控制、或者几何着色器内部声明一个centroid类型的输出变量。如centroid out vec2 tex_coord;
然后在片段着色器内部声明同一个变量为centroid类型的输入变量。centroid in vec2 tex_coord;
你也可以使用限定词centroid声明一个block类型的变量,使得block内部的所有成员按照片段的重心进行插值计算。
centroid out VS_OUT {
vec2 tex_coord;
} vs_out;
如果当前使用的帧缓存是单重采样的,那么限定词centroid并不会产生任何效果,片段着色器的输入变量在之前的阶段中还是以像素的中心为准进行插值。当渲染多重采样格式的帧缓存时,该特性非常有用。根据OpenGL的官方说明,当重心采样未被指定时(默认情况),那么片段着色器中变量的插值计算将以像素的中心或者该像素内任意位置为准。当绘制大的三角形图元中心的时候,这并不重要。当绘制三角形图元边缘上的点,并且这些像素正好被图元的边切割时,该特性非常有用。下图演示了OpenGL对三角形图元的一种可能的采样方式。
左图中,实心的黑色点表示该采样的样本位于三角形图元内部,反之白色的点表示不在其内部。OpenGL在对片段着色器的输入进行插值计算时,选择的参照点是最接近像素中心点样本。这些样本使用向下的箭头标注。
对于左上角的点,这样的处理并不会有什么问题,因为它们完全不在三角形图元的内部,它们并不会触发片段着色器的调用。同样的对于右下角的那些完全位于图元内部的点也不会有什么影响,这些像素将会触发片段着色器的调用,但是调用时选择那个样本作为参照点并不重要。但是对于三角形图元边缘的点就会出现问题,由于OpenGL挑选最接近像素中心的点作为插值点,因此你的片段着色器的输入变量在插值时的参照点可能位于三角形图元的外部。这些样本使用符合叉表示。想象一下你使用这些输入值对纹理进行采样将会发生什么,如果纹理的边缘和三角形图元的边缘一致,那么纹理坐标就有可能在纹理外部。在最好的情况下,你会得到一张轻微变形的图片,最糟的情况下,你讲得到一个有明显走行的图片。
如果我们使用存储限定词centroid修饰片段着色器中的输入变量,OpenGL的官方文档说到此时插值的参考点一定同时在像素和被渲染的图元内部,或者说是该像素被图元覆盖的样本点中的一个。这意味着OpenGL将会为每个像素选择一个位于图元内部的样本点作为参考点对该变量进行插值计算。这样你就可以安全的将该变量用于任何目的,OpenGL也会确保这些变量是有效的,并且它们绝不会以三角形图元外部的样本点作为参考点进行插值计算。
现在让我们再来看右边的图片,对于那些完全被覆盖的像素,OpenGL仍然选择离像素中心最近的样本作为参考点,对片段着色器的输入变量进行插值计算。但是对于那些部分被覆盖的像素,当离图元中心最近的样本不在图元内部时,OpenGL选择了其他位于图元内部的样本作为替代,使用向上的箭头表示。这意味着输入到片段着色器中的变量是有效的,它们的参考点都位于三角形图元内部。你可以使用这些输入变量对纹理进行采样,或者使用它们调用一些结果只定义在有限范围内函数,并且我们也知道一定能够得到有意义的结果。
你也许相知到是否使用限定词centroid会保证你能够在片段着色器中得到一个有效的值,不使用该限定词时片段着色器的经过插值运算的值将会位于图元的外部,为什么我不一直打开重心采样(centroid sampling)功能。下面将会介绍该功能的一些缺点。
OpenGL输入到片段着色器中的值会因片段的不同呈现出梯度或者说是渐变现象。不同的OpenGL实现可能有着微小的差异,但是大多数都使用了离散微分的方式,这种方式通过计算相邻像素中同一个输入变量的差值实现。当输入变量计算时的参考点都位于每个像素内部的同一个位置时,这种方式非常有用。在这种情况下,在计算插值时不用关注以哪个位置的样本作为参考点,计算的样本值之间总是相隔一个像素的距离。然而当centroid特性被开启时,对于输入变量的插值计算采用的参考点就可能位于像素内的不同位置。这意味着这些样本之间的距离并不严格是一个像素,此时在片段着色器中那些通过离散微分计算出来的输入值就可能不是正确的。如果要求输入到片段着色器的值在插值运算时的准确性,那最好不要使用重心采样。Don’t forget, the calculations that OpenGL performs during mipmapping depend on gradients of texture coordinates, and so using a centroid qualified input as the source of texture coordinates to a mipmapped texture could lead to inaccurate results.
使用重心采样执行边缘检测(Using Centroid Sampling to Perform Edge Detection)
重心采样的一个有趣的使用实例就是用于基于硬件加速的边缘检测。刚刚说到使用重心采样能够确保片段着色器的值在进行插值运算时,其参考的样本点一定是在正在渲染的图元内部。为了实现这个目的,OpenGL在同一个像素中挑选的参考样本可能会由于该变量是否声明了centroid限定词而不同,这个特性正好可以为我们所用。
为了利用这个特性提取边缘信息,我们在片段着色器中可以声明两个变量,其中一个使用限定词centroid修饰,同时我们在顶点着色器中为这两个变量传入相同的值。这个值具体是多少并不重要,只要每个顶点的值不同即可。顶点经过转化的坐标x轴和y轴分量可能是一个不错的选择,因为你知道对于任何一个可见的三角形,每个顶点的这两个值都是不同的。声明的变量如下。
out vec2 maybe_outside;
centroid out vec2 certainly_inside;
在片段着色器内部,我们可以比较这两个值。如果该像素完全位于图元内部,那么OpenGL计算出的这两个输入变量一定是相等的。然而如果像素只是部分位于图元内部,那么OpenGL将会使用默认的样本计算出maybe_outside值,会挑选一个一定位于图元内部的样本计算出certainly_inside的值。这就可能导致其挑选的样本和计算maybe_outside时的样本不是同一个,这也意味着这两个输入变量的值可能并不相同。此时可以通过比较这两个值是否相同来判断该像素是否位于图元的边缘。
bool may_be_on_edge = any(notEqual(maybe_outside, certainly_inside));
这个方法并不是一定正确的,因为即使像素部分位于图元内部,插值计算默认挑选的样本点仍然可能位于图元的内部,此时两种不同策略的输入值就会相等,但是这种方法仍能够识别出大部分的边缘像素。
想要使用这些信息,你可以将这个值写入到一个绑定至帧缓存上的纹理中,并在后面你想要用的时候从这个纹理中读取数据。另外一个选项是是绘制模版缓存,将模版参考值设置为1,禁用模版测试,将模版的操作函数设置为GL_REPLACE。处理到边缘像素时使片段着色器正常运行,当处理部分被图元覆盖的像素时,在着色器内部使用关键字discard确保该像素不会处理模版缓存。最后的结果是模版缓存中所有为1的点都表示该像素位于图元的边缘,为0的点表示该像素位于图元的内部。接下来你可以使用一个昂贵的片段着色器来绘制一个全屏幕的四边形Later, you can render a full-screen quad with an expensive fragment shader,该片段着色器仅为图元边缘的像素调用,这些像素在计算片段着色器输入值时采用的参考样本将会位于图元的外部,并启用模版测试,将测试函数设置为GL_EQUAL,将模板测试的参考值设置为1。此时着色器中访问到图元中的每一个像素来进行图像处理,如使用卷积操作应用高斯模糊效果能够平滑场景中多边形的边缘,这样我们就实现了一个自定义的抗锯齿逻辑。
6 总结 (Summary)
本章内容详细的描述了OpenGL渲染管线的末端细节。首先我们讲到了片段着色器,插值计算和一些片段着色器中使用到的内建变量。我们同样说到了使用深度和模版缓存执行了一些测试操作。接下来我们介绍了如何处理颜色输出变量,包括颜色遮挡、混合以及逻辑操作,这些处理都会影响片段着色器生成的值是如何被写入到帧缓存中的。
在我们介绍完应用于默认帧缓存的功能后,我们讲到了自定义帧缓存。自定义的帧缓存最大的优点是它们可以包含多个附件。我们同样介绍了用于多种使用抗锯齿特性来处理分辨率限制的方法,包括使用混合、透明度覆盖、MSAA和过采样的方式实现抗锯齿效果,并且我们也讲到了这里面的优点和缺点。