8-延迟渲染

至今为止,我们用的光照都是前向渲染(forward rendering),这是一种直接的渲染物体和光照的方法。对于每个物体,都要计算一次着色和光照,这虽然很容易实现,但性能不怎么好,且让片段着色器跑了许多用不到的信息。

而延迟渲染(Deferred Rendering)就是为了解决这个问题出现的。该方式给了我们很多显著优化多光源场景的选择,允许我们一次渲染成百上千的光源,且帧率可接受。例如下面1847个点光源:

延迟渲染

基本思想

延迟渲染的基本思想是,将计算量大的渲染阶段(如光照)延迟/推迟到更后的阶段。延迟渲染主要由两步构成:

  1. 几何阶段(Geometry Pass):先渲染一次场景,然后获取场景中各个种类的几何信息到一个被称为 G-buffer 的纹理集合中。

  2. 光照阶段(Lighting Pass):使用G-Buffer提供的几何信息一次性为所有物体计算光照。

这种渲染方法的主要优点就是能保证在G-Buffer中的片段和在屏幕上的片段信息是相同的,因为已经过深度测试。这省下许多不必要的渲染步骤,给我们更多优化空间。

但延迟渲染也有缺点:

  • G-Buffer需要的显存消耗大;
  • 不支持混合,因为我们只有经过深度测试后的信息;
  • 不支持MSAA,因为G-Buffer中每一像素的信息都是有用的,如果用MSAA处理会全部失去意义。

像之前处理泛光一样,G-Buffer也能交由FBO管理。

G-Buffer

G-Buffer存储了所有和光照计算步骤有关的纹理,看看光照计算需要什么:

  • 3D世界空间的位置向量信息,用于计算(插值了的)片段的lightDirviewDir
  • RGB diffuse颜色向量信息,也被称为albedo;
  • 3D法线信息,决定表面的斜率;
  • 镜面反射强度浮点值;
  • 所有光源的位置和颜色向量;
  • 玩家/摄像机的位置向量;

有了这些值,就能计算冯氏光照模型了。整个流程的伪代码如下:

while (...)		// 渲染循环
{
// 1.几何处理Pass: 将所有几何/颜色量存储到g-buffer
	glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
    glClearColor(0.0, 0.0, 0.0, 1.0);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    gBufferShader.use();
    for (Object obj : objects)
    {
        ConfigureShaderTransformsAndUniforms();
        obj.Draw();
    }
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2.光照处理Pass: 用g-buffer的信息计算场景光照
    lightingPassShader.use();
    bindAllGBufferTextures();
    SetLightingUniforms();
    RenderScreenQuad();
}

我们需要存储每一个片段的 位置向量,法线向量,颜色向量和 镜面反射强度值。需要用到之前泛光章节中的MRT技术。

几何处理阶段

在几何处理阶段,需要准备一个gBuffer的FBO,它有多个颜色附件,一个深度RBO。对于位置和法线贴图,我们使用高精度纹理存储(16/32位float);对于颜色亮度和镜面反射强度值,使用默认精度纹理存储(8位byte):

unsigned int gBuffer;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
unsigned int gPosition, gNormal, gColorSpec;
  
// - position color buffer
glGenTextures(1, &gPosition);
glBindTexture(GL_TEXTURE_2D, gPosition);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, 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_ATTACHMENT0, GL_TEXTURE_2D, gPosition, 0);
  
// - normal color buffer
glGenTextures(1, &gNormal);
glBindTexture(GL_TEXTURE_2D, gNormal);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, 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_ATTACHMENT1, GL_TEXTURE_2D, gNormal, 0);
  
// - color + specular color buffer
glGenTextures(1, &gAlbedoSpec);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, 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);
  
// - tell OpenGL which color attachments we'll use (of this framebuffer) for rendering 
unsigned int attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
glDrawBuffers(3, attachments);
  
// then also add render buffer object as depth buffer and check for completeness.
[...]

接下来准备编写G-Buffer的片段着色器:

#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()
{
	// 存储位置信息
	gPosition = FragPos;
	// 存储法线信息
	gNormal = normalize(Normal);
	// 存储diffuse材质颜色 - albedo
	gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb;
	// 存储specular材质值 - specular
	gAlbedoSpec.a = texture(texture_specular1, TexCoords).r;
}

