
在先前的文章中,我们使用了直接照明(前向渲染或前向阴影) 。 这是一种简单的方法,其中我们在考虑所有光源的情况下绘制对象,然后绘制下一个对象以及其上的所有照明,依此类推。 它的理解和实现非常简单,但同时从性能的角度来看却变得相当缓慢:对于每个对象,您都必须通过所有光源进行分类。 另外,由于大多数像素着色器计算没有用处,并且会覆盖较近对象的值,因此直接光照在大量对象相互重叠的场景上的效率很低。
延迟的光照,延迟的阴影或延迟的渲染会绕过此问题,并极大地改变我们绘制对象的方式。 这为使用大量光源显着优化场景提供了新的机会,使您可以以可接受的速度绘制成百上千个光源。 下面是使用延迟照明绘制的1847个点光源的场景(图片由Hannes Nevalainen提供)。 直接计算照明量是不可能实现这样的事情的:

延迟照明的想法是,我们将计算最复杂的部分(例如照明)延迟到以后。 延迟光照包括两个遍:在第一遍中,绘制几何遍历(geometry pass) ,绘制整个场景并将各种信息存储在称为G缓冲区的一组纹理中。 例如:每个像素的位置,颜色,法线和/或表面镜像。 G缓冲区中存储的图形信息稍后将用于计算照明。 以下是一帧G缓冲区的内容:

在第二阶段(称为光照阶段)中,当绘制全屏矩形时,我们使用G缓冲区中的纹理。 不必为每个对象分别使用顶点着色器和片段着色器,而是逐像素绘制整个场景。 光照的计算与直接传递完全相同,但是我们仅从G缓冲区和可变着色器(均匀性)中获取必要的数据,而不从顶点着色器中获取必要的数据。
下图很好地显示了一般绘图过程。

主要优点是,存储在G缓冲区中的信息属于未被任何东西遮挡的最接近的片段:深度测试仅保留它们。 因此,我们无需为每个像素计算一次光照,而无需做太多工作。 此外,延迟照明为我们提供了进一步优化的机会,从而使我们可以使用比直接照明更多的光源。
但是,有两个缺点:G缓冲区存储有关场景的大量信息。 另外,位置类型数据必须以高精度存储,因此,G缓冲区会占用相当大的存储空间。 另一个缺点是,我们将无法使用半透明的对象(因为缓冲区仅存储最近表面的信息),并且像MSAA这样的抗锯齿也不起作用。 有几种解决这些问题的方法,将在本文末尾讨论。
(请注意,-G缓冲区占用大量内存空间。例如,对于1920 * 1080的屏幕,每像素使用128位,该缓冲区将占用33mb。内存带宽要求不断提高-正在写入和读取更多数据)
G缓冲
G缓冲区是指用于存储最后渲染过程中使用的与照明有关的信息的纹理。 让我们看看计算直接渲染所需的照明信息:
- 3D位置矢量:用于找出片段相对于相机和光源的位置。
- 片段的漫反射颜色(红色,绿色和蓝色的反射率-通常是颜色)。
- 3d法线向量(以确定光以什么角度落在表面上)
- 用于存储镜像组件的浮动
- 光源的位置及其颜色。
- 相机位置。
使用这些变量,我们可以使用我们已经知道的Blinn-Fong模型来计算覆盖率。 光源的颜色和位置以及相机的位置可以是公共变量,但是对于每个图像片段,其余值将有所不同。 如果我们将完全相同的数据传递到递延照明的最终传递中(我们将其用于直接传递),尽管我们将在常规2D矩形上绘制片段,但我们将获得相同的结果。
OpenGL对我们可以在纹理中存储的内容没有任何限制,因此将所有信息存储在一个或多个屏幕大小的纹理(称为G缓冲区)中并在照明过程中全部使用它们是有意义的。 由于纹理的大小和屏幕的大小相同,因此我们获得与直接照明相同的输入数据。
在伪代码中,一般情况如下所示:
while(...) // render loop { // 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缓冲区中。 我们可以使用多个渲染目标在一次绘制中填充所有缓冲区,在上一章有关发光的实现的文章中讨论了这种方法: Bloom , 在中心上转换
对于几何传递,创建一个具有明显名称gBuffer的帧缓冲区,我们将在其中附加几个颜色缓冲区和一个深度缓冲区。 要存储位置和法线,最好使用具有高精度的纹理(每个组件16或32位浮点值),默认情况下,我们将在纹理中存储漫反射颜色和镜面反射值(每个组件精度8位)。
unsigned int gBuffer; glGenFramebuffers(1, &gBuffer); glBindFramebuffer(GL_FRAMEBUFFER, gBuffer); unsigned int 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_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); // . [...]
由于我们使用了多个渲染目标,因此我们必须显式告诉OpenGL我们将在glDrawBuffers()
绘制来自GBuffer的哪些缓冲区。 还值得注意的是,我们存储位置,并且法线每个都有3个分量,并以RGB纹理存储它们。 但是与此同时,我们立即将相同的颜色和镜面反射系数都放入了相同的RGBA纹理中–由于此,我们减少了一个缓冲区。 如果延迟渲染的实现变得更加复杂并使用了更多数据,则可以轻松地找到新的方式来组合数据并将其排列在纹理中。
将来,我们必须将数据渲染到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.a = texture(texture_specular1, TexCoords).r; }
由于我们使用了多个渲染目标,因此在layout
的帮助下, layout
指示当前帧缓冲区的内容以及在哪个缓冲区中进行渲染。 请注意,我们不会将镜像系数存储在单独的缓冲区中,因为我们可以将浮点值存储在其中一个缓冲区的alpha通道中。
请记住,在计算照明时,将所有变量存储在同一坐标空间中非常重要,在这种情况下,我们将存储(并执行计算)在世界空间中。
如果现在将几个纳米套件渲染到一个G缓冲区中,然后通过将每个缓冲区投影到屏幕的四分之一上来绘制其内容,我们将看到以下内容:

