原文地址:http://ogldev.atspace.co.uk/www/tutorial36/tutorial36.html
背景知识:
在前一章节中,我们学习了延迟渲染的基础,还有如何集合渲染结果到G-Buffer中。如果你运行例子,你会看到G-Buffer是长什么样子。进行,我们会继续完善基础实现,最终场景的渲染结果和向前渲染一样。当我们完成了本节之后,存在的问题才会明显。这个问题将会在下一节解决。
现在G-Buffer被填充了,我们想使用它来渲染灯光。灯光的方程本身没有变化。环境光、漫反射、镜面反射和之前一样,所有相关的数据被存储在G-Buffer中的图片。对于屏幕上的每个像素,我们只需要从不同的纹理上采样,然后和之前一样做光照的计算。唯一的问题是:我们怎样知道要处理哪些像素?在向前渲染,这个很容易做到。顶点着色器提供裁剪空间的坐标。把裁剪空间坐标转换到屏幕空间是自动完成的,光栅化器负责执行片段着色器,屏幕空间三角形的每个像素。我们只要计算这些像素的灯光效果。目前为止几何阶段被完成了,我们不想在使用原始的几何体。如果还是使用原始的几何体,将会有悖于延迟渲染的目的。
反之,我们从光源处看所有的东西。如果在场景中有一个平行光,所有的像素都会被影响。在那种情况下,我们只需要简单的绘制一个四边形。片段着色器会在每个像素上执行,像往常一样渲染。对于点光源,我们只要爱光源周围的球体内渲染一个粗略的球体模型。球的大小和灯光的强度有关。同样,像素着色器会对球体内的像素执行,对这些像素执行灯光。这个是延迟渲染的第一个优化——减少渲染的像素数。我们不是将小光源在所有的物体上都计算,我们只要考虑它的可影响的范围即可。我们只要把球体设置为光能够影响到的范围即可。
The demo in this tutorial is very simple, showing only a few boxes and three light sources. It’s a bit ironic that the number of vertices in the bounding sphere is larger than the number of vertices in the actual models. However, you need to remember that in a scene in a modern game you have an order of hundreds of thousands of vertices. In this case it is not that big a deal to add a few dozen vertices by rendering a bounding sphere around each light source. In the following picture you can see the light volume of three light sources:
本节的例子很简单,展示一些盒子,以及三个光源。具有讽刺意味的是,在球体包围的区域内的像素个数比真实的模型中的顶点个数要多的多。但是,你需要记住在场景中,现在的游戏顶点的个数在成千上万个。
在下面的图中,你会看到三个光源所形成的体积。
如果我们只是在灰色的块中执行FS,他会显著减少片段着色器的调用次数。在深度差距更大的复杂的场景中,这个间隔会变更大。所以现在的问题是:如何设置盒子的边界。
我们想让这个边界很大,所以这个光不会在边界处突然的消失,但是也要相应的能保证受光影响很小的很远的像素不在被渲染。这个解决方案很简单——使用衰减模型来找到优化的区间。衰减模型可以是常量的、线性的、指数形式的,后面两个和顶点离光源的距离相关。我们将会找到一个距离,此位置将会导致,除法结果远远小于阈值。8位的通道提供16,777,216 不同的颜色,被视为标准的颜色主题。每个通道允许有256个不同的值,所以我们设置这个阈值为1/256(小于这个都是黑色)。由于颜色通道的最大值,可以比256的小。下面是如何计算距离的:
上面是基于二次方程求根公式计算。
代码注释:
(tutorial36.cpp:142)
virtual void RenderSceneCB()
{
CalcFPS();
m_scale += 0.05f;
m_pGameCamera->OnRender();
DSGeometryPass();
BeginLightPasses();
DSPointLightsPass();
DSDirectionalLightPass();
RenderFPS();
glutSwapBuffers();
}
我们从上往下研究代码的变化。和之前的章节相比,主循环中的变化不大。我增加了一个函数,用来设置灯光的共同部分,BeginLightPasses。然后把平行光和点光源分开处理。
(tutorial36.cpp:164)
void DSGeometryPass()
{
m_DSGeomPassTech.Enable();
m_gbuffer.BindForWriting();
// Only the geometry pass updates the depth buffer
glDepthMask(GL_TRUE);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
Pipeline p;
p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
p.SetPerspectiveProj(m_persProjInfo);
p.Rotate(0.0f, m_scale, 0.0f);
for (unsigned int i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(m_boxPositions) ; i++) {
p.WorldPos(m_boxPositions[i]);
m_DSGeomPassTech.SetWVP(p.GetWVPTrans());
m_DSGeomPassTech.SetWorldMatrix(p.GetWorldTrans());
m_box.Render();
}
// When we get here the depth buffer is already populated and the stencil pass
// depends on it, but it does not write to it.
glDepthMask(GL_FALSE);
glDisable(GL_DEPTH_TEST);
}
在几何通道中,有三个主要的变化。第一个变化是,我们使用函数glDepthMask()来阻止一些东西,但是这个通道写入深度缓冲。几何通道需要深度缓冲,用来存储最近的像素到G-Buffer。在灯光通道中,屏幕中的每个像素都有一个单独的纹理,所以我们不是把所有的东西都写入深度缓冲。这个把我们带入了第二个变化,在几何阶段限制了深度测试。在灯光阶段做测试没有任何意义,因为没有其他的东西需要比较。重要的一点是,我们必须注意到,在写入深度缓冲之前要清除它。glClear()在深度遮罩设置为false时,不会触碰到深度缓冲。最后一个变化是,关闭了混合。后面我们会看到,如何灯光通道如何使用混合,为了混合多个灯光。在几何通道,他们是不相关的。
(tutorial36.cpp:199)
void BeginLightPasses()
{
glEnable(GL_BLEND);
glBlendEquation(GL_FUNC_ADD);
glBlendFunc(GL_ONE, GL_ONE);
m_gbuffer.BindForReading();
glClear(GL_COLOR_BUFFER_BIT);
}
在我们开始事实上的灯光通道之前,我们需要上门的函数来处理些共同的事务。之前提到,我们需要混合两种灯类型,因为每个灯光源有自己的draw call处理。在向前渲染中,我们在片段着色器中累加所有的光源,但是每个FS调用只处理单个的光源。我们需要一种方式来累加所有的灯,然后进行混合。混合是一个简单的函数,它接收源颜色(FS的输出),和一个目标颜色(从帧缓冲中获取),然后执行一些计算。混合通常使用透明的计算。在我们的例子中,我们使用GL_FUNC_ADD函数。它的意思是GPU会简单的对源颜色和目标颜色进行累加。由于我们希望的是真正的加,我们对源和目标的混合函数设置为GL_ONE。结果是:1src + 1dst,我们还要在混合之前开启混合。
在我们考虑混合之后,我们设置G-Buffer用来读取,和清除颜色缓冲,现在我们已经准备好了灯光阶段:
(tutorial36.cpp:210)
void DSPointLightsPass()
{
m_DSPointLightPassTech.Enable();
m_DSPointLightPassTech.SetEyeWorldPos(m_pGameCamera->GetPos());
Pipeline p;
p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
p.SetPerspectiveProj(m_persProjInfo);
for (unsigned int i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(m_pointLight); i++) {
m_DSPointLightPassTech.SetPointLight(m_pointLight[i]);
p.WorldPos(m_pointLight[i].Position);
float BSphereScale = CalcPointLightBSphere(m_pointLight[i]);
p.Scale(BSphereScale, BSphereScale, BSphereScale);
m_DSPointLightPassTech.SetWVP(p.GetWVPTrans());
m_bsphere.Render();
}
}
在点光源通道,我们只是简单的渲染一个包围的球,对于每个点光源。包围的球中心在光源的位置,CalcPointLightBSphere()函数用于计算球体的大小,根据的参数来自于灯光。
(tutorial36.cpp:275)
float CalcPointLightBSphere(const PointLight& Light)
{
float MaxChannel = fmax(fmax(Light.Color.x, Light.Color.y), Light.Color.z);
float ret = (-Light.Attenuation.Linear + sqrtf(Light.Attenuation.Linear * Light.Attenuation.Linear -
4 * Light.Attenuation.Exp * (Light.Attenuation.Exp - 256 * MaxChannel * Light.DiffuseIntensity)))
/
(2 * Light.Attenuation.Exp);
return ret;
}
这个函数计算包围的盒子大小,对于每个光源。
(tutorial36.cpp:230)
void DSDirectionalLightPass()
{
m_DSDirLightPassTech.Enable();
m_DSDirLightPassTech.SetEyeWorldPos(m_pGameCamera->GetPos());
Matrix4f WVP;
WVP.InitIdentity();
m_DSDirLightPassTech.SetWVP(WVP);
m_quad.Render();
}
处理平行光的很简单。我们只需要一个和屏幕大小的四边形。四边形的模型,我们使用(-1,-1)到(1,1),我们使用的WVP矩阵是单位矩阵。这个将会保证顶点保持不变,在透视除法和屏幕空间转换之后,我们讲到四边形的点在(0,0)到(SCREEN_WIDTH,SCREEN_WIDTH)。
(light_pass.vs)
#version 330
layout (location = 0) in vec3 Position;
uniform mat4 gWVP;
void main()
{
gl_Position = gWVP * vec4(Position, 1.0);
}
灯光的顶点着色器是很简单的。平行光的WVP矩阵是单位矩阵,所顶点没有变化。对于点光源,我们得到限定的区域。这些像素我们将会对其着色。
(dir_light_pass.fs:108)
void main()
{
vec2 TexCoord = CalcTexCoord();
vec3 WorldPos = texture(gPositionMap, TexCoord).xyz;
vec3 Color = texture(gColorMap, TexCoord).xyz;
vec3 Normal = texture(gNormalMap, TexCoord).xyz;
Normal = normalize(Normal);
FragColor = vec4(Color, 1.0) * CalcDirectionalLight(WorldPos, Normal);
}
(point_light_pass.fs:109)
void main()
{
vec2 TexCoord = CalcTexCoord();
vec3 WorldPos = texture(gPositionMap, TexCoord).xyz;
vec3 Color = texture(gColorMap, TexCoord).xyz;
vec3 Normal = texture(gNormalMap, TexCoord).xyz;
Normal = normalize(Normal);
FragColor = vec4(Color, 1.0) * CalcPointLight(WorldPos, Normal);
}
这些是针对平行光和点光源的片段着色器。我们把两个函数区分开来。另外一个方式在片段着色器中做分支处理,但是性能不高。内部的处理灯光的函数和之前处理灯光的函数类似。我们从G-Buffer中采样,得到世界坐标、颜色、法线。在之前的一节中,G-Buffer中还存储了纹理坐标,但是最好是存储起来,然后需要的时候计算。函数如下:
(dir_light_pass.fs:101, point_light_pass.fs:101)
vec2 CalcTexCoord()
{
return gl_FragCoord.xy / gScreenSize;
}
我们需要从G-Buffer中采样,根据当前的屏幕的像素位置。GLSL提供一个内置变量叫做gl_FragCoord,这个是我们需要的。它是一个4D的向量,包含了屏幕的坐标,xy分量,深度在z分量,1/w在w分量。我们需要提供屏幕的宽度和高度给片段着色器,通过除以屏幕空间的坐标,我们得到在0到1范围的纹理坐标。
(gbuffer.cpp:49)
bool GBuffer::Init(unsigned int WindowWidth, unsigned int WindowHeight)
{
...
for (unsigned int i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(m_textures) ; i++) {
...
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
...
}
...
}
我们需要在G-Buffer中增加一点值。之前的章节中,我们渲染它,然后把它拷贝到默认缓冲。由于我们将要采样,这里在屏幕像素和G-Buffer像素有一个一对一的映射,我们把过滤类型设置为GL_NEAREST。在其两种之间做差值。
(gbuffer.cpp:98)
void GBuffer::BindForReading()
{
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
for (unsigned int i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(m_textures); i++) {
glActiveTexture(GL_TEXTURE0 + i);
glBindTexture(GL_TEXTURE_2D, m_textures[GBUFFER_TEXTURE_TYPE_POSITION + i]);
}
}
问题,问题……
我们目前实现的延迟渲染有些问题。第一个,当相机进入到光源的体内,那么光源就消失了。原因是,我们只渲染限定球体内的正对的面,所内被裁剪了。如果关闭背面剔除,由于混合,在超出球体外的将会得到得加的灯光效果,因为我们将要渲染面两次。对在其内的面只会渲染一次,只有背面被渲染。
第二个问题是,限定的球体没有和光源的绝对对应,有些时候物体超出这个区域,依然被照亮,因为球体包含整个屏幕。
下一节我们会解决这些问题。