简介
如今,延时着色是相当普遍的渲染技术。该技术在各种资料 ([1], [2], [3]) 中均有详细论述,并且应用于十多种游戏中 [4]。
场景的复杂性变得越来越高,这种技术随之应用而生。首先,让我们看一下正向渲染。在复杂场景下,多个目标可以投影在同一屏幕片段上,但只会显示一个片段:即最靠近摄像头的那个片段。对其他片段进行的计算会丢弃,因此浪费了片段着色处理器资源。场景中光线数增加时这个问题变得更加糟糕,因为片段着色器必须针对场景中的每一条光线运行。我们还应该知道,场景中的许多光线并不会对照亮目标的表面有实际贡献,因为它们与被照亮的片段距离太远,因此贡献是零或微不足道。
为克服这一问题,引入了延时着色技术。原理非常简单:首先让我们找出将要显示哪些片段,然后仅计算它们的显示属性。但在此您可能注意到,我们仅能找出片段着色器中显示哪些片段和丢弃哪些片段,而光线所需的对象属性(如世界位置和法向矢量)存在于先前的图形渲染管线阶段——顶点着色器。这个问题可以通过相当简单明了的方式解决:让我们为处理的每个片段缓存所需的数据。值得庆幸的是,由于深度缓冲区的工作方式,我们可以存储最靠近摄像头的片段的数据,而被遮挡的对象的数据会自动丢弃。
这种方法在多渲染目标技术中得到应用。该技术的主要理念是把所需数据输出到不同的纹理目标,以便存储片段属性。数据输出到的一组三个或四个纹理称为 G-Buffer。大多数现代 OpenGL ES 实施允许将场景同时渲染成多达四个的纹理,足以存储即将进行的光线计算所需的所有必要数据。遗憾的是,使用多渲染目标有其自身的缺点。其中之一是我们现在输出三倍或四倍以上的数据,这会增加 GPU 内存总线的性能开销。
幸运的是,OpenGL ES 的最新发展解决了这一问题 [5]:
Mali 和其他基于区块的架构的主要优势在于,大量操作可以在芯片上执行而不必访问外部存储器。为使应用程序在该类架构上有效运行,尽可能长时间地在芯片上尝试和保持运行非常有益。将区块缓冲区的数据清空到帧缓冲区,随后再通过采样纹理读取这些数据的做法非常昂贵,而且会消耗大量带宽。
仅供 OpenGL ES 3.0 使用的扩展 [EXT_shader_pixel_local_storage] 为应用程序提供了一个机制,可在覆盖同一像素的片段着色器调用之间传递信息。
在本教程中,我们在所有三个阶段中均利用该扩展在 GPU 上保存数据,而且仅在组合过程中将数据输出到帧缓冲区存储器。
应用设计
我们利用位于平面上的多个球体渲染场景。这个平面和每个球体都分配有不同的颜色。多个光源在场景周围移动。摄像头不停地在场景上方移动,从不同视角显示场景。
要实施不同着色,应执行以下过程:
G-Buffer生成过程 G-Buffer生成过程渲染平面和球体。最靠近摄像头的片段的参数会存储在像素本地存储中。
- 着色过程 着色过程计算被光源覆盖的片段的光线。片段的总累积光线量在像素本地存储中进行更新。
组合过程 组合过程利用存储在片段的像素本地存储中的颜色和光线数据,将片段渲染到屏幕上。
上述过程均出现在控制程序的所有三个主要函数中: setup_graphics()
,render_frame()
和 cleanup()
. setup_graphics()
和 cleanup()
函数在很大程度上已经标准化了,且代码都有很好的注释,因此我们主要讨论渲染函数及其三个过程。
渲染函数
除所述的三个过程以外,渲染函数还有一些其他功能。首先,我们计算视图投影和反转的视图投影矩阵,这些将在三个阶段中的两个阶段中用到:
/* We use it during gbuffer generation and shading passes. */ calc_view_projection_matrices(model_time, matrix_view_projection, matrix_view_projection_inverted);
在渲染函数开始时,我们应该启用扩展:
GL_CHECK(glEnable(GL_SHADER_PIXEL_LOCAL_STORAGE_EXT));
在渲染函数结束时,我们应该禁用扩展:
GL_CHECK(glDisable(GL_SHADER_PIXEL_LOCAL_STORAGE_EXT));
G-BUFFER 生成过程
这一阶段的顶点着色器相当有规律:它使用模型视图投影矩阵转换顶点,并使颜色和法向矢量在片段着色器中可被访问:
vColor = vec3(uObjectColor); vNormal = vec3(vObjectVertexNormal); gl_Position = uMVP * vec4(vObjectVertexCoordinates, 1.0);
片段着色器更有意思:首先我们启用扩展:
#extension GL_EXT_shader_pixel_local_storage : require
然后,声明像素本地存储的格式架构 (FragData) 和该类型的一个变量 (gbuf):
__pixel_local_outEXT FragData { layout(rgba8) highp vec4 Color; layout(rg16f) highp vec2 NormalXY; layout(rg16f) highp vec2 NormalZ_LightingB; layout(rg16f) highp vec2 LightingRG; } gbuf;
现在,G-Buffer 生成过程中的每个片段都有一个变量 gbuf. gbuf 的功能是在该片段着色器中写入其中的值会得到保留,并提供给接下来的两个过程中的片段着色器,从而使值可在其他过程的片段着色器之间共享。该结构的格式(字段、名称和类型,以及字段顺序)应与所有其他着色器中的结构格式相同。结构声明中唯一允许不同的标记是限定符。在此过程中所使用的是 __pixel_local_outEXT, 因为我们只需写入到像素本地存储。根据 [5],扩展引入下列新限定符:
限定符th> | 存储访问 |
---|---|
pixel_local_EXT | 可以读取并写入到存储。 |
pixel_local_inEXT | 可以读取存储。 |
pixel_local_outEXT | 可以写入到存储。 |
其他两个限定符会在着色过程和组合过程的片段着色器中使用。现在,让我们来看一下存储在gbuf中的字段。虽然 gbuf,中有四个字段,但实际上我们在其中存储三个值:片段颜色、片段法向矢量和光线累加器。我们必须把法向和光线矢量分成两部分,并将每个的第三个分量存储在 gbuf 的 NormalZ_LightingB 字段中。 将法向和光线矢量用作vec3 变量会比较容易,但是像素本地存储中的字段的可用类型非常有限[5]
:
布局 | 基本类型 |
---|---|
r32ui | uint |
r11f_g11f_b10f | vec3 |
r32f | float |
rg16f | vec2 |
rgb10_a2 | vec4 |
rgba8 | vec4 |
rg16 | vec2 |
rgba8i | ivec4 |
rg16i | ivec2 |
rgb10_a2ui | uvec4 |
rgba8ui | uvec4 |
rg16ui | uvec2 |
唯一可用的vec3 的精度相当低(约 2.5 小数位数 [6]
) 甚至无法包含负值,因此与这样的vec3 字段互动会更为复杂。目前,像素本地存储可用的数据量限制在每个片段 128 位(16 个字节)。因此,我们无法将法向和颜色声明为一组r32f float 每分量字段。如要获取像素本地存储中的可用总字节数,可通过调用pName为GL_SHADER_PIXEL_LOCAL_STORAGE_EXT的 glGetInteger 函数来获取,或通过度常量gl_MaxShaderPixelLocalStorageSizeEXT获得 [7]
.
在 G-Buffer 生成过程中使用 gbuf 相当简单。只需存储片段颜色和法向矢量,并将光线累加器设置为零:
/* Store primitive color. */ gbuf.Color = vec4(vColor, 0.0); /* Store normal vector. */ gbuf.NormalXY = vNormal.xy; gbuf.NormalZ_LightingB[0] = vNormal.z; /* Reserve and set lighting to 0. */ gbuf.LightingRG = vec2(0.0); gbuf.NormalZ_LightingB[1] = 0.0;
某些工作分配给深度缓冲区来完成;深度缓冲区在控制程序 setup_graphics() 函数结束时启用:
GL_CHECK(glDisable(GL_BLEND)); GL_CHECK(glEnable(GL_DEPTH_TEST));
这样,我们就能够利用深度测试在区块缓冲区中存储有关最靠近摄像头的片段的信息。我们将需要为 G-Buffer 生成过程激活这一功能:
/* Only the fragment closest to the camera will be stored in the tile buffer. */ GL_CHECK(glDepthMask(GL_TRUE));
渲染原语相当简单而且代码有清楚的注释,所以只在此作大致讲解。首先,我们渲染平面:
/* Attach mesh vertices and normals to appropriate shader attributes. */ GL_CHECK(glVertexAttribPointer(gbuffer_generation_pass_vertex_coordinates_location, 3, GL_FLOAT, GL_FALSE, 0, &plane_mesh_vertices[0])); GL_CHECK(glVertexAttribPointer(gbuffer_generation_pass_vertex_normal_location, 3, GL_FLOAT, GL_FALSE, 0, &plane_mesh_normals[0] )); /* Specify a model-view-projection matrix. The model matrix is an identity matrix for the plane, so we can save one multiplication for the pass. */ GL_CHECK(glUniformMatrix4fv(gbuffer_generation_pass_mvp_matrix_location, 1, GL_FALSE, matrix_view_projection.getAsArray())); GL_CHECK(glUniform3f(gbuffer_generation_pass_object_color_location, plane_color_r, plane_color_g, plane_color_b)); /* Execute shader to render the plane into pixel storage. */ GL_CHECK(glDrawArrays(GL_TRIANGLES, 0, (GLsizei)plane_mesh_vertices.size()/3));
接下来,以类似方式循环渲染球体:
/* Attach mesh vertices and normals to appropriate shader attributes. */ GL_CHECK(glVertexAttribPointer(gbuffer_generation_pass_vertex_coordinates_location, 3, GL_FLOAT, GL_FALSE, 0, &sphere_mesh_vertices[0])); GL_CHECK(glVertexAttribPointer(gbuffer_generation_pass_vertex_normal_location, 3, GL_FLOAT, GL_FALSE, 0, &sphere_mesh_normals[0] )); /* Render each sphere on the scene in its position, size and color. */ for (int i = 0; i < spheres_array_size; i++) { /* Calculate and set the MVP matrix for the sphere. Apply scaling and translation to sphere. */ matrix_mvp = matrix_view_projection * calc_model_matrix(spheres_array[i].radius, Vec3f(spheres_array[i].x, spheres_array[i].y, spheres_array[i].z)); GL_CHECK(glUniformMatrix4fv(shading_pass_mvp_matrix_location, 1, GL_FALSE, matrix_mvp.getAsArray())); /* Set MVP matrix and sphere color for the shader. */ GL_CHECK(glUniformMatrix4fv(gbuffer_generation_pass_mvp_matrix_location, 1, GL_FALSE, matrix_mvp.getAsArray())); GL_CHECK(glUniform3f(gbuffer_generation_pass_object_color_location, spheres_array[i].r, spheres_array[i].g, spheres_array[i].b)); /* Execute shader to render sphere into pixel local storage. */ GL_CHECK(glDrawArrays(GL_TRIANGLES, 0, (GLsizei)sphere_mesh_vertices.size()/3)); }
在前两行中,我们指定顶点和法线的存储位置。所有球体均采此操作,并通过 calc_model_matrix返回的模型矩阵,将球体尺寸和球体位置应用到球体。该函数结合缩放和转换功能,缩小标识球体并将其从原点移到 spheres_array[i].xyz 指定的点。
spheres_array[i].xyz .
calc_model_matrix
着色过程
光源(在现实生活中通常是球形)在此处被渲染为立方体对象(灯箱),因为渲染立方体比球体更容易。如果灯箱的大小与光源直径相同,则受光源影响的所有片段都在该灯箱内。由于距离因素,被灯箱覆盖而未被球体覆盖的某些额外片段不受光线影响。
让我们首先查看控制程序部分:
/* Attach the mesh vertices to the appropriate shader attribute. */ GL_CHECK(glVertexAttribPointer(shading_pass_lightbox_vertex_coordinates_location, 3, GL_FLOAT, GL_FALSE, 0, &cube_mesh_vertices[0])); /* Process each light's bounding box on the scene in its position, size and color. */ for (int i = 0; i < lights_array_size; i++) { /* Calculate the light position for the current time. */ Vec3f light_position = calculate_light_position(model_time, lights_array[i].orbit_height, lights_array[i].orbit_radius, lights_array[i].angle_speed); /* Determine light box size. To avoid interference with the frustum it cannot be large than the scene. */ float light_box_size = lights_array[i].light_radius > 1.0f ? 1.0f : lights_array[i].light_radius; /* Calculate and set MVP matrix for the light box. Apply scaling and translation. */ matrix_mvp = matrix_view_projection * calc_model_matrix(light_box_size, light_position); GL_CHECK(glUniformMatrix4fv(shading_pass_mvp_matrix_location, 1, GL_FALSE, matrix_mvp.getAsArray())); /* Set the light radius, the light position and its color for the shading pass program. */ GL_CHECK(glUniform1f(shading_pass_light_radius_location, lights_array[i].light_radius)); GL_CHECK(glUniform3f(shading_pass_light_color_location, lights_array[i].r, lights_array[i].g, lights_array[i].b));> GL_CHECK(glUniform3f(shading_pass_light_coordinates_location, light_position.x, light_position.y, light_position.z )); /* Execute the shader to light up fragments in the light box of the pixel storage. */ GL_CHECK(glDrawArrays(GL_TRIANGLES, 0, (GLsizei)cube_mesh_vertices.size()/3)); }
您可能会注意到,其与负责渲染平面和球体的控制程序中的 G-Buffer 生成部分非常相似。唯一值得注意的事情是,我们对灯箱应用的变换与我们应用到球体和平面的变换相同。因此,这可能看起来像是尝试以片段方式显示这些灯箱,而这些片段可能被平面或球体表面占据。但是,我们这样做只是为了调用用于灯箱下片段(可能被光线照明的片段)的着色过程片段着色器。此外,由于光线具有累积性,我们应当为每个片段运行片段着色器,即便该着色器已被从另一光线运行的另一着色器处理。为实现这一目的,我们应该禁用深度缓冲区:
/* This pass should not update depths, only use them. */ GL_CHECK(glDepthMask(GL_FALSE));
现在,让我们来查看构成此过程的着色器。顶点着色器甚至比 G-Buffer 生成过程顶点着色器更短,因此我们将不在此讨论。我们最感兴趣的是片段着色器。片段着色器实施定期漫射 Phong 光线,但数据从各种各样的光源中提取。在片段着色器开始时,我们启用两个扩展:
#extension GL_EXT_shader_pixel_local_storage : require #extension GL_ARM_shader_framebuffer_fetch_depth_stencil : require
GL_ARM_shader_framebuffer_fetch_depth_stencil 可使我们访问 gl_LastFragDepthARM 内置变量,其包含片段深度。与 gl_FragCoord 和反转的视图投影矢量结合,我们可以计算剪辑坐标的所有三个分量:
ClipCoord.xy = gl_FragCoord.xy * uInvViewport; ClipCoord.z = gl_LastFragDepthARM; ClipCoord.w = 1.0; ClipCoord = ClipCoord * 2.0 - 1.0;
利用剪辑坐标和反转的视图投影矩阵(已在控制程序中计算并利用统一制式正在转移到着色器),我们可以发现当前运行片段的世界空间:
vec4 worldPosition = ClipCoord * uInvViewProj; worldPosition /= worldPosition.w;
具有世界位置和光线位置(已作为统一制式转移到着色器)之后,我们可以计算光线矢量和强度(作为 lightVectorLength):
vec3 lightVector = uLightPos - worldPosition.xyz; float lightVectorLength = length(lightVector); lightVector /= lightVectorLength;
缺少法向矢量,我们将其从像素本地存储中解压缩出来:
vec3 normalVector = vec3(gbuf.NormalXY, gbuf.NormalZ_LightingB[0]);
之后,我们可以计算常规 Phong 光线参数,如光衰减 (lightAttenuation) 和光线和法向矢量的点乘积 (normalDotLightVector):
现在,我们有了计算纹素的片段光线所需的所有必要数据:
vec3 texelLighting = vec3(gbuf.LightingRG, gbuf.NormalZ_LightingB[1]); texelLighting += uLightColor * normalDotLightVector * lightAttenuation; gbuf.LightingRG = texelLighting.rg; gbuf.NormalZ_LightingB[1] = texelLighting.b;
已处理片段的光线份额在该代码的第二行中进行计算。第一行和最后两行执行向像素本地存储压缩和解压缩光线。由于我们不仅从像素本地存储中读取值,还要向其写入值,因此我们把该缓冲区声明为可读写:
__pixel_localEXT FragData
组合过程
在组合过程中,我们把在像素本地存储中收集的数据渲染到屏幕上。此过程的控制程序相当简单。它四次调用过程的着色器程序:
/* Activate this pass' program. */ GL_CHECK(glUseProgram(combination_pass_program_id)); /* Render a fullscreen quad, so that each fragment has a chance to be updated with data from local pixel storage. */ GL_CHECK(glDrawArrays(GL_TRIANGLE_STRIP, 0, full_quad_vertex_count));
顶点着色器输出所谓的“全屏四边形”。在我们的程序中,它是由两个三角形组成:
switch(gl_VertexID) { case 0: gl_Position = vec4( 1.0, 1.0, -1.0, 1.0); break; case 1: gl_Position = vec4(-1.0, 1.0, -1.0, 1.0); break; case 2: gl_Position = vec4( 1.0, -1.0, -1.0, 1.0); break; case 3: gl_Position = vec4(-1.0, -1.0, -1.0, 1.0); break; }
这两个三角形覆盖了整个视口(屏幕表面),并强制组合过程的片段着色器处理每个像素。片段着色器相当简单,它读取累积的光线和片段颜色:
vec3 diffuseColor = gbuf.Color.xyz; vec3 texelLighting = vec3(gbuf.LightingRG, gbuf.NormalZ_LightingB[1]);
然后,片段着色器计算片段颜色,并将其写入到输出变量 fragColor,从而渲染到屏幕上。
/* This will effectively write the color data to the native framebuffer * format of the currently attached color attachment. */ fragColor = vec4(diffuseColor * texelLighting, 1.0);
然后,片段着色器计算片段颜色,并将其写入到输出变量 fragColor,从而渲染到屏幕上。
__pixel_local_inEXT FragData
结论
在本教程中,我们概述了可使我们在 GPU 上执行操作且无需向外部存储器输入输出数据的新扩展。我们还演示了如何使用此新功能实施延时着色。当然,最重要的是利用新扩展来实现高性能 ([5]
]“数据分解图显示带宽耗量减少 9 倍”)。
nbsp;