标签:延迟着色
前向着色和延迟着色
在开始本章的新内容前,我们先来回忆一下之前的渲染流程是什么:在渲染一个物体前,我们需要把shader准备好。由于需要在片元着色器中进行光照计算,我们需要把场景中所有的光源信息都传递到shader中保存,这样在渲染物体的时候,就可以计算出这个物体在光照下的显示效果。如果场景中有多个物体(事实上场景中只有一个物体的情况非常少),那么我们就必须对每一个物体都走一遍上述的流程,直到所有物体都渲染完成为止。
上面的流程有没有问题?没有。这个流程非常容易理解,而且实现的效果也不错。按照这个流程,我们可以在大部分的场景中获得一个不错的渲染效果。但是(没错,又是但是),如果我们的场景中物体和光源都非常多,那么上述的流程就显得有些笨拙了。
分析一下,假设场景中有m个物体和n个光源,我们执行渲染流程的次数就是m * n次,算法的时间复杂度就是O(m * n)。这个复杂度很高,如果m=n,那么这个复杂度就是一个平方级的复杂度,在算法的领域里,平方级的复杂度属于不可接受的范围,这就逼着我们去想办法降低这个复杂度,使其达到一个可以让人接受的范围。
在算法中,复杂度有这么几种等级:lg(n), n的平方根,n,n * lg(n),n的平方,n的三次方,2的n次方,n的阶乘。速度最快的等级是lg(n),这种速度在现实生活中就像是光速一样,快地让人感觉不到。而n的阶乘的速度,就像是蜗牛爬行的速度一样慢的令人发指,可能运行到世界末日都没有结果。一般而言,可以接受的复杂度是:lg(n),n的平方根,n,以及n * lg(n)。
这下,我们本章要讲的东西就有了发挥的余地。我们把上述的渲染流程称作前向渲染或者前向着色,接下来要介绍的方法被称作延迟渲染或者延迟着色。为了方便起见,我们统一用前向着色和延迟着色来称呼两种方法。延迟着色方法,是先把场景中所有物体渲染一次,将其相关数据(包括位置、法向量、纹理等)保存到一个帧缓存中,然后,用这些数据配合上光源信息再进行一次计算,得到最终的效果。这样,我们的算法复杂度就降低成O(m + n),也就是线性复杂度,这就是一个可以接受的复杂度等级。
延迟着色的流程
一个完整的延迟着色包括以下4个步骤
- 1、几何阶段:这个阶段要将场景中的物体渲染到帧缓存中,将物体的数据保存起来。
- 2、光照阶段:在这个阶段,运用上述帧缓存中的数据,结合光源信息,计算出场景经过光照后的结果
- 3、后期处理阶段:经过光照后,还需要对场景进行一些抗锯齿等后期处理,保证场景的效果
- 4、最终阶段:将图像传递到主缓存中去,然后在屏幕上显示
下面这张图很好的展示了延迟着色的流程,请仔细看:
在本章的例子中,我们不去实现抗锯齿等后期特效,专注于前面两个阶段,整个场景在光照阶段完成后直接输出到屏幕上,不需要再做其他处理。下面,我们分别来看一下两个阶段到底要做些什么事情。
几何阶段
几何阶段中,我们要将场景的信息保存到帧缓存,以便后面的光照阶段使用。这就产生了一个问题,我们需要保存哪些数据呢?先来列举我们在前向着色中用到的数据:
- 顶点的坐标
- 顶点的纹理
- 顶点的法向量
- 镜面高光强度
- 光源位置
- 光源颜色
- 观察者位置
在几何阶段,我们需要准备的东西是可以让光照阶段使用的数据。研究一下之前的片元着色器代码,我们发现,光源位置、光源颜色、观察者位置都是可以在光照阶段直接传递给片元着色器的。而需要从顶点着色器传递过来的数据是:法向量、片元位置、纹理坐标。也就是说,物体的信息都需要从几何阶段传递过去,这样,我们就能得出结论:顶点坐标、纹理、法向量以及镜面高光强度都是需要保存的数据。我们要将这些数据保存到一个名叫G-Buffer的缓存中。
G-buffer是一个我们创造出来的概念,它本质上是一个帧缓存,是那些我们在几何阶段用来保存数据缓存的统称。这些缓存可能是纹理图,也可能是其他东西我们不知道,我们把存有这些数据的帧缓存统称为G-buffer。
创建帧缓存的流程我们已经非常熟悉了,由于我们要保存4种数据,我们至少需要将3个颜色缓存(顶点的纹理和高光强度共用一个缓存)附加到帧缓存上,构成MRT。这样,我们就能使用一次渲染得到所有的物体信息。(有关MRT的内容,请参考HDR和Bloom一文。)如果要把G-Buffer中的数据显示出来,结果就是这个样子:
这里只给出了顶点、法线和纹理数据,没有高光信息。因为高光信息和纹理数据是保存在一起的,它只占了1个字节的空间,显示出来的话就是一片红色,没有太大意义,有兴趣的同学不妨尝试显示出来。
光照阶段
我们已经拥有了光照计算要用到的所有数据,这些数据保存在G-Buffer中,以纹理图的格式保存。到了光照阶段,我们就要用起来了。这一阶段的主要工作集中在片元着色器中,我们通过绑定纹理图的方式将G-Buffer中的3个颜色缓存纹理图传递到片元着色器中。同时,也将场景中的光源信息传递到片元着色器中,这些信息包括:光源位置、光源颜色等。然后,在片元着色器中只要像平常一样进行光照计算,就可以使整个场景都得到光照的效果了。
这一个阶段完成后,我们的场景就是这个样子:
这个场景中,我们用了128个聚光灯光源去照射前面的盒子,每个光源还能移动其位置,完成计算后,我们的场景就是这么华丽。
延迟着色的实现
终于到了编码实现的时候了。在编码之前,请先下载本章要用到的工程源码,我们会在这些代码的基础上添加场景,完成延迟着色的功能。在动手之前,我们先来理一下实现的思路,上面的源码是一个空壳子里面没有任何的物体,也没有光源,更加没有帧缓存的东西,这些都是要我们一步步去实现的,因此,我们的实现思路是:
- 一、在场景中的固定位置放置一些立方体盒子,放置的位置是xy平面,在原点的周围放置11x11个盒子。
- 二、创建G-Buffer,包括3个颜色附件和一个深度值附件
- 三、创建着色器,将场景渲染到G-Buffer中
- 四、显示G-Buffer中的信息
- 五、创建光源,为光源添加移动的功能
- 六、光照计算,显示最终场景
- 七、显示光源,用纯色立方体代替光源显示
顺着这个思路,我们就能写出上面的场景,想想有点小激动,赶紧开始吧。
第一步、创建场景
创建场景的方法很简单,使用renderTextureCube()函数就可以了。在循环体中,我们只要设定好盒子的位置、大小、以及旋转量就可以非常容易的绘制出这个场景。代码如下:
const int dim = 11;
const float offset = (float(dim - 1) * 1.5f) * 0.5f;
glm::mat4 model;
for (int yy = 0; yy < dim; ++yy) {
for (int xx = 0; xx < dim; ++xx) {
model = glm::mat4();
model = glm::translate(model, glm::vec3(-offset + float(xx) * 1.5f, -offset + float(yy) * 1.5f, 0.0f));
model = glm::rotate(model, glm::radians(currentFrame * -25.0f), glm::vec3(0.5f, 0.5f, 0.0f));
model = glm::scale(model, glm::vec3(0.5f));
ourShader.setMat4("model", glm::value_ptr(model));
renderTextureCube(ourShader);
}
}
代码本身非常直观,不多解释。
第二步、创建G-Buffer
运用前一章学到的知识,我们绑定的纹理缓存需要高精度的数据格式,这里我们选择GL_RGB16F格式,但是纹理和镜面高光的数据不用,因为它们本身就是颜色值,所以我们仍然是用GL_RGBA格式,如下:
/** G-Buffer的创建 */
unsigned int gBuffer;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
//位置缓存
unsigned int gPosition;
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);
//法线缓存
unsigned int gNormal;
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);
//漫反射和镜面高光缓存
unsigned int gAlbedoSpec;
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);
//告诉OpenGL要渲染三个缓存
unsigned int attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
glDrawBuffers(3, attachments);
//深度缓存
unsigned int rboDepth;
glGenRenderbuffers(1, &rboDepth);
glBindRenderbuffer(GL_RENDERBUFFER, rboDepth);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, SCR_WIDTH, SCR_HEIGHT);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rboDepth);
// 检查
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "帧缓存初始化失败!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
/** G-Buffer的创建-结束 */
位置缓存和法线缓存都采用GL_RGB16F的格式保存,这样我们得到的位置和法线数据都是高精度的。漫反射和镜面高光缓存就要用GL_RGBA格式,注意在写的时候后面的参数要从GL_FLOAT改成GL_UNSIGNED_BYTE。然后,告诉OpenGL在渲染这个帧缓存的时候需要渲染3个颜色缓存。最后,使用渲染缓存作为深度缓存附加到帧缓存上,这样,我们就大功告成了。
第三步、创建着色器,绘制场景
顶点着色器中,其他的东西都一样只有一点要注意,那就是输出的片元位置是世界空间中的位置,我们来看:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
out vec3 FragPos;
out vec2 TexCoords;
out vec3 Normal;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
vec4 worldPos = model * vec4(aPos, 1.0);
FragPos = worldPos.xyz;
TexCoords = aTexCoords;
mat3 normalMatrix = transpose(inverse(mat3(model)));
Normal = normalMatrix * aNormal;
gl_Position = projection * view * worldPos;
}
可以看到,我们将顶点的世界坐标位置赋值给FragPos使其传递给片元着色器,当然,纹理坐标、法向量也需要传递给片元着色器。
#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 diffuse;
uniform sampler2D specular;
void main()
{
//保存位置信息
gPosition = FragPos;
//保存法线信息
gNormal = normalize(Normal);
//保存漫反射颜色信息
gAlbedoSpec.rgb = texture(diffuse, TexCoords).rgb;
//保存镜面高光颜色信息
gAlbedoSpec.a = texture(specular, TexCoords).r;
}
跟前一章(本文中多次提到了前一章的知识,如果你对前一章还不了解的话,强烈建议你仔细阅读了前一章之后再来阅读本章,这会使你理解更为容易。)的片元着色器一样,我们需要用诸如layout (location = 0)这样的代码来指定输出数据保存到哪个颜色缓存中。对于传入的数值,片元位置可以直接输出,法向量需要规范化一下,漫反射纹理需要从输入的diffuse中采样保存,同样,镜面高光信息也需要从输入的纹理中采样保存。
准备好着色器之后,在主函数中加载它,在循环体中使用它就可以了:
/** 1、几何阶段 */
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
glClearColor(0.05f, 0.05f, 0.05f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ourShader.use();
glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
glm::mat4 view = camera.GetViewMatrix();
ourShader.setMat4("projection", glm::value_ptr(projection));
ourShader.setMat4("view", glm::value_ptr(view));
...
glBindFramebuffer(GL_FRAMEBUFFER, 0);
至此,我们的G-Buffer中就有了下一阶段要用到的所有数据,先将它显示出来看看效果。
四、显示G-Buffer
要显示G-Buffer中的数据,我们就需要对G-Buffer的颜色缓存采样,然后绘制到默认帧缓存中。要做到这一点,我们首先得要新建两个着色器,分别命名为:showShader.vs、showShader.fs。在.vs文件(顶点着色器)中,我们需要获得位置和纹理输入,并将其输出给片元着色器,片元着色器只要根据纹理坐标对其内的纹理图采样就行了,两个文件的代码如下所示:
//showShader.vs
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoords;
out vec2 TexCoords;
void main()
{
TexCoords = aTexCoords;
gl_Position = vec4(aPos, 1.0);
}
//showShader.fs
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D showMap;
void main()
{
FragColor = texture(showMap, TexCoords);
}
有了着色器程序之后,创建一个Shader对象加载它们,这个Shader对象命名为showShader。使用showShader.setInt("showMap", 0);将showMap成员设置好后,就可以在主循环中根据不同的渲染模式绘制不同的信息了:
//2、渲染到四边形中
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
if (showMapType == 1) {
showShader.use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
renderQuad();
}
else if (showMapType == 2) {
showShader.use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gNormal);
renderQuad();
}
else if (showMapType == 3) {
showShader.use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
renderQuad();
}
绘制之前,自然是要把颜色缓存和深度缓存都清理一遍,然后,根据showMapType的值该设置不同的纹理,这里我们在showMapType为1时绘制位置信息,为2时绘制法向量信息,为3时绘制纹理信息。showMapType的值可以通过键盘上的1、2、3键进行设置,这样方便我们观察不同的数据。完成编码之后,编译运行,你就能看到下面的场景:
按下键盘上的2或者3,你就能看到另外的两个场景,不错吧!到这一步的源代码打开这里的链接下载。
五、创建光源,为光源添加移动功能
这一步,我们要来创建场景中的光源。既然延迟着色要在光源多的环境下才能体现优势,那我们就多创建几个光源好了,建它个128个光源:
/** 光源信息 */
const int NR_LIGHTS = 128;
std::vector<glm::vec3> lightPositions;
std::vector<glm::vec3> originLightPositions; //光源初始位置,光源只能在此位置2.5的矩形范围内移动
std::vector<glm::vec2> lightMoveDirections; //光源移动方向
std::vector<glm::vec3> lightColors;
srand(13);
for (int i = 0; i < NR_LIGHTS; i++) {
float xPos = ((rand() % 100) / 100.0) * 15.0 - 7.5;
float yPos = ((rand() % 100) / 100.0) * 15.0 - 7.5;
float zPos = 2.0;
lightPositions.push_back(glm::vec3(xPos, yPos, zPos));
originLightPositions.push_back(glm::vec3(xPos, yPos, zPos));
float xDir = ((rand() % 100) / 100.0) * 2 - 1;
float yDir = ((rand() % 100) / 100.0) * 2 - 1;
lightMoveDirections.push_back(glm::normalize(glm::vec2(xDir, yDir)));
//颜色值,在0.5到1之间
float rColor = ((rand() % 100) / 200.0f) + 0.5;
float gColor = ((rand() % 100) / 200.0f) + 0.5;
float bColor = ((rand() % 100) / 200.0f) + 0.5;
lightColors.push_back(glm::vec3(rColor, gColor, bColor));
}
/** 光源信息-结束 */
代码非常直观,不多解释。移动功能也非常容易,为了方便起见,我们允许光源在其出生点周围2.5的矩形范围内移动,如果超过这个范围,则改变其运动方向,返回到2.5的范围之内:
//移动光源
for (int i = 0; i < lightMoveDirections.size(); ++i) {
glm::vec2 moveStep = lightMoveDirections[i] * speed;
lightPositions[i] += glm::vec3(moveStep, 0.0f);
if (lightPositions[i].x < (originLightPositions[i].x - 2.5) || lightPositions[i].x >(originLightPositions[i].x + 2.5))
lightMoveDirections[i].x = -lightMoveDirections[i].x;
if (lightPositions[i].y < (originLightPositions[i].y - 2.5) || lightPositions[i].y >(originLightPositions[i].y + 2.5))
lightMoveDirections[i].y = -lightMoveDirections[i].y;
}
每当有一个轴的坐标超过范围时,我们就将相应的移动方向置反,使其往回移动,这样我们就将光源的移动范围限制在了2.5的矩形范围内。
六、光照计算,显示最终场景
执行光照计算的着色器和之前的着色器都不同,所以,我们还需要新建两个着色器,命名为:deferred_shading.vs和deferred_shading.fs。光照计算的重点在于片元着色器,顶点着色器的代码我就直接贴出了,我们要将注意力集中到片元着色器上:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoords;
out vec2 TexCoords;
void main()
{
TexCoords = aTexCoords;
gl_Position = vec4(aPos, 1.0);
}
片元着色器要比顶点着色器“粗壮”的多,它是骨干是精英,它完成了所有的光照计算操作,所以看起来要比顶点着色器“可怕”许多:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedoSpec;
struct SpotLight{
vec3 Direction;
vec3 Position;
float cutOff;
float outerCutOff;
vec3 Color;
float Linear;
float Quadratic;
};
const int NR_LIGHTS = 128;
uniform SpotLight lights[NR_LIGHTS];
uniform vec3 viewPos;
//计算聚光灯的效果
vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir, vec3 diffuse, float specular);
void main() {
//对图片采样
vec3 FragPos = texture(gPosition, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Diffuse = texture(gAlbedoSpec, TexCoords).rgb;
float Specular = texture(gAlbedoSpec, TexCoords).a;
//和平常一样计算光照
vec3 lighting = vec3(0.0f);
vec3 viewDir = normalize(viewPos - FragPos);
for (int i = 0; i < NR_LIGHTS; ++i) {
lighting += CalcSpotLight(lights[i], Normal, FragPos, viewDir, Diffuse, Specular);
}
FragColor = vec4(lighting, 1.0);
}
//计算聚光灯的影响
vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir, vec3 Diffuse, float Specular){
//环境光
vec3 ambient = 0.1 * Diffuse;
//漫反射光
vec3 norm = normalize(normal);
vec3 lightDir = normalize(light.Position - fragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.Color * diff * Diffuse;
//镜面高光
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
vec3 specular = light.Color * spec * Specular;
//聚光灯
float theta = dot(lightDir, normalize(-light.Direction)); //计算片元角度的cos值
float epsilon = light.cutOff - light.outerCutOff; //计算epsilon的值,用内锥角的cos值减去外锥角的cos值
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0); //根据公式计算光照强度,并限制结果的范围
diffuse *= intensity;
specular *= intensity;
//衰减
float distance = length(light.Position - fragPos);
float attenuation = 1.0 / (1 + light.Linear * distance + light.Quadratic * (distance * distance));
ambient *= attenuation;
diffuse *= attenuation;
specular *= attenuation;
return ambient + diffuse + specular;
}
聚光灯的计算函数,我们直接从三种光源模型一章中复制过来,修改一下参数就行。原理是一样的,所以计算流程没有改变。本章的实现是main函数中的代码,对输入的三组数据进行采样,作为参数传递给CalcSpotLight进行计算,将所有光源的计算结果累加,得到最终的颜色值,输出!
准备好着色器之后,我们就可以使用了。首先,片元着色器的纹理需要设置好:
shaderLightingPass.use();
shaderLightingPass.setInt("gPosition", 0);
shaderLightingPass.setInt("gNormal", 1);
shaderLightingPass.setInt("gAlbedoSpec", 2);
接着,绘制的时候绑定所需的纹理:
else if (showMapType == 4) {
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
...
}
新建一个显示类型的分支,在showMapType等于4的时候显示延迟着色的效果。然后,设置光源属性,进行绘制:
shaderLightingPass.use();
for (int i = 0; i < lightPositions.size(); ++i) {
shaderLightingPass.setVec3("lights[" + std::to_string(i) + "].Position", lightPositions[i]);
shaderLightingPass.setVec3("lights[" + std::to_string(i) + "].Color", lightColors[i]);
shaderLightingPass.setFloat("lights[" + std::to_string(i) + "].Linear", linear);
shaderLightingPass.setFloat("lights[" + std::to_string(i) + "].Quadratic", quadratic);
shaderLightingPass.setVec3("lights[" + std::to_string(i) + "].Direction", glm::vec3(0.0f, 0.0f, -3.0f));
shaderLightingPass.setFloat("lights[" + std::to_string(i) + "].cutOff", glm::cos(glm::radians(17.5f)));
shaderLightingPass.setFloat("lights[" + std::to_string(i) + "].outerCutOff", glm::cos(glm::radians(20.0f)));
}
shaderLightingPass.setVec3("viewPos", camera.Position);
renderQuad();
光源的所有信息都需要这样设置,完成之后,编译运行,就能看到效果了:
Very good!这样的场景才够爽!
到这一阶段的代码,你可以在这里下载。
七、显示光源,前向着色和延迟着色的结合
理论上,到上一步为止,我们的延迟着色功能已经实现了,不过,我们还可以来继续搞点事情,把前向着色和延迟着色结合起来!利用前向着色显示光源,用延迟着色显示场景。听起来好像挺复杂的,做起来非常容易。就是在渲染了场景之后按照以前的流程绘制一系列的立方体而已。而且绘制立方体的着色器也不需要有什么特别的功能,直接输出某一种颜色就可以了。
//显示所有的光源
glClear(GL_DEPTH_BUFFER_BIT);
shaderLightBox.use();
shaderLightBox.setMat4("projection", glm::value_ptr(projection));
shaderLightBox.setMat4("view", glm::value_ptr(view));
for (unsigned int i = 0; i < lightPositions.size(); i++) {
glm::mat4 model = glm::mat4();
model = glm::translate(model, lightPositions[i]);
model = glm::scale(model, glm::vec3(0.1f));
shaderLightBox.setMat4("model", glm::value_ptr(model));
shaderLightBox.setVec3("lightColor", lightColors[i]);
renderCube();
}
顶点着色器和片元着色器非常简单,我就不贴出来了,从前面的例子中拷贝一份就可以。绘制光源的时候,最重要的一点是一定要将深度信息清空!否则,无法看到光源。完成所有这些操作后,我们的场景就变的更好玩了:
这实在是太有趣了。好了,完整的代码在这里,有兴趣的同学在此基础上继续搞事情吧。
延迟着色的缺点
当然了,延迟着色也不是万能的,它的缺点也十分明显,主要的缺点有以下四个:
- 内存开销大
- 读写G-Buffer的内存带宽用量是性能瓶颈
- 无法实现透明物体的渲染
- 对MSAA的支持不友好,主要原因是需要开启MRT
由于这些缺点,如果场景中光源数量较少,使用延迟着色的效率可能还比不上前向着色,所以,慎重而有目的性地选择一种合适的着色方式是一个不错的主意。
延迟着色的优化
既然知道了延迟着色的缺点,那我们就可以对延迟着色进行一些改进。针对读写G-Buffer速度慢的缺点,我们最直接的想法就是使G-Buffer数据结构变小就行了,这就衍生出了延迟光照(Deferred Lighting)方法。另一种方法是对光源进行分组,每一组光源一起处理,这就是分块延迟渲染方法(Tile-Based Deferred Rendering)。
限于篇幅,我们只是提一下这两种方法,有兴趣的同学可以阅读本文最后的参考资料获取更详细的信息。
总结
在本章中,我们学习了延迟着色方法。这种方法主要用于场景中物体和光源数量都较多的情况。它的思路是将场景中的物体信息先渲染到G-Buffer中,在用G-Buffer中的数据结合光源信息进行一次计算,得到最终的光照效果,这样它的时间复杂度就从O(m*n)降到O(m+n),变得非常有效率。当然,延迟着色也有其缺点,比如内存消耗大、读写G-Buffer容易成为性能瓶颈等等。针对其缺点,我们也提到了两个优化方法,分别是延迟光照和分块延迟渲染法。具体的内容就需要你自己研究了。
参考资料
Deferred Shading
延迟渲染(Deferred Rendering)的前生今世
延迟着色
bgfx的延迟着色example代码
Light Pre-Pass Rednerer
Deferred Rendering for Current and Future Rendering Pipelines