尝试可视化位置和法线向量,并确保它们正确。 例如,指向右侧的法向矢量将为红色。 与位于场景中心右侧的对象类似。 对G缓冲区的内容满意后,让我们继续下一部分:照明的通过。
照明通道
现在,我们在G缓冲区中拥有大量信息,我们能够使用G缓冲区的内容作为光照计算算法的输入来完全计算G缓冲区每个像素的光照和最终颜色。 由于G缓冲区的值仅表示可见片段,因此我们将对每个像素执行一次精确的照明计算。 因此,延迟照明非常有效,尤其是在复杂的场景中,在这种场景中,当直接为每个像素渲染时,经常需要多次计算照明。
对于光照的传递,我们将渲染一个全屏矩形(有点像后处理效果),并对每个像素执行缓慢的光照计算。
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); // shaderLightingPass.use(); SendAllLightUniformsToShader(shaderLightingPass); shaderLightingPass.setVec3("viewPos", camera.Position); RenderQuad();
我们在渲染之前绑定所有必要的G缓冲区纹理,此外,在着色器中设置与照明有关的变量值。
片段通过着色器与我们在会议课程中使用的非常相似。 从根本上来说,我们是直接从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); }
照明着色器接受3种纹理,这些纹理包含在几何通道中记录的所有信息,并且G缓冲区由该纹理组成。 如果我们从纹理中获取照明的输入,我们将获得与普通直接渲染完全相同的值。 在片段着色器的开头,我们只需从纹理中读取,即可获得与照明变量相关的值。 请注意,我们从一种纹理gAlbedoSpec
获取颜色和镜面反射系数。
由于每个片段都有根据Blinn-Fong模型计算照明所需的值(以及统一的着色器变量),因此我们无需更改照明计算代码。 唯一已更改的是获取输入值的方法。
用32个小光源开始一个简单的演示看起来像这样:

