延迟着色、G-buffer 与光照重建

延迟着色、G-buffer 与光照重建:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释 前向着色 为什么在多光源、深度复杂场景下会重复执行昂贵光照(早期深度测试能减少浪费,但每个已着色片元仍要遍历每盏灯),以及 延迟着色 怎么把渲染拆成「几何 pass 填 G-buffer」+「光照 pass 只对可见片元算一次」两步绕开这份浪费
  • 能改出延迟着色的「先存几何、再点灯」效果:在 demo 里切 查看通道(反照率 / 法线 / 位置 / 最终光照)亲眼看 G-buffer 每张图长什么样、再看它们怎么合成最终光照,并调 光源数 看延迟下轻松上多盏灯
  • 能回答:延迟着色为什么对大量光源更省?它的光照计算次数和什么成正比、和什么无关?

工厂的浪费:每来一个半成品就从头加工一遍

想象一条灯具加工流水线:每来一个半成品零件,工人就立刻把它从打磨、上色到抛光全套加工一遍——哪怕这个零件最后会被压在箱底、根本没人看见,工人也照样花了同样的功夫。零件一多、工序一复杂,工人就被这些白做的活拖垮了。

更糟的是:加工的工序(相当于场景里的「灯」)越多,每个零件要重复的功夫就翻倍——十道工序,每个零件(包括那些注定看不见的)都得走十遍。零件数 × 工序数,开销爆炸式增长。

这一章要解决的就是这种浪费:怎么不让看不见的零件白白被加工、又怎么在工序很多时不至于成本失控。聪明的做法是换个流程——先把所有零件的属性(形状、材质、朝向)登记造册,确定哪些最终会露面,最后只对露面的那些统一加工一遍。没有这套办法,场景里灯一多就卡成幻灯片;有了它,几百盏灯也能流畅跑。

前向着色的浪费:被遮挡的片元也白算光照

先看清那条「来一个加工一个」的老流程叫什么。你之前画场景一直用的就是 (forward shading):把物体一个个画出来,每个片元一生成,就立刻在片段着色器里把光照全算完——环境光、每盏灯的漫反射和镜面,一并写进帧缓冲。

这套流程在光少时挺好。但它有两处藏不住的浪费。第一是 overdraw:一个片元算完了光照写进帧缓冲,结果后面又画了一个更近的物体把它盖住——那次光照白算了。场景越复杂、物体叠得越多,这种「算了又被盖掉」的浪费越多。第二更要命:片段着色器里要 for 循环每一盏灯,光源数翻倍、每个片元的光照计算就翻倍,成本随「物体片元数 × 光源数」一起涨。想上一百盏灯?前向直接卡死。

延迟着色:先登记几何、最后只对可见片元点一次灯

解法是把渲染拆成两遍,这就是 (deferred shading)的核心。把「立刻点灯」这件事**推迟(defer)**到最后:

  • 第一遍不点任何灯,只把每个片元的几何属性(它在哪、法线朝哪、是什么颜色)记录下来。深度测试会保证:屏幕每个像素最后记的是最靠前那个可见片元的属性,被挡住的根本不会留在最上层。
  • 第二遍才点灯:对屏幕上每个可见像素,取出它记下的几何属性,只算一次光照。

回到工厂的比喻:第一遍是「给每个会露面的零件登记造册——形状、朝向、材质」,第二遍是「照着登记表,只给露面的零件统一抛光上灯」。被箱底压住的零件压根没上登记表的最上层,自然不会白白加工。下面这张图把两条流水线上下排开,一眼看出差别:

前向着色 Forward:每个物体片元都立刻算光照几何光栅化出片元每片元 × 每盏灯 立刻算光照深度复杂时可能有被覆盖的额外工作帧缓冲上屏光照 ~ 已着色片元 × 灯(深度复杂时有额外工作)延迟着色 Deferred:先存几何、最后只对可见片元算一次几何 pass → 填 G-bufferMRT 存 位置/法线/反照率,不点灯光照 pass:全屏四边形采 G-buffer只对每个可见像素 × 每盏灯 算一次帧缓冲上屏光照次数 ~ 屏幕像素数 × 灯(与场景复杂度 / overdraw 解耦)延迟把光照从「每个物体片元都算」变成「只对可见像素算一次」→ 轻松上几百盏灯
前向:几何 → 每个已着色片元 × 每盏灯立刻算光照(高深度复杂时有额外工作),光照次数 ~ 已着色片元 × 灯 延迟:几何 pass 填 G-buffer → 光照 pass 只对每个可见像素 × 灯算一次,光照次数 ~ 屏幕像素数 × 灯 —— 这就是延迟能上几百盏灯的原因。

