笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者,国家专利发明人;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。
CSDN视频网址:http://edu.csdn.net/lecturer/144
如果读者使用过Unity3D引擎,在相机的组件中有关于延迟着色的控制开关,读者自己可以尝试体验一下效果。我们现在一直使用的光照方式叫做正向渲染(Forward Rendering)或者正向着色法(Forward Shading),它是我们渲染物体的一种非常直接的方式,在场景中我们根据所有光源照亮一个物体,之后再渲染下一个物体,以此类推。它非常容易理解,也很容易实现,但是同时它对程序性能的影响也很大,因为对于每一个需要渲染的物体,程序都要对每一个光源每一个需要渲染的片段进行迭代,这是非常多的!因为大部分片段着色器的输出都会被之后的输出覆盖,正向渲染还会在场景中因为高深的复杂度(多个物体重合在一个像素上)浪费大量的片段着色器运行时间。
延迟着色法(Deferred Shading),或者说是延迟渲染(Deferred Rendering),为了解决上述问题而诞生了,它大幅度地改变了我们渲染物体的方式。这给我们优化拥有大量光源的场景提供了很多的选择,因为它能够在渲染上百甚至上千光源的同时还能够保持能让人接受的帧率。下面这张图片包含了一共1874个点光源,它是使用延迟着色法来完成的,而这对于正向渲染几乎是不可能的。
延迟着色法基于我们延迟(Defer)或推迟(Postpone)大部分计算量非常大的渲染(像是光照)到后期进行处理的想法。它包含两个处理阶段(Pass):在第一个几何处理阶段(Geometry Pass)中,我们先渲染场景一次,之后获取对象的各种几何信息,并储存在一系列叫做G缓冲(G-buffer)的纹理中;想想位置向量(Position Vector)、颜色向量(Color Vector)、法向量(Normal Vector)和/或镜面值(Specular Value)。场景中这些储存在G缓冲中的几何信息将会在之后用来做(更复杂的)光照计算。下面是一帧中G缓冲的内容:
我们会在第二个光照处理阶段(Lighting Pass)中使用G缓冲内的纹理数据。在光照处理阶段中,我们渲染一个屏幕大小的方形,并使用G缓冲中的几何数据对每一个片段计算场景的光照;在每个像素中我们都会对G缓冲进行迭代。我们对于渲染过程进行解耦,将它高级的片段处理挪到后期进行,而不是直接将每个对象从顶点着色器带到片段着色器。光照计算过程还是和我们以前一样,但是现在我们需要从对应的G缓冲而不是顶点着色器(和一些uniform变量)那里获取输入变量了。
下面这幅图片很好地展示了延迟着色法的整个过程:
这种渲染方法一个很大的好处就是能保证在G缓冲中的片段和在屏幕上呈现的像素所包含的片段信息是一样的,因为深度测试已经最终将这里的片段信息作为最顶层的片段。这样保证了对于在光照处理阶段中处理的每一个像素都只处理一次,所以我们能够省下很多无用的渲染调用。除此之外,延迟渲染还允许我们做更多的优化,从而渲染更多的光源。
在几何处理阶段中填充G缓冲非常高效,因为我们直接储存像是位置,颜色或者是法线等对象信息到帧缓冲中,而这几乎不会消耗处理时间。在此基础上使用多渲染目标(Multiple Render Targets, MRT)技术,我们甚至可以在一个渲染处理之内完成这所有的工作。
G缓冲(G-buffer)是对所有用来储存光照相关的数据,并在最后的光照处理阶段中使用的所有纹理的总称。趁此机会,让我们顺便复习一下在正向渲染中照亮一个片段所需要的所有数据:
- 一个3D位置向量来计算(插值)片段位置变量供
lightDir
和viewDir
使用 - 一个RGB漫反射颜色向量,也就是反照率(Albedo)
- 一个3D法向量来判断平面的斜率
- 一个镜面强度(Specular Intensity)浮点值
- 所有光源的位置和颜色向量
- 玩家或者观察者的位置向量
有了这些(逐片段)变量的处置权,我们就能够计算我们很熟悉的(布林-)冯氏光照(Blinn-Phong Lighting)了。光源的位置,颜色,和玩家的观察位置可以通过uniform变量来设置,但是其它变量对于每个对象的片段都是不同的。如果我们能以某种方式传输完全相同的数据到最终的延迟光照处理阶段中,我们就能计算与之前相同的光照效果了,尽管我们只是在渲染一个2D方形的片段。
OpenGL并没有限制我们能在纹理中能存储的东西,所以现在你应该清楚在一个或多个屏幕大小的纹理中储存所有逐片段数据并在之后光照处理阶段中使用的可行性了。因为G缓冲纹理将会和光照处理阶段中的2D方形一样大,我们会获得和正向渲染设置完全一样的片段数据,但在光照处理阶段这里是一对一映射。
整个过程在伪代码中会是这样的:
while(...) // 游戏循环 { // 1. 几何处理阶段:渲染所有的几何/颜色数据到G缓冲 glBindFramebuffer(GL_FRAMEBUFFER, gBuffer); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); gBufferShader.Use(); for(Object obj : Objects) { ConfigureShaderTransformsAndUniforms(); obj.Draw(); } // 2. 光照处理阶段:使用G缓冲计算场景的光照 glBindFramebuffer(GL_FRAMEBUFFER, 0); glClear(GL_COLOR_BUFFER_BIT); lightingPassShader.Use(); BindAllGBufferTextures(); SetLightingUniforms(); RenderQuad(); }
对于每一个片段我们需要储存的数据有:一个位置向量、一个法向量,一个颜色向量,一个镜面强度值。所以我们在几何处理阶段中需要渲染场景中所有的对象并储存这些数据分量到G缓冲中。我们可以再次使用多渲染目标(Multiple Render Targets)来在一个渲染处理之内渲染多个颜色缓冲,在之前的OpenGL核心技术之Bloom中我们也简单地提及了它。
对于几何渲染处理阶段,我们首先需要初始化一个帧缓冲对象,我们很直观的称它为gBuffer
,它包含了多个颜色缓冲和一个单独的深度渲染缓冲对象(Depth Renderbuffer Object)。对于位置和法向量的纹理,我们希望使用高精度的纹理(每分量16或32位的浮点数),而对于反照率和镜面值,使用默认的纹理(每分量8位浮点数)就够了。
GLuint gBuffer; glGenFramebuffers(1, &gBuffer); glBindFramebuffer(GL_FRAMEBUFFER, gBuffer); GLuint gPosition, gNormal, gColorSpec; // - 位置颜色缓冲 glGenTextures(1, &gPosition); glBindTexture(GL_TEXTURE_2D, gPosition); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gPosition, 0 // - 法线颜色缓冲 glGenTextures(1, &gNormal); glBindTexture(GL_TEXTURE_2D, gNormal); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, gNormal, 0); // - 颜色 + 镜面颜色缓冲 glGenTextures(1, &gAlbedoSpec); glBindTexture(GL_TEXTURE_2D, gAlbedoSpec); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, gAlbedoSpec, 0); // - 告诉OpenGL我们将要使用(帧缓冲的)哪种颜色附件来进行渲染 GLuint attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 }; glDrawBuffers(3, attachments); // 之后同样添加渲染缓冲对象(Render Buffer Object)为深度缓冲(Depth Buffer),并检查完整性 [...]
由于我们使用了多渲染目标,我们需要显式告诉OpenGL我们需要使用glDrawBuffers
渲染的是和GBuffer
关联的哪个颜色缓冲。同样需要注意的是,我们使用RGB纹理来储存位置和法线的数据,因为每个对象只有三个分量;但是我们将颜色和镜面强度数据合并到一起,存储到一个单独的RGBA纹理里面,这样我们就不需要声明一个额外的颜色缓冲纹理了。随着你的延迟渲染管线变得越来越复杂,需要更多的数据的时候,你就会很快发现新的方式来组合数据到一个单独的纹理当中。
接下来我们需要渲染它们到G缓冲中。假设每个对象都有漫反射,一个法线和一个镜面强度纹理,我们会想使用一些像下面这个片段着色器的东西来渲染它们到G缓冲中去。
#version 330 core layout (location = 0) out vec3 gPosition; layout (location = 1) out vec3 gNormal; layout (location = 2) out vec4 gAlbedoSpec; in vec2 TexCoords; in vec3 FragPos; in vec3 Normal; uniform sampler2D texture_diffuse1; uniform sampler2D texture_specular1; void main() { // 存储第一个G缓冲纹理中的片段位置向量 gPosition = FragPos; // 同样存储对每个逐片段法线到G缓冲中 gNormal = normalize(Normal); // 和漫反射对每个逐片段颜色 gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb; // 存储镜面强度到gAlbedoSpec的alpha分量 gAlbedoSpec.a = texture(texture_specular1, TexCoords).r; }
因为我们使用了多渲染目标,这个布局指示符(Layout Specifier)告诉了OpenGL我们需要渲染到当前的活跃帧缓冲中的哪一个颜色缓冲。注意我们并没有储存镜面强度到一个单独的颜色缓冲纹理中,因为我们可以储存它单独的浮点值到其它颜色缓冲纹理的alpha分量中。
如果我们现在想要渲染一大堆纳米装战士对象到gBuffer
帧缓冲中,并通过一个一个分别投影它的颜色缓冲到铺屏四边形中尝试将他们显示出来,我们会看到向下面这样的东西:
尝试想象世界空间位置和法向量都是正确的。比如说,指向右侧的法向量将会被更多地对齐到红色上,从场景原点指向右侧的位置矢量也同样是这样。一旦你对G缓冲中的内容满意了,我们就该进入到下一步:光照处理阶段了。
现在我们已经有了一大堆的片段数据储存在G缓冲中供我们处置,我们可以选择通过一个像素一个像素地遍历各个G缓冲纹理,并将储存在它们里面的内容作为光照算法的输入,来完全计算场景最终的光照颜色。由于所有的G缓冲纹理都代表的是最终变换的片段值,我们只需要对每一个像素执行一次昂贵的光照运算就行了。这使得延迟光照非常高效,特别是在需要调用大量重型片段着色器的复杂场景中。
对于这个光照处理阶段,我们将会渲染一个2D全屏的方形(有一点像后期处理效果)并且在每个像素上运行一个昂贵的光照片段着色器。
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); shaderLightingPass.Use(); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, gPosition); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, gNormal); glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_2D, gAlbedoSpec); // 同样发送光照相关的uniform SendAllLightUniformsToShader(shaderLightingPass); glUniform3fv(glGetUniformLocation(shaderLightingPass.Program, "viewPos"), 1, &camera.Position[0]); RenderQuad();
我们在渲染之前绑定了G缓冲中所有相关的纹理,并且发送光照相关的uniform变量到着色器中。
光照处理阶段的片段着色器和我们之前一直在用的光照博客着色器是非常相似的,除了我们添加了一个新的方法,从而使我们能够获取光照的输入变量,当然这些变量我们会从G缓冲中直接采样。
#version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D gPosition; uniform sampler2D gNormal; uniform sampler2D gAlbedoSpec; struct Light { vec3 Position; vec3 Color; }; const int NR_LIGHTS = 32; uniform Light lights[NR_LIGHTS]; uniform vec3 viewPos; void main() { // 从G缓冲中获取数据 vec3 FragPos = texture(gPosition, TexCoords).rgb; vec3 Normal = texture(gNormal, TexCoords).rgb; vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb; float Specular = texture(gAlbedoSpec, TexCoords).a; // 然后和往常一样地计算光照 vec3 lighting = Albedo * 0.1; // 硬编码环境光照分量 vec3 viewDir = normalize(viewPos - FragPos); for(int i = 0; i < NR_LIGHTS; ++i) { // 漫反射 vec3 lightDir = normalize(lights[i].Position - FragPos); vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Albedo * lights[i].Color; lighting += diffuse; } FragColor = vec4(lighting, 1.0); }
光照处理阶段着色器接受三个uniform纹理,代表G缓冲,它们包含了我们在几何处理阶段储存的所有数据。如果我们现在再使用当前片段的纹理坐标采样这些数据,我们将会获得和之前完全一样的片段值,这就像我们在直接渲染几何体。在片段着色器的一开始,我们通过一个简单的纹理查找从G缓冲纹理中获取了光照相关的变量。注意我们从gAlbedoSpec
纹理中同时获取了Albedo
颜色和Spqcular
强度。
因为我们现在已经有了必要的逐片段变量(和相关的uniform变量)来计算布林-冯氏光照(Blinn-Phong Lighting),我们不需要对光照代码做任何修改了。我们在延迟着色法中唯一需要改的就是获取光照输入变量的方法。
运行一个包含32个小光源的简单Demo会是像这样子的:
下面把实现的GPU源代码给读者展示如下,首先展示的是几何渲染阶段的顶点着色器代码:
#version 330 core layout (location = 0) in vec3 position; layout (location = 1) in vec3 normal; layout (location = 2) in vec2 texCoords; out vec3 FragPos; out vec2 TexCoords; out vec3 Normal; uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { vec4 worldPos = model * vec4(position, 1.0f); FragPos = worldPos.xyz; gl_Position = projection * view * worldPos; TexCoords = texCoords; mat3 normalMatrix = transpose(inverse(mat3(model))); Normal = normalMatrix * normal; }
几何渲染阶段的片段着色器代码如下所示:
#version 330 core layout (location = 0) out vec3 gPosition; layout (location = 1) out vec3 gNormal; layout (location = 2) out vec4 gAlbedoSpec; in vec2 TexCoords; in vec3 FragPos; in vec3 Normal; uniform sampler2D texture_diffuse1; uniform sampler2D texture_specular1; void main() { // Store the fragment position vector in the first gbuffer texture gPosition = FragPos; // Also store the per-fragment normals into the gbuffer gNormal = normalize(Normal); // And the diffuse per-fragment color gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb; // Store specular intensity in gAlbedoSpec's alpha component gAlbedoSpec.a = texture(texture_specular1, TexCoords).r; }
另外光照渲染阶段的顶点着色器代码如下所示:
#version 330 core layout (location = 0) in vec3 position; layout (location = 1) in vec2 texCoords; out vec2 TexCoords; void main() { gl_Position = vec4(position, 1.0f); TexCoords = texCoords; }光照渲染阶段的片段着色器代码如下所示:
#version 330 core layout (location = 0) in vec3 position; layout (location = 1) in vec2 texCoords; out vec2 TexCoords; void main() { gl_Position = vec4(position, 1.0f); TexCoords = texCoords; }
另外,延迟着色法的其中一个缺点就是它不能进行混合(Blending),因为G缓冲中所有的数据都是从一个单独的片段中来的,而混合需要对多个片段的组合进行操作。延迟着色法另外一个缺点就是它迫使你对大部分场景的光照使用相同的光照算法,你可以通过包含更多关于材质的数据到G缓冲中来减轻这一缺点,这个将在后面的博客中介绍。。。。。。