需要注意的是,光线计算需要保证全部相关计算量在同一坐标系中,这里将它们都存到世界空间坐标系中。

最终得到的G-Buffer如下:

光照处理阶段

接下来我们需要逐像素遍历G-Buffer纹理中的信息,使用这些信息去计算光照。对于光照处理阶段,我们将要渲染一个2D屏幕四边形(就像后处理时候那样),并对每个像素计算一次光照:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
// also send light relevant uniforms
shaderLightingPass.use();
SendAllLightUniformsToShader(shaderLightingPass);
shaderLightingPass.setVec3("viewPos", camera.Position);
RenderQuad();  

在渲染前要绑定G-Buffer的所有纹理,然后也将光照计算相关的uniform变量设置好。

光照处理阶段的片段着色器和之前光照章节的相似,不一样的是光照计算变量的获取方式,现在直接从gBuffer中获取了:

#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()
{             
    // retrieve data from G-buffer
    vec3 FragPos = texture(gPosition, TexCoords).rgb;
    vec3 Normal = texture(gNormal, TexCoords).rgb;
    vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb;
    float Specular = texture(gAlbedoSpec, TexCoords).a;
    
    // then calculate lighting as usual
    vec3 lighting = Albedo * 0.1; // hard-coded ambient component
    vec3 viewDir = normalize(viewPos - FragPos);
    for(int i = 0; i < NR_LIGHTS; ++i)
    {
        // diffuse
        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);
} 

光照处理阶段接受3个来自G-Buffer的uniform纹理,然后逐像素采样,再进行平常的光照计算即可。如下是渲染了32个小光源的场景:

与前向渲染结合

接下来我们想渲染每个光源的小方块,最简单的方法就是利用前向渲染,在延迟渲染管线结束后将光源方块渲染上去:

// deferred lighting pass
[...]
RenderQuad();
  
// now render all light cubes with forward rendering as we'd normally do
shaderLightBox.use();
shaderLightBox.setMat4("projection", projection);
shaderLightBox.setMat4("view", view);
for (unsigned int i = 0; i < lightPositions.size(); i++)
{
    model = glm::mat4(1.0f);
    model = glm::translate(model, lightPositions[i]);
    model = glm::scale(model, glm::vec3(0.25f));
    shaderLightBox.setMat4("model", model);
    shaderLightBox.setVec3("lightColor", lightColors[i]);
    RenderCube();
}

结果会出现问题,可能是像教程那样,渲染出的光源方块无视深度覆盖到延迟渲染结果的图上;也有可能是延迟渲染结果的图将小方块覆盖了。这是深度缓冲出现了问题,我们需要做的就是将存储在G-Buffer的深度信息拷贝到默认帧缓冲的深度缓冲中,然后才渲染光源方块。

可以通过glBlitFramebuffer来将一个帧缓冲中的内容拷贝到另一个帧缓冲,这在我们之前学习抗锯齿的时候也用到过。以下是将G-Buffer上的深度信息拷贝到默认帧缓冲的代码:

glBindFramebuffer(GL_READ_FRAMEBUFFER, gBuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); // write to default framebuffer
glBlitFramebuffer(
  0, 0, SCR_WIDTH, SCR_HEIGHT, 0, 0, SCR_WIDTH, SCR_HEIGHT, GL_DEPTH_BUFFER_BIT, GL_NEAREST
);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

然后才渲染光源方块,可见效果不错:

这样就能结合前向渲染和延迟渲染了,可以通过这样的方式实现延迟渲染做不到的事。

大量光源

延迟渲染技术支持渲染巨量光源,且性能消耗不大。如果我们仍然通过遍历场景光源去计算光照的话,延迟渲染还是不能渲染巨量光源。因此需要优化,这里使用 体积光(Light Volumes,也称绘制光源形状) 技巧来优化。

通常在渲染大的光照场景时,需要计算场景中每个光源对当前着色点的贡献,不管光源离着色点有多远。有大部分光源实际上照不到着色点,进行了许多不必要的计算。

体积光背后的思想是计算光源的半径/体积,知道光源照射的范围。由于大多数光源使用类似于亮度attenuation的量,我们可以用它计算光源照射的范围。有了范围后,只需计算照射范围包含该着色点的光源贡献即可,省了许多计算量。