上排前向:被遮挡的片元也乘每盏灯白算了;下排延迟:几何 pass 只登记、光照 pass 只对可见像素点一次灯。

G-buffer:那张「几何属性登记表」

第一遍记下的那组属性,存在一个专门的「登记表」里,叫 (几何缓冲)。它不是一张图,而是几张纹理,每张存一种几何属性。最常见的三样:

  • 位置 gPosition:每个片元在空间里的坐标 xyz,当 rgb 存进一张纹理(看起来是一片按位置编码的彩色)。
  • 法线 gNormal:每个片元的法线朝向。在本章的浮点 RGBA16F 附件中,直接存归一化后的 [-1,1] 法线;只有把它调试显示到屏幕时,才用 n*0.5+0.5 映射成整体偏蓝紫的颜色。
  • 反照率 gAlbedoSpec:物体的漫反射本色(不含任何光照),镜面强度顺手塞进 alpha 通道。这张图看起来就是物体本来的颜色。
G-buffer:几何 pass 一次 MRT 输出这几张,都不含光照① 位置 positiongPosition · RGBA16F② 法线 normalgNormal · RGBA16F(图为调试显示)③ 反照率 albedogAlbedoSpec · RGB+a这一步只「登记几何属性」、零光照;光照 pass 再采这几张算一次光
几何 pass 靠 MRT 一次输出 G-buffer 的几张纹理:位置xyz→rgb)、法线RGBA16F 原值;图中 n*0.5+0.5 仅作调试显示)、反照率(物体本色 + 镜面强度塞 a)。都不含光照——光只在后面的光照 pass 算一次。

关键是:这三张图里一点光照都没有,全是「几何事实」。它们是怎么一次就全画出来的?靠的是 MRT 多渲染目标(Multiple Render Targets)——和泛光那章一次输出「场景色 + 亮区色」是同一个能力(那章已细讲,这里直接复用):一个片段着色器靠 layout(location=N) 同时往好几个颜色附件写。

几何 pass 与光照 pass:两遍各干一件事

把这两遍正式起个名。第一遍叫 (geometry pass):把所有物体照常渲一遍,但片段着色器只填 G-buffer、不点灯。开着深度测试,所以每个像素留下的是最靠前的可见片元。

第二遍叫 (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):一盏灯(尤其点光源)其实只照亮周围一小块,光体积就是只在这盏灯影响得到的那块屏幕区域里才算它的贡献,远处的小灯不必全屏白算——延迟上更多光时的标准优化。

动手:切通道看 G-buffer,调光源数看多光

猜一猜:下面这块画布里有一块地面 + 几个不同颜色的球。先把「查看通道」切到 法线——每个片元的法线被 n*0.5+0.5 编码成颜色。你觉得画面会变成什么样?是五颜六色乱成一团,还是整体偏蓝紫(朝你这侧的面 z 偏大)、球面上颜色随朝向平滑过渡?先切一下,再读下面。

这块画布里,片段着色器用 ray-sphere 求交程序化渲了一块地面 + 4 个不同反照率的球,对每个命中片元算出它的 位置 / 法线 / 反照率——这正是 G-buffer 存的三样东西。两个控件:查看通道在「反照率 / 法线 / 位置 / 最终光照」之间切,亲眼看 G-buffer 每张图长什么样、再看它们怎么合成最终光照;光源数从 1 加到 4 盏点光源,看延迟下轻松点多盏灯(光照通道里就是 for 循环这几盏灯累加):

可交互

实时演示加载中…

谜底:切到 反照率,看到的是几个球和棋盘地面的纯本色——一点光照都没有,平板板的,这正是 G-buffer 第三张存的东西。切到 法线,果然整体偏蓝紫,每个球面上的颜色随朝向平滑过渡(朝左偏红、朝右偏绿、朝你偏蓝),地面是一整片同色(法线全朝上)。切到 位置,是一片随坐标变化的彩色编码图。最后切到 最终光照——这才是把前三样合成出来的成品:地面和球被点亮、有了高光和阴影感。再把光源数从 1 拖到 4,画面一盏盏地被点亮、色彩叠加——这就是延迟着色「先存几何、再点灯」里点灯那一步,循环几盏就点几盏,加灯几乎只是多跑一次循环。