延迟照明的缺点之一是无法混合,因为每个像素的所有g缓冲区仅包含有关一个表面的信息,而混合使用多个片段的组合。 (混合) , 翻译 。 延迟照明的另一个缺点是,它迫使您使用一种通用方法来计算所有对象的照明。 尽管可以通过向g缓冲区添加材料信息来以某种方式避免这种限制。
为了解决这些缺点(尤其是缺乏混合),渲染通常分为两部分:使用延迟光照进行渲染,第二部分使用直接渲染将场景应用于场景或使用与延迟光照不兼容的着色器。 (注:来自示例:添加半透明的烟雾,火,玻璃)为了说明这一工作,我们将使用直接渲染将光源绘制为小立方体,因为照明立方体需要特殊的着色器(它们以相同的颜色均匀发光)。
将延迟渲染与直接渲染相结合。
假设我们要以3D立方体的形式绘制每个光源,其中心与光源的位置一致,并发出具有光源颜色的光。 想到的第一个想法是,在延迟的渲染结果之上直接为每个光源渲染立方体。 也就是说,我们照常绘制立方体,但仅在延迟渲染之后才能绘制。 该代码将如下所示:
// [...] RenderQuad(); // shaderLightBox.use(); shaderLightBox.setMat4("projection", projection); shaderLightBox.setMat4("view", view); for (unsigned int i = 0; i < lightPositions.size(); i++) { model = glm::mat4(); model = glm::translate(model, lightPositions[i]); model = glm::scale(model, glm::vec3(0.25f)); shaderLightBox.setMat4("model", model); shaderLightBox.setVec3("lightColor", lightColors[i]); RenderCube(); }
这些渲染的多维数据集不考虑延迟渲染的深度值,因此总是在已渲染的对象之上绘制:这不是我们的目标。

首先,我们需要将深度信息从几何通道复制到深度缓冲区中,然后才绘制发光立方体。 因此,只有当发光立方体的片段比已经绘制的对象更近时,它们才会被绘制。
我们可以使用glBlitFramebuffer
函数将帧缓冲区的内容复制到另一个帧缓冲区。 我们已经在抗锯齿示例中使用了此功能:( anti-aliasing ), translation 。 glBlitFramebuffer
函数将用户指定的帧缓冲区部分复制到另一个帧缓冲区的指定部分。
对于在延迟照明通道中绘制的对象,我们将深度保存在帧缓冲区对象的g缓冲区中。 如果我们仅将g缓冲区深度缓冲区的内容复制到默认深度缓冲区,则将绘制发光立方体,就像使用直接渲染过程绘制场景的整个几何一样。 正如在抗锯齿示例中简要解释的那样,我们需要设置用于读取和写入的帧缓冲区:
glBindFramebuffer(GL_READ_FRAMEBUFFER, gBuffer); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); // - glBlitFramebuffer( 0, 0, SCR_WIDTH, SCR_HEIGHT, 0, 0, SCR_WIDTH, SCR_HEIGHT, GL_DEPTH_BUFFER_BIT, GL_NEAREST ); glBindFramebuffer(GL_FRAMEBUFFER, 0); // [...]
在这里,我们将帧缓冲区深度缓冲区的全部内容复制到默认深度缓冲区(如果需要,您可以用相同的方式复制颜色缓冲区或stensil缓冲区)。 如果现在渲染发光的多维数据集,则它们的绘制就好像场景的几何形状是真实的一样(尽管绘制起来很简单)。

