8-延迟渲染
至今为止,我们用的光照都是前向渲染(forward rendering),这是一种直接的渲染物体和光照的方法。对于每个物体,都要计算一次着色和光照,这虽然很容易实现,但性能不怎么好,且让片段着色器跑了许多用不到的信息。
而延迟渲染(Deferred Rendering)就是为了解决这个问题出现的。该方式给了我们很多显著优化多光源场景的选择,允许我们一次渲染成百上千的光源,且帧率可接受。例如下面1847个点光源:
延迟渲染
基本思想
延迟渲染的基本思想是,将计算量大的渲染阶段(如光照)延迟/推迟到更后的阶段。延迟渲染主要由两步构成:
几何阶段(Geometry Pass):先渲染一次场景,然后获取场景中各个种类的几何信息到一个被称为 G-buffer 的纹理集合中。
光照阶段(Lighting Pass):使用G-Buffer提供的几何信息一次性为所有物体计算光照。
这种渲染方法的主要优点就是能保证在G-Buffer中的片段和在屏幕上的片段信息是相同的,因为已经过深度测试。这省下许多不必要的渲染步骤,给我们更多优化空间。
但延迟渲染也有缺点:
- G-Buffer需要的显存消耗大;
- 不支持混合,因为我们只有经过深度测试后的信息;
- 不支持MSAA,因为G-Buffer中每一像素的信息都是有用的,如果用MSAA处理会全部失去意义。
像之前处理泛光一样,G-Buffer也能交由FBO管理。
G-Buffer
G-Buffer存储了所有和光照计算步骤有关的纹理,看看光照计算需要什么:
- 3D世界空间的位置向量信息,用于计算(插值了的)片段的
lightDir
和viewDir
; - 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