接下来看看如何计算光源的体积/半径。

计算光源体积/半径

为了获取体积光的半径,我们需要求解亮度为零时的距离。首先看看亮度公式: \[ \nonumber F_{light}=\frac{I}{K_c+K_l*d+K_q*d^2} \] 实际上,这个等式根本不会达到0,所以无解。但我们可以找一个接近0的值来代替它,这里使用\(5/256\)。那么就需要求解以下式子了: \[ \nonumber \frac5{256}=\frac{I_{max}}{Attenuation} \] 其中\(I_{max}\)是光源最亮时候的值。然后求解上式有: \[ \nonumber K_q*d^2+K_l*d+K_c-I_{max}*\frac{256}5=0 \] 可以发现这是个关于d的一元二次方程,用求根公式就能求解出d了: \[ \nonumber d=\frac{-K_l+\sqrt{K_l^2-4*K_q*(K_c-I_{max}*\frac{256}5)}}{2*K_q} \] 翻译成代码如下:

float constant = 1.0;
float linear = 0.7;
float quadratic = 1.8;
float lightMax = std::fmaxf(std::fmaxf(lightColor.r, lightColor.g), lightColor.b);
float radius = (-linear +  std::sqrtf(linear * linear - 4 * quadratic * (constant - (256.0 / 5.0) * lightMax))) / (2 * quadratic); 

然后更新光照计算的片段着色器代码,只在距离内进行光照计算:

struct Light
{
	...
    float Radius;
};

void main()
{
    ...
    for(int i = 0; i < NR_LIGHTS; ++i)
    {
        // 计算光源和着色点的距离
        float distance = length(lights[i].Position - FragPos);
        if (distance < lights[i].Radius)
        {
            ...
        }
	}
}

如何真正使用体积光

上面片段着色器的运行效率实际上并没有提升多少,只是说明如何用这个方法减少光照计算。这是因为 GPU和GLSL着色器语言不擅长优化循环和条件分支。由于着色器程序是在高度并行化的GPU上执行的,大多数架构要求大量线程同时运行相同的着色器代码。这意味着运行一个着色器需要执行if全部的分支才能让多个线程同步,我们实际上还是计算了所有光源。

使用体积光更好的方法是渲染一个实际球体,并根据光体积的半径缩放,球心是光源位置。然后就是该技巧的关键:我们使用延迟光照着色器去渲染这些球,然后只渲染被球所影响的像素而跳过其他的像素:

场景中每个光源都会这样做一遍,并将结果混合到对应像素中。结果和之前做法一样准确,但这次只渲染了光源照射范围内的像素。复杂度从nr_objects * nr_lights变成nr_objects + nr_lights

要想使用这个方法,需要启用面剔除(否则会渲染一个光源两次)。当它被启用时,用户可能会走进体积光内部,导致光源不会被渲染(因为背面剔除),以缺少这个光源的影响。可以通过只渲染球的背向面解决这个问题。

渲染体积光确实会带来沉重的性能负担,虽然比普通的延迟渲染快一点,但这仍然不是最好的优化。另外两个更流行高效的方法是 延迟光照(Deferred Lighting)切片式延迟着色(Tile-based Deferred Shading),这些方法会很大程度上提高大量光源渲染的效率,且允许MSAA。

延迟渲染vs前向渲染

延迟渲染本身(没有体积光)的优化就很好了,每个像素仅仅运行一个单独的片段着色器;而对于前向渲染,则需要对一个像素运行多个片段着色器。当然,延迟渲染本身也有缺点,比如大内存开销,没有MSAA和混合(仍需要正向渲染的配合)。

小场景使用前向渲染,大场景使用延迟渲染。

实际上,基本上所有使用前向渲染完成的效果也能被延迟渲染实现,这需要小小的翻译步骤。例如要实现法线映射,需要将几何处理阶段的着色器输出一个世界空间法线,它从法线贴图中提取出来(使用TBN矩阵)而不是表面法线。例如要实现视差映射,则需要先置换几何体通道中的纹理坐标,然后再对对象的漫反射、镜面反射和法线纹理进行采样。

参考资料

  • 延迟着色法 - LearnOpenGL CN (learnopengl-cn.github.io)
  • LearnOpenGL - Deferred Shading