演示的源代码可以在这里找到。
通过这种方法,我们可以轻松地将延迟渲染与直接渲染结合起来。 这非常好,因为我们可以应用混合和绘制需要特殊着色器的对象,而这些着色器不适用于延迟渲染。
更多光源
延迟照明通常能够在不显着降低性能的情况下吸引大量光源,因此受到赞誉。 单单延迟照明并不能使我们绘制大量光源,因为我们仍然必须计算每个像素对所有光源的贡献。 为了绘制大量光源,使用了非常漂亮的优化方法,适用于延迟渲染-光源的作用区域。 (轻量)
通常,在高度照亮的场景中绘制片段时,无论光源到片段的距离如何,我们都会考虑到每个光源对场景的贡献。 如果大多数光源永远不会影响片段,为什么我们要浪费时间为它们计算呢?
光源范围的想法是找到光源的半径(或体积),即光源能够到达表面的区域。 由于大多数光源使用某种衰减,因此我们可以找到光可以达到的最大距离(半径)。 之后,我们仅对影响此片段的那些光源执行复杂的照明计算。 因为我们仅在需要的地方计算照明,所以这使我们免于进行大量计算。
使用这种方法,主要技巧是确定光源作用区域的大小。
计算光源范围(半径)
为了获得光源的半径,我们必须求解亮度的衰减方程,我们认为它是暗的-它可以是0.0或稍微更亮一些,但仍然很暗:例如0.03。 为了演示如何计算半径,我们将使用光投射器示例中最复杂,最常见的衰减函数之一
Flight= fracIKc+K1∗d+Kq∗d2
我们想解决这种情况下的方程 Flight=0.0 ,即光源完全黑暗时。 但是,此等式永远不会达到精确值0.0,因此没有解决方案。 但是,我们可以代之以将亮度方程求解为接近0.0的值,该值实际上可以视为暗的。 在此示例中,我们认为可接受的亮度值 frac5256 -除以256,因为8位帧缓冲区可以包含256个不同的亮度值。
选定的衰减函数在一定距离范围内几乎变暗,如果将其限制为低于5/256的亮度,则光源的范围将变得太大-这不是那么有效。 理想情况下,一个人不应看到来自光源的突然突然的光边界。 当然,这取决于场景的类型,较大的最小亮度值会给出较小的光源作用区域并提高计算效率,但会导致图像中出现明显的伪像:照明会突然在光源作用区域的边界处破裂。
我们必须解决的衰减方程变为:
frac5256= fracImax衰减
在这里 Imax -光的最亮成分(来自r,g,b通道)。 我们将使用最亮的组件,因为其他组件对光源范围的限制较弱。
我们继续求解方程:
frac5256 cdot衰减=Imax
衰减=Imax cdot frac2565
Kc+Kl cdotd+Kq cdotd2=Imax cdot frac2565
Kc+Kl cdotd+Kq cdotd2−Imax cdot frac2565=0
最后一个方程是形式为二次方程 ax2+bx+c=0 使用以下解决方案:
x= frac−Kl+ sqrtK21−4Kq(Kc−Imax frac2565)2Kq
我们得到了一个通用方程,可以用它替代参数(恒定衰减,线性和二次系数)来找到x-光源的半径。
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);
该公式返回的半径大约为1.0到5.0,具体取决于光源的最大亮度。
我们在舞台上找到每个光源的半径,并使用它来考虑仅位于每个片段范围内的那些光源。 下面是重做的照明通道,其中考虑了光源的作用范围。 请注意,此方法仅用于教育目的,不适合实际使用(我们将很快讨论原因)。
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) { // [...] } } }
结果与之前完全相同,但是现在对于每个光源,仅在其作用区域内才考虑其效果。
最终的代码是一个演示。 。
实际应用的光源范围。
上面显示的片段着色器在实际中将不起作用,仅用于说明如何摆脱不必要的光照计算。 实际上,视频卡和GLSL着色器语言在优化循环和分支方面做得很差。 这样做的原因是视频卡上的着色器的执行是针对不同像素并行执行的,并且许多体系结构施加了以下限制:并行执行时,不同的线程必须计算相同的着色器。 通常,这会导致以下事实:正在运行的着色器始终会计算所有分支,以便所有着色器同时工作。 (请注意泳道。这不会影响计算结果,但是会降低着色器的性能。)因此,事实证明我们的半径检查没有用:我们仍将计算所有光源的照明!
使用光范围的一种合适方法是渲染半径类似于光源的球体。 球体的中心与光源的位置重合,因此,球体内部包含了光源的作用范围。 这里有一个小技巧-我们使用基本上相同的延迟片段着色器绘制一个球体。 绘制球体时,专门为受光源影响的那些像素调用片段着色器,我们仅渲染必要的像素,而跳过所有其他像素。 :

, . , , . _*__
_ + __ , .
: ( ) , , , - ( ). stenil .
, , , . ( ) : c (deferred lighting) (tile-based deferred shading) . MSAA. .
vs
( ) - , , . , — , MSAA, .
( ), ( g- ..) . , .
: , , , . , , , . . parallax mapping, , . , .
网站连结
PS - . , !