流程走一遍:从几何 pass 到光照 pass

把上面切的四个通道串成正经的两遍流程,每一步都配了图示意那一刻的数据形态:

分步1 / 2

① 几何 pass + MRT:一次填好 G-buffer 三张图

G-buffer:几何 pass 一次 MRT 输出这几张,都不含光照① 位置 positiongPosition · RGBA16F② 法线 normalgNormal · RGBA16F(图为调试显示)③ 反照率 albedogAlbedoSpec · RGB+a这一步只「登记几何属性」、零光照;光照 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 只对可见像素 × 灯算一次):

前向着色 Forward:每个物体片元都立刻算光照几何光栅化出片元每片元 × 每盏灯 立刻算光照深度复杂时可能有被覆盖的额外工作帧缓冲上屏光照 ~ 已着色片元 × 灯(深度复杂时有额外工作)延迟着色 Deferred:先存几何、最后只对可见片元算一次几何 pass → 填 G-bufferMRT 存 位置/法线/反照率,不点灯光照 pass:全屏四边形采 G-buffer只对每个可见像素 × 每盏灯 算一次帧缓冲上屏光照次数 ~ 屏幕像素数 × 灯(与场景复杂度 / overdraw 解耦)延迟把光照从「每个物体片元都算」变成「只对可见像素算一次」→ 轻松上几百盏灯
前向:几何 → 每个已着色片元 × 每盏灯立刻算光照(高深度复杂时有额外工作),光照次数 ~ 已着色片元 × 灯 延迟:几何 pass 填 G-buffer → 光照 pass 只对每个可见像素 × 灯算一次,光照次数 ~ 屏幕像素数 × 灯 —— 这就是延迟能上几百盏灯的原因。
延迟(只对可见片元算一次)
前向着色 Forward:每个物体片元都立刻算光照几何光栅化出片元每片元 × 每盏灯 立刻算光照深度复杂时可能有被覆盖的额外工作帧缓冲上屏光照 ~ 已着色片元 × 灯(深度复杂时有额外工作)延迟着色 Deferred:先存几何、最后只对可见片元算一次几何 pass → 填 G-bufferMRT 存 位置/法线/反照率,不点灯光照 pass:全屏四边形采 G-buffer只对每个可见像素 × 每盏灯 算一次帧缓冲上屏光照次数 ~ 屏幕像素数 × 灯(与场景复杂度 / overdraw 解耦)延迟把光照从「每个物体片元都算」变成「只对可见像素算一次」→ 轻松上几百盏灯
前向:几何 → 每个已着色片元 × 每盏灯立刻算光照(高深度复杂时有额外工作),光照次数 ~ 已着色片元 × 灯 延迟:几何 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 通道
}

着色器声明了三个输出还不够——还得用 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();    // 画全屏四边形,对每个可见像素只算一次光照

两端逻辑一一对应:第一遍把光照「推迟」掉、只填 G-buffer;第二遍才点灯,而且只对全屏四边形覆盖的每个可见像素点一次。整条流水线:几何 pass 填 G-buffer(MRT)→ 光照 pass 采 G-buffer 循环 N 灯 → 上屏

容易踩的坑

小结

  • 前向着色跟随物体逐片元立刻算光照,深度复杂时可能有后来被覆盖的额外工作;即使早期深度测试挡住部分片元,已着色片元的成本仍随「灯数」增长
  • 延迟着色把渲染拆两遍:几何 passMRT 把 位置/法线/反照率 填进 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 采样并累加所有有效光源”

状态所有者G-buffer framebuffer、geometry program 与 lighting program
受控状态/资源position/normal/albedo-spec attachments、格式、坐标空间、draw buffers 和 light list
触发命令几何 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 与光照重建:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测: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. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“G-buffer 保存 view-space position,却拿 world-space light position直接相减”

3. 定位首差一致

position、normal 与 light 位于同一空间;附件精度满足后续重建

4. 清理并重放一致

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 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…