延迟着色、G-buffer 与光照重建
延迟着色、G-buffer 与光照重建:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释 前向着色 为什么在多光源、深度复杂场景下会重复执行昂贵光照(早期深度测试能减少浪费,但每个已着色片元仍要遍历每盏灯),以及 延迟着色 怎么把渲染拆成「几何 pass 填 G-buffer」+「光照 pass 只对可见片元算一次」两步绕开这份浪费
- 能改出延迟着色的「先存几何、再点灯」效果:在 demo 里切 查看通道(反照率 / 法线 / 位置 / 最终光照)亲眼看 G-buffer 每张图长什么样、再看它们怎么合成最终光照,并调 光源数 看延迟下轻松上多盏灯
- 能回答:延迟着色为什么对大量光源更省?它的光照计算次数和什么成正比、和什么无关?
工厂的浪费:每来一个半成品就从头加工一遍
想象一条灯具加工流水线:每来一个半成品零件,工人就立刻把它从打磨、上色到抛光全套加工一遍——哪怕这个零件最后会被压在箱底、根本没人看见,工人也照样花了同样的功夫。零件一多、工序一复杂,工人就被这些白做的活拖垮了。
更糟的是:加工的工序(相当于场景里的「灯」)越多,每个零件要重复的功夫就翻倍——十道工序,每个零件(包括那些注定看不见的)都得走十遍。零件数 × 工序数,开销爆炸式增长。
这一章要解决的就是这种浪费:怎么不让看不见的零件白白被加工、又怎么在工序很多时不至于成本失控。聪明的做法是换个流程——先把所有零件的属性(形状、材质、朝向)登记造册,确定哪些最终会露面,最后只对露面的那些统一加工一遍。没有这套办法,场景里灯一多就卡成幻灯片;有了它,几百盏灯也能流畅跑。
前向着色的浪费:被遮挡的片元也白算光照
先看清那条「来一个加工一个」的老流程叫什么。你之前画场景一直用的就是 ↡Forward Rendering / Forward Shading,前向着色。最常规的渲染方式:把每个物体逐一画出来,每个通过深度测试而执行片段着色器的片元,就立刻在它的片段着色器里把所有光照(环境光 + 每盏灯的漫反射 + 镜面)算完写进帧缓冲。问题是:深度复杂的场景仍可能有很多片元后来被覆盖而浪费光照;早期深度测试能减少这类浪费,但不能改变『每个已着色片元都要遍历每盏灯』的多光源成本。(forward shading):把物体一个个画出来,每个片元一生成,就立刻在片段着色器里把光照全算完——环境光、每盏灯的漫反射和镜面,一并写进帧缓冲。
这套流程在光少时挺好。但它有两处藏不住的浪费。第一是 overdraw:一个片元算完了光照写进帧缓冲,结果后面又画了一个更近的物体把它盖住——那次光照白算了。场景越复杂、物体叠得越多,这种「算了又被盖掉」的浪费越多。第二更要命:片段着色器里要 for 循环每一盏灯,光源数翻倍、每个片元的光照计算就翻倍,成本随「物体片元数 × 光源数」一起涨。想上一百盏灯?前向直接卡死。
延迟着色:先登记几何、最后只对可见片元点一次灯
解法是把渲染拆成两遍,这就是 ↡Deferred Shading / Deferred Rendering,延迟着色。把渲染拆成两遍来绕开前向的浪费:第一遍『几何 pass』只把每个片元的几何属性(位置、法线、反照率、镜面强度)用 MRT 存进一组叫 G-buffer 的纹理,完全不算光照;第二遍『光照 pass』画一个全屏四边形,对屏幕上每个可见像素采样 G-buffer 拿到几何属性,只算一次光照。因为光照只对最终可见的像素算(被遮挡的根本没进 G-buffer 的最上层)、且次数只和屏幕像素数 × 光源数有关,与场景里物体多少、overdraw 解耦,所以能轻松支持成百上千盏灯。(deferred shading)的核心。把「立刻点灯」这件事**推迟(defer)**到最后:
- 第一遍不点任何灯,只把每个片元的几何属性(它在哪、法线朝哪、是什么颜色)记录下来。深度测试会保证:屏幕每个像素最后记的是最靠前那个可见片元的属性,被挡住的根本不会留在最上层。
- 第二遍才点灯:对屏幕上每个可见像素,取出它记下的几何属性,只算一次光照。
回到工厂的比喻:第一遍是「给每个会露面的零件登记造册——形状、朝向、材质」,第二遍是「照着登记表,只给露面的零件统一抛光上灯」。被箱底压住的零件压根没上登记表的最上层,自然不会白白加工。下面这张图把两条流水线上下排开,一眼看出差别:
已着色片元 × 灯。 延迟:几何 pass 填 G-buffer → 光照 pass 只对每个可见像素 × 灯算一次,光照次数 ~ 屏幕像素数 × 灯 —— 这就是延迟能上几百盏灯的原因。上排前向:被遮挡的片元也乘每盏灯白算了;下排延迟:几何 pass 只登记、光照 pass 只对可见像素点一次灯。
G-buffer:那张「几何属性登记表」
第一遍记下的那组属性,存在一个专门的「登记表」里,叫 ↡Geometry Buffer,几何缓冲。延迟着色第一遍渲染输出的一组纹理,专门存每个片元的几何属性而不存光照结果。典型存:①位置(片元的世界/视空间坐标 xyz)②法线(朝向)③反照率 albedo(物体的漫反射底色)+ 镜面强度(常塞进 alpha 通道)。靠 MRT 一次几何渲染同时写这几张纹理。第二遍光照 pass 再采样它们、对每个可见像素算一次光照。位置和法线必须用浮点格式(如 RGBA16F)存,否则精度不够、光照会发斑错位。(几何缓冲)。它不是一张图,而是几张纹理,每张存一种几何属性。最常见的三样:
- 位置
gPosition:每个片元在空间里的坐标xyz,当rgb存进一张纹理(看起来是一片按位置编码的彩色)。 - 法线
gNormal:每个片元的法线朝向。在本章的浮点RGBA16F附件中,直接存归一化后的[-1,1]法线;只有把它调试显示到屏幕时,才用n*0.5+0.5映射成整体偏蓝紫的颜色。 - 反照率
gAlbedoSpec:物体的漫反射本色(不含任何光照),镜面强度顺手塞进alpha通道。这张图看起来就是物体本来的颜色。
xyz→rgb)、法线(RGBA16F 原值;图中 n*0.5+0.5 仅作调试显示)、反照率(物体本色 + 镜面强度塞 a)。都不含光照——光只在后面的光照 pass 算一次。关键是:这三张图里一点光照都没有,全是「几何事实」。它们是怎么一次就全画出来的?靠的是 MRT 多渲染目标(Multiple Render Targets)——和泛光那章一次输出「场景色 + 亮区色」是同一个能力(那章已细讲,这里直接复用):一个片段着色器靠 layout(location=N) 同时往好几个颜色附件写。
几何 pass 与光照 pass:两遍各干一件事
把这两遍正式起个名。第一遍叫 ↡Geometry Pass,几何 pass。延迟着色的第一遍渲染:把场景里所有物体照常画一遍,但片段着色器不算任何光照,只用 MRT 把每个片元的几何属性(位置、法线、反照率、镜面强度)写进 G-buffer 的几张纹理。开着深度测试,所以每个像素最后留下的是最靠前的可见片元的属性。这一遍结束,G-buffer 就填好了。(geometry pass):把所有物体照常渲一遍,但片段着色器只填 G-buffer、不点灯。开着深度测试,所以每个像素留下的是最靠前的可见片元。
第二遍叫 ↡Lighting Pass,光照 pass。延迟着色的第二遍渲染:绑回默认帧缓冲,画一个铺满屏幕的全屏四边形,它的片段着色器对每个屏幕像素采样 G-buffer 拿到位置/法线/反照率,然后 for 循环每盏灯累加光照(如 Blinn-Phong),算出最终颜色上屏。因为这一遍只对每个可见像素算一次光照、和屏幕像素数成正比,与场景里有多少物体无关,所以这是延迟着色省下大量光照计算的关键一步。(lighting pass):绑回默认帧缓冲,画一个铺满屏幕的全屏四边形,它的片段着色器对每个屏幕像素采样 G-buffer 拿到位置/法线/反照率,再 for 循环每盏灯累加光照,算出最终色。整条出口流水线:几何 pass 填 G-buffer → 光照 pass 采 G-buffer 对每个可见像素点一次灯 → 上屏。
为什么延迟对大量光源更省
现在能回答开头那个浪费问题了。延迟着色把光照计算挪到了光照 pass,而光照 pass 只对屏幕上每个可见像素算一次。所以它的光照计算次数 ≈ 屏幕像素数 × 光源数——注意这里没有「物体片元数」了:场景里塞一千个物体还是十个物体、有没有 overdraw,光照 pass 都只看那固定的「屏幕像素数」。光照成本和场景复杂度彻底解耦了。
对比前向的「物体片元数 × 光源数」:物体片元数往往远大于屏幕像素数(因为 overdraw、因为很多片元被遮挡)。所以光源一多,延迟的优势就压倒性地显现——这正是延迟着色被发明出来的理由:让你能在场景里塞成百上千盏灯还不卡。上面那张对比图下排标的就是这个「屏幕像素数 × 灯」。
别忘了局限:延迟不是万能的
延迟很香,但有几个绕不开的代价,写在前面免得你以为它能包治百病:
- 透明物体通常走前向:G-buffer 每个像素只存一个最前片元的属性,可透明物体需要与后方内容多层混合。因此常见混合管线是:不透明物体延迟,透明和特殊材质在之后用前向再画一遍。
- MSAA 集成更复杂:延迟的光照发生在之后的全屏 pass,多采样 G-buffer、每样本光照或解析都会增加实现与带宽成本。它不是完全不能做,但传统简单的延迟管线常改用 FXAA/TAA 等后处理,或采用专门的 MSAA deferred 方案。
- G-buffer 占带宽和显存:好几张全屏的浮点纹理(位置、法线、反照率…)既吃显存,每帧读写又吃带宽——这是延迟实打实的固定开销。
还有一个进一步的省法值得一提:↡Light Volume,光体积。一盏灯(尤其点光源)的光照其实只能影响它周围一小块范围(超出衰减半径基本就照不到了)。光体积的思路是:在光照 pass 里不要让每盏灯都全屏算一遍,而是只在这盏灯影响得到的那块屏幕区域里的像素才算它的贡献。常见做法是为每盏灯画一个包住它影响范围的球体/锥体(它的『体积』),只对落在体积内的像素累加该灯。这样远处的小灯就不会浪费全屏的光照计算,是延迟着色上更多光源时的标准优化。(light volume):一盏灯(尤其点光源)其实只照亮周围一小块,光体积就是只在这盏灯影响得到的那块屏幕区域里才算它的贡献,远处的小灯不必全屏白算——延迟上更多光时的标准优化。
动手:切通道看 G-buffer,调光源数看多光
猜一猜:下面这块画布里有一块地面 + 几个不同颜色的球。先把「查看通道」切到 法线——每个片元的法线被
n*0.5+0.5编码成颜色。你觉得画面会变成什么样?是五颜六色乱成一团,还是整体偏蓝紫(朝你这侧的面z偏大)、球面上颜色随朝向平滑过渡?先切一下,再读下面。
这块画布里,片段着色器用 ray-sphere 求交程序化渲了一块地面 + 4 个不同反照率的球,对每个命中片元算出它的 位置 / 法线 / 反照率——这正是 G-buffer 存的三样东西。两个控件:查看通道在「反照率 / 法线 / 位置 / 最终光照」之间切,亲眼看 G-buffer 每张图长什么样、再看它们怎么合成最终光照;光源数从 1 加到 4 盏点光源,看延迟下轻松点多盏灯(光照通道里就是 for 循环这几盏灯累加):
实时演示加载中…
谜底:切到 反照率,看到的是几个球和棋盘地面的纯本色——一点光照都没有,平板板的,这正是 G-buffer 第三张存的东西。切到 法线,果然整体偏蓝紫,每个球面上的颜色随朝向平滑过渡(朝左偏红、朝右偏绿、朝你偏蓝),地面是一整片同色(法线全朝上)。切到 位置,是一片随坐标变化的彩色编码图。最后切到 最终光照——这才是把前三样合成出来的成品:地面和球被点亮、有了高光和阴影感。再把光源数从 1 拖到 4,画面一盏盏地被点亮、色彩叠加——这就是延迟着色「先存几何、再点灯」里点灯那一步,循环几盏就点几盏,加灯几乎只是多跑一次循环。
流程走一遍:从几何 pass 到光照 pass
把上面切的四个通道串成正经的两遍流程,每一步都配了图示意那一刻的数据形态:
① 几何 pass + MRT:一次填好 G-buffer 三张图
xyz→rgb)、法线(RGBA16F 原值;图中 n*0.5+0.5 仅作调试显示)、反照率(物体本色 + 镜面强度塞 a)。都不含光照——光只在后面的光照 pass 算一次。几何 pass 把所有物体照常画一遍,但片段着色器
不点灯,靠 MRT 一次把每个片元的
位置(location 0)、法线(
location 1)、反照率 + 镜面(
location 2)写进 G-buffer 三张纹理。开着深度测试,每个像素只留
最靠前的可见片元。
左右擦一擦:前向 vs 延迟
把「前向每片元都算(含被遮挡的浪费)」和「延迟只对可见片元算一次」定格成两态——左边是前向管线(每个物体片元 × 灯立刻算光照,被遮挡的也白算),右边是延迟管线(几何 pass 填 G-buffer → 光照 pass 只对可见像素 × 灯算一次):
已着色片元 × 灯。 延迟:几何 pass 填 G-buffer → 光照 pass 只对每个可见像素 × 灯算一次,光照次数 ~ 屏幕像素数 × 灯 —— 这就是延迟能上几百盏灯的原因。已着色片元 × 灯。 延迟:几何 pass 填 G-buffer → 光照 pass 只对每个可见像素 × 灯算一次,光照次数 ~ 屏幕像素数 × 灯 —— 这就是延迟能上几百盏灯的原因。两侧画的是同一张管线对比图(上排前向、下排延迟)——重点不在左右不同,而在对照那两行光照次数:前向的昂贵光照随已着色片元与灯数增长,深度复杂时还可能有后来被覆盖的工作;延迟则是「屏幕像素数 × 灯」(与场景物体数和深度复杂度解耦)。一句话:延迟把光照从「跟随每个物体的着色」变成「只对可见像素重建一次」。
代码逐段拆解
延迟着色的代码就三块,全建立在帧缓冲的多附件 FBO 和 HDR 浮点纹理之上:几何 pass 片段着色器(MRT 填 G-buffer)、建 G-buffer FBO(多浮点附件 + drawBuffers)、光照 pass 片段着色器(采 G-buffer + 循环 N 灯)。
第一块:几何 pass 片段着色器——MRT 三输出,只写几何不点灯
几何 pass 的片段着色器声明三个输出,靠 layout(location=N) 指定写到第几个颜色附件:位置、法线、反照率+镜面。注意它完全不算光照,只把上游传来的几何属性原样写进去:
#version 300 es
precision highp float;
in vec3 FragPos; // 片元位置(视/世界空间,来自顶点着色器)
in vec3 Normal; // 片元法线
in vec2 TexCoords; // 用于采反照率/镜面贴图
layout(location = 0) out vec4 gPosition; // 附件 0:位置
layout(location = 1) out vec4 gNormal; // 附件 1:法线
layout(location = 2) out vec4 gAlbedoSpec; // 附件 2:反照率(rgb)+镜面(a)
uniform sampler2D texDiffuse;
uniform sampler2D texSpecular;
void main() {
// 只登记几何属性,一行光照都不算
gPosition = vec4(FragPos, 1.0);
gNormal = vec4(normalize(Normal), 1.0);
gAlbedoSpec.rgb = texture(texDiffuse, TexCoords).rgb; // 反照率本色
gAlbedoSpec.a = texture(texSpecular, TexCoords).r; // 镜面强度塞 a 通道
}// G-buffer FBO:挂三张纹理附件(位置/法线/反照率)
unsigned int gBuffer, gPosition, gNormal, gAlbedoSpec;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
unsigned int atts[3] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1,
GL_COLOR_ATTACHMENT2};
// 位置、法线:必须 RGBA16F 浮点(精度),反照率 RGBA8 即可
// gPosition / gNormal 用 GL_RGBA16F + GL_FLOAT;gAlbedoSpec 用 GL_RGBA + GL_UNSIGNED_BYTE
// (每张 glGenTextures + glTexImage2D + glFramebufferTexture2D 到对应附件,从略)
// 关键:告诉这次渲染要往「三个」附件写,否则只有附件 0 生效
glDrawBuffers(3, atts);
// (深度 renderbuffer 附件 + checkFramebufferStatus 同帧缓冲那章,从略)着色器声明了三个输出还不够——还得用 glDrawBuffers 告诉这次渲染「确实要往三个附件写」,这步在 WebGL2 里 API 名字不同;且位置/法线这两张必须是浮点纹理:
第二块:光照 pass 片段着色器——采 G-buffer,循环 N 盏灯
几何 pass 跑完,G-buffer 三张纹理就填好了。第二遍绑回默认帧缓冲、画一个全屏四边形,它的片段着色器采样这三张纹理拿到几何属性,再 for 循环每盏灯累加 Blinn-Phong——这是延迟省下大量计算的关键一步,对每个可见像素只算这一次:
#version 300 es
precision highp float;
in vec2 TexCoords;
uniform sampler2D gPosition; // 采 G-buffer 三张纹理
uniform sampler2D gNormal;
uniform sampler2D gAlbedoSpec;
uniform vec3 lightPos[64]; // 几十上百盏灯也只是个数组
uniform vec3 lightColor[64];
uniform int activeLights; // 当前生效灯数(对应 demo「光源数」)
uniform vec3 viewPos;
out vec4 FragColor;
void main() {
// 1) 采 G-buffer:拿到这个可见像素的 位置/法线/反照率/镜面
vec3 FragPos = texture(gPosition, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb;
float Spec = texture(gAlbedoSpec, TexCoords).a;
// 2) 只算这一次光照:for 循环每盏灯累加 Blinn-Phong
vec3 lighting = Albedo * 0.1; // 环境光托底
vec3 V = normalize(viewPos - FragPos);
for (int i = 0; i < activeLights; i++) {
vec3 L = normalize(lightPos[i] - FragPos);
float diff = max(dot(Normal, L), 0.0);
vec3 H = normalize(L + V);
float s = pow(max(dot(Normal, H), 0.0), 32.0) * Spec;
lighting += lightColor[i] * (Albedo * diff + s); // 漫反射 + 镜面
}
FragColor = vec4(lighting, 1.0);
}注意这段和泛光、HDR 是连着的:算出来的 lighting 可能 >1(多盏灯叠加),所以延迟通常也渲进浮点帧缓冲,后面接 tonemap、甚至 Bloom。这里的 for (i < activeLights) 正对应上面 demo 的「光源数」滑块——加灯就是多跑一次循环,每个可见像素的成本只随灯数线性涨,和场景里有多少物体无关。
第三块:CPU 侧把两遍串起来
最后看 CPU 侧怎么调度这两遍:第一遍绑 G-buffer FBO 渲场景填属性,第二遍绑回默认帧缓冲、把三张纹理喂给光照着色器、画全屏四边形:
// 第一遍·几何 pass:绑 G-buffer,正常渲场景(片段着色器只填属性、不点灯)
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
geometryShader.use();
RenderScene(); // 所有物体照常画,写进 gPosition/gNormal/gAlbedoSpec
// 第二遍·光照 pass:绑回默认帧缓冲,把三张 G-buffer 纹理喂给光照着色器
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT);
lightingShader.use();
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
RenderQuad(); // 画全屏四边形,对每个可见像素只算一次光照// 第一遍·几何 pass:绑 G-buffer,正常渲场景(片段着色器只填属性、不点灯)
gl.bindFramebuffer(gl.FRAMEBUFFER, gBuffer);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
geometryProgram.use();
renderScene(); // 所有物体照常画,写进 gPosition/gNormal/gAlbedoSpec
// 第二遍·光照 pass:绑回默认帧缓冲,把三张 G-buffer 纹理喂给光照着色器
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.clear(gl.COLOR_BUFFER_BIT);
lightingProgram.use();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, gPosition);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, gNormal);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, gAlbedoSpec);
renderQuad(); // 画全屏四边形,对每个可见像素只算一次光照两端逻辑一一对应:第一遍把光照「推迟」掉、只填 G-buffer;第二遍才点灯,而且只对全屏四边形覆盖的每个可见像素点一次。整条流水线:几何 pass 填 G-buffer(MRT)→ 光照 pass 采 G-buffer 循环 N 灯 → 上屏。
容易踩的坑
小结
- 前向着色跟随物体逐片元立刻算光照,深度复杂时可能有后来被覆盖的额外工作;即使早期深度测试挡住部分片元,已着色片元的成本仍随「灯数」增长
- 延迟着色把渲染拆两遍:几何 pass 用 MRT 把 位置/法线/反照率 填进 G-buffer(不点灯),光照 pass 画全屏四边形、只对可见像素采 G-buffer 算一次光
- 延迟的光照成本 ~「屏幕像素数 × 灯」,与场景复杂度 / overdraw 解耦——所以能轻松上几百盏灯
- G-buffer 的位置通常用浮点
RGBA16F;法线常用RGBA16F原值或明确的紧凑编码,反照率RGBA8够用;这组纹理是延迟的固定带宽/显存开销 - 局限:透明通常要单独前向画,MSAA 集成更复杂(可用专门方案或 FXAA/TAA);灯极多时用光体积只对受影响像素算
练习
问题 1(改 Demo 代码型) 在上面那个 demo 里,亲手把「先存几何、再点灯」走一遍:① 把「查看通道」依次切到 反照率 → 法线 → 位置 → 最终光照,说出前三个通道分别对应 G-buffer 的哪张纹理、最终光照这一通道做了什么;② 在最终光照通道下,把「光源数」从 1 拖到 4,画面怎么变?对应代码里哪一行?
问题 2(选型题 · C 型) 三个场景,分别该用 前向 还是 延迟,各给一句理由:① 一个夜晚的赛博朋克街区,几百盏霓虹灯、车灯、广告牌都在发光,几何不算特别复杂;② 一款手机休闲游戏,全场景只有一盏太阳光(平行光),但要支持很多半透明的水面和玻璃,还要开 MSAA 抗锯齿省功夫;③ 一个有大量半透明粒子特效(烟、火、魔法)叠在不透明物体上的场景。
问题 3(问答型) 有人做延迟着色:G-buffer 的位置、法线、反照率三张纹理图省事全用了 RGBA8,结果反照率看着没问题,但点灯后光照发斑、远处物体边缘错位。问题出在哪?怎么改?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 前向着色
Forward Shading,前向着色。最常规的渲染方式:物体一个个画,每个物体的每个片元一生成,就立刻在片段着色器里把所有光照(环境光
- 每盏灯的漫反射 + 镜面)算完写进帧缓冲。问题:很多片元最终被前面的物体盖住(overdraw),它们的光照白算了;而且光源越多每个片元要乘的灯越多,成本随「物体片元数 × 光源数」暴涨,多光源场景极易卡顿。详见本章「前向着色的浪费」一节。
- 延迟着色
Deferred Shading,延迟着色。把渲染拆成两遍绕开前向的浪费:第一遍「几何 pass」只把每片元的几何属性(位置/法线/反照率/镜面)用 MRT 存进 G-buffer、不算光照;第二遍「光照 pass」画全屏四边形,对每个可见像素采样 G-buffer 只算一次光照。因为光照只对可见像素算、次数只和「屏幕像素数 × 光源数」有关,与场景物体多少、overdraw 解耦,所以能轻松支持成百上千盏灯。详见本章「延迟着色」一节。
- G-buffer
Geometry Buffer,几何缓冲。延迟着色第一遍渲染输出的一组纹理,专门存每片元的几何属性而不存光照:①位置(坐标
xyz)②法线(朝向)③反照率 albedo(漫反射底色)+ 镜面强度(常塞a通道)。靠 MRT 一次几何渲染同时写这几张。位置和法线必须用浮点格式(如RGBA16F)存,否则精度不够、光照发斑错位。详见本章「G-buffer」一节。- 几何 pass
Geometry Pass,几何 pass。延迟着色第一遍:把所有物体照常画一遍,但片段着色器 不算任何光照,只用 MRT 把每片元的几何属性写进 G-buffer 的几张纹理。开着深度测试,所以每个像素留下的是最靠前的可见片元的属性。这一遍结束,G-buffer 就填好了。详见本章「几何 pass 与光照 pass」一节。
- 光照 pass
Lighting Pass,光照 pass。延迟着色第二遍:绑回默认帧缓冲,画一个铺满屏幕的全屏四边形,它的片段着色器对每个屏幕像素采样 G-buffer 拿到位置/法线/反照率,再
for循环每盏灯累加光照(如 Blinn-Phong)算出最终色上屏。因为只对每个可见像素算一次、和屏幕像素数成正比,与场景物体多少无关,这是延迟省下大量光照计算的关键一步。详见本章「几何 pass 与光照 pass」一节。- 光体积
Light Volume,光体积。一盏灯(尤其点光源)只照亮周围一小块(超出衰减半径基本照不到)。光体积的思路是:光照 pass 里不让每盏灯都全屏算一遍,而是只在这盏灯影响得到的那块屏幕区域内的像素才算它的贡献——常为每盏灯画一个包住其影响范围的球/锥(它的「体积」),只对落在体积内的像素累加该灯。这样远处小灯不浪费全屏计算,是延迟上更多光源时的标准优化。详见本章「别忘了局限」一节。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- deferred shading:在“延迟着色、G-buffer 与光照重建”中由G-buffer framebuffer、geometry program 与 lighting program负责解释其输入、受控状态和可观察结果;运行时以attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素定位它的第一处变化。
- g-buffer:在“延迟着色、G-buffer 与光照重建”中由G-buffer framebuffer、geometry program 与 lighting program负责解释其输入、受控状态和可观察结果;运行时以attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素定位它的第一处变化。
- geometry pass:在“延迟着色、G-buffer 与光照重建”中由G-buffer framebuffer、geometry program 与 lighting program负责解释其输入、受控状态和可观察结果;运行时以attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素定位它的第一处变化。
- lighting pass:在“延迟着色、G-buffer 与光照重建”中由G-buffer framebuffer、geometry program 与 lighting program负责解释其输入、受控状态和可观察结果;运行时以attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“几何 pass 填充 G-buffer,光照 pass 采样并累加所有有效光源”发生后,G-buffer framebuffer、geometry program 与 lighting program应怎样改变position/normal/albedo-spec attachments、格式、坐标空间、draw buffers 和 light list;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
延迟着色、G-buffer 与光照重建:状态合同
让 geometry pass 写出可重建的 G-buffer,再在 lighting pass 逐像素读取同一空间数据
验证场景
官方教程正式概念
logl-37 · 基线帧
deferred shading:固定 context、资源内容与输入事件,执行“几何 pass 填充 G-buffer,光照 pass 采样并累加所有有效光源”
冻结输入:deferred shading
G-buffer framebuffer、geometry program 与 lighting program记录position/normal/albedo-spec attachments、格式、坐标空间、draw buffers 和 light list
结果:得到可重复的初始 GL 状态与资源身份
观测:attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素中的初始快照
预期:G-buffer framebuffer、geometry program 与 lighting program得到可复查结果,并持续满足“position、normal 与 light 位于同一空间;附件精度满足后续重建”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“几何 pass 填充 G-buffer,光照 pass 采样并累加所有有效光源”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“position、normal 与 light 位于同一空间;附件精度满足后续重建”。
CPU command · GL state · GPU result
延迟着色、G-buffer 与光照重建:五段轨迹
当前观测:attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素中的初始快照
不变量:position、normal 与 light 位于同一空间;附件精度满足后续重建
实验三:单故障与同输入恢复
注入“G-buffer 保存 view-space position,却拿 world-space light position直接相减”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素一起恢复才算修复。
Single fault · first divergence · replay
延迟着色、G-buffer 与光照重建:反例与恢复
故障:G-buffer 保存 view-space position,却拿 world-space light position直接相减
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“G-buffer 保存 view-space position,却拿 world-space light position直接相减”
position、normal 与 light 位于同一空间;附件精度满足后续重建
attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素
最小可重放检查
unit: logl-37
owner: G-buffer framebuffer、geometry program 与 lighting program
state_or_resource: position/normal/albedo-spec attachments、格式、坐标空间、draw buffers 和 light list
command: 几何 pass 填充 G-buffer,光照 pass 采样并累加所有有效光源
pass_invariant: position、normal 与 light 位于同一空间;附件精度满足后续重建
single_fault: G-buffer 保存 view-space position,却拿 world-space light position直接相减
required_evidence: attachment 格式/绑定、G-buffer texel、法线长度、光源空间、pass 顺序与像素复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。