需要预先学习:


1. 前向渲染(Forward Rendering)

前向渲染(forward rendering)是 着色与渲染管线 描述的标准流程:对每个物体,用一套顶点着色器 + 片元着色器完成变换与光照,结果直接写入颜色缓冲。


1.1 基本流程

前向渲染中,光照发生在片元着色器里:片元拿到位置、法线、材质后,遍历场景中的每一个光源,逐个累加光照贡献:

// 伪代码:前向渲染的片元着色器
vec3 lighting = vec3(0.0);

for (int i = 0; i < lightCount; i++)          // 遍历所有光源
{
    lighting += calcLighting(fragPos, normal, albedo, lights[i]);
}

FragColor = vec4(lighting, 1.0);

思路直接:每个片元、每个光源算一遍。于是总光照成本大致是:

O(物体数×光源数×片元数)O(\text{物体数} \times \text{光源数} \times \text{片元数})


1.2 多光源的问题

前向渲染的瓶颈在光源数量。场景里有 100 个物体、10 个光源时,光栅化阶段每个片元都要算 10 次光照;光源翻倍到 20 个,所有片元的光照计算整体翻倍——不管该光源是否真的影响这个片元。

另外还有一个隐蔽的开销:过度绘制(overdraw)。前向渲染中片元着色器先跑、深度测试后做(见 着色与渲染管线),被其他物体挡住的片元也完整算了一遍光照,最后才被深度测试丢弃。物体层层遮挡时,像素上的光照被重复计算多次。

注:光源数少(个位数)时前向渲染完全够用,这也是移动端、低配设备的默认选择。它的麻烦在光源多起来之后才显现。


2. 延迟渲染(Deferred Rendering)

延迟渲染(deferred rendering)换个思路:把"几何"与"光照"拆成两个阶段。第一遍只画几何,把每个可见片元的几何信息(位置、法线、颜色等)存进一组纹理;第二遍不再画物体,而是画一个全屏四边形,对每个像素从这些纹理里取数据算光照。

“延迟"的含义:光照从"物体被光栅化时"推迟到了"所有几何信息都准备好之后”。


2.1 几何 Pass:写入 G-Buffer

第一遍称几何 Pass(geometry pass),把场景渲染进一组颜色纹理,这组纹理统称 G-Buffer(Geometry Buffer,几何缓冲)。G-Buffer 用 帧缓冲 的多重渲染目标(MRT,Multiple Render Targets)一次写多张纹理,每张存一项属性:

附件 存储内容 常见格式
位置纹理 片元世界坐标 GL_RGBA16F(半浮点)
法线纹理 片元法线 GL_RGBA16F
反照率纹理 漫反射颜色(albedo) GL_RGBA8
材质纹理 金属度、粗糙度等 GL_RGBA8(PBR,见 PBR)
深度附件 深度(供深度测试) RBO 或深度纹理

注:位置纹理必须用浮点格式。位置的范围与精度远超 8 位整数的能力,GL_RGBA8 存位置会出现明显的色带断层(banding)。

创建 G-Buffer 的代码与 帧缓冲 中 FBO 的创建一致,区别是挂上多个颜色附件并显式声明:

// 3 张颜色纹理:位置、法线、反照率
GLuint gBuffer;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);

GLuint tex[3];
const GLenum attachments[3] = { GL_COLOR_ATTACHMENT0,
                                GL_COLOR_ATTACHMENT1,
                                GL_COLOR_ATTACHMENT2 };
for (int i = 0; i < 3; i++)
{
    glGenTextures(1, &tex[i]);
    glBindTexture(GL_TEXTURE_2D, tex[i]);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, W, H, 0,
                 GL_RGBA, GL_FLOAT, nullptr);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
    glFramebufferTexture2D(GL_FRAMEBUFFER, attachments[i],
                           GL_TEXTURE_2D, tex[i], 0);
}
glDrawBuffers(3, attachments);   // 显式声明:片元着色器有 3 个输出目标

// 深度附件用 RBO(只需参与深度测试,不需要采样)
GLuint rboDepth;
glGenRenderbuffers(1, &rboDepth);
glBindRenderbuffer(GL_RENDERBUFFER, rboDepth);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, W, H);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                          GL_RENDERBUFFER, rboDepth);

if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
{
    // 配置有误:附件缺失或格式不匹配
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

几何 Pass 的片元着色器用 layout (location = N) 声明多个输出,每个输出写一张纹理:

#version 330 core
layout (location = 0) out vec4 gPosition;   // 世界坐标
layout (location = 1) out vec4 gNormal;     // 法线
layout (location = 2) out vec4 gAlbedo;     // 漫反射颜色

in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoords;

uniform sampler2D texture_diffuse1;

void main()
{
    gPosition = vec4(FragPos, 1.0);
    gNormal   = vec4(normalize(Normal), 1.0);
    gAlbedo   = texture(texture_diffuse1, TexCoords);
}

几何 Pass 中不做任何光照计算,只写属性。光照留到下一遍。


2.2 光照 Pass:屏幕空间着色

第二遍称光照 Pass(lighting pass)。把 G-Buffer 的纹理绑定为采样器,画一个覆盖全屏的四边形——不再有几何、深度测试、模型变换,每个像素从 G-Buffer 取回位置、法线、颜色,再对每个光源算光照:

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;

uniform vec3 lightPos;    // 点光源位置
uniform vec3 lightColor;
uniform vec3 viewPos;     // 摄像机位置

void main()
{
    // 从 G-Buffer 取回片元属性
    vec3 fragPos = texture(gPosition, TexCoords).rgb;
    vec3 normal  = normalize(texture(gNormal, TexCoords).rgb);
    vec3 albedo  = texture(gAlbedo, TexCoords).rgb;

    // 与 [[Phong模型与Blinn-Phong模型]] 相同的 Blinn-Phong 计算
    vec3 lightDir = normalize(lightPos - fragPos);
    vec3 viewDir  = normalize(viewPos - fragPos);
    vec3 halfwayDir = normalize(lightDir + viewDir);

    float diff = max(dot(normal, lightDir), 0.0);
    float spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);

    vec3 ambient  = 0.1 * albedo;
    vec3 result = ambient + (diff * albedo + spec * 0.5) * lightColor;

    FragColor = vec4(result, 1.0);
}

C++ 侧每帧两个 Pass:

// Pass 1:几何 —— 写入 G-Buffer
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(geometryProg);
drawScene();                       // 正常画所有物体,只写 G-Buffer

// Pass 2:光照 —— 全屏四边形
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(lightingProg);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex[0]);   // gPosition
// ... 绑定其余纹理与 uniform
drawFullScreenQuad();

注:光照 Pass 中每个像素的光照成本只与该像素有关——即使屏幕上有 1000 个物体重叠,最终每个像素也只算一遍光照。这就是延迟渲染解决"多光源 + 过度绘制"的核心:光照的计算量从"物体维度"转移到"屏幕像素维度"。


2.3 复杂度对比

阶段 前向渲染 延迟渲染
几何处理 每物体一次 每物体一次(几何 Pass)
光照计算 物体数 × 光源数 × 片元数 像素数 × 光源数
过度绘制 被遮挡片元也完整算光照 几何 Pass 被深度测试丢弃,光照 Pass 每个像素只算一次
光源数量 个位数友好,多了线性恶化 屏幕上点几盏灯几乎无成本差异

延迟渲染把光照复杂度与场景几何复杂度解耦:画 100 个物体还是 1000 个物体,光照 Pass 的成本完全一样(都是屏幕分辨率)。


3. 延迟渲染的代价

延迟渲染用带宽和显存换来了光照自由,代价也很具体。


3.1 带宽与显存

G-Buffer 每帧要写满 3~4 张全屏纹理(1080p 下约 30~60 MB),光照 Pass 每个像素又要读回它们。显存带宽是延迟渲染最大的瓶颈——移动端带宽尤其紧张,这也是手机游戏普遍用前向渲染的原因。


3.2 半透明

半透明需要与已绘制内容混合(见 着色与渲染管线),混合必须在绘制顺序正确的片元上进行。延迟渲染的光照 Pass 已经和几何脱钩,无法在屏幕空间重排混合顺序,所以半透明物体要单独用前向渲染再画一遍(排序 + 混合),与 G-Buffer 光照结果叠加。多数引擎的做法是:不透明物体走延迟,透明物体走前向。


3.3 MSAA

多重采样抗锯齿(MSAA,见 光栅化)依赖光栅化阶段对同一像素的多个子采样点同时执行片元着色器,才能让几何边缘平滑。延迟渲染在光照 Pass 里只剩全屏四边形,几何信息已烘焙进纹理,无法再做 MSAA——只能用 FXAA、TAA 等后处理抗锯齿,或为 G-Buffer 专门设计的重构方案。


3.4 G-Buffer 精度

位置、法线写进纹理是有损的:浮点纹理的半精度只有约 3 位十进制有效数字,远处物体的位置会抖动;法线的 16 位存储也会引入噪点。工程上有各种缓解:位置用深度重建(viewPos = depth * 逆投影,省掉位置纹理)、法线用八面体映射压缩到 2 通道等——每一招都是在"带宽"与"精度"之间权衡。


4. 前向与延迟:对比总表

维度 前向渲染 延迟渲染
光照成本 物体数 × 光源数 × 片元数 像素数 × 光源数
多光源 开销线性增长 几乎无压力
过度绘制 被遮挡片元重复算光照 光照与几何解耦
半透明 天然支持(排序 + 混合) 不支持,需额外前向 Pass
MSAA 天然支持 困难,通常用后处理抗锯齿
带宽 / 显存 低 高(G-Buffer 写入 + 读回)
逐物体优化 方便(逐物体裁剪光源等) 困难(光照在屏幕空间)
适用场景 光源少、移动端、透明物体多 光源多、桌面端、大型场景

5. 改进方向

延迟渲染并非终点,业界在它之上还有两代改进。


5.1 延迟光照(Light Pre-Pass)

延迟光照(deferred lighting,也称 light pre-pass)只把位置和法线(+ 深度)写进 G-Buffer,光照 Pass 先算出每个像素的漫反射与高光中间结果,最后一遍把材质颜色与光照结果合成。G-Buffer 只有 2~3 张纹理,带宽减半;但需要额外的合成 Pass,且高光需要法线 + 位置配合,写法更繁琐。这是老一代引擎(如战争机器 1 代)的折中方案。


5.2 Forward+ 与集群着色

Forward+(前向+)是近十年的主流方案:把屏幕划分成网格(tile),对每个网格预计算"会影响该网格的光源列表",前向渲染时片元只遍历本网格的光源。它保留了前向渲染的优点(支持 MSAA、半透明、逐物体优化),又让光源数量不再成为瓶颈,复杂度变成 O(片元数×本网格光源数)O(\text{片元数} \times \text{本网格光源数})。集群着色(clustered shading)在屏幕网格上再按深度分层,光源裁剪更精细,是现代引擎(Unity HDRP、Unreal)的默认方案。


6. 小结

前向渲染把几何与光照揉在一个 Pass 里,简单直接,适合光源少的场景;延迟渲染用 G-Buffer 把光照推迟到屏幕空间,用带宽换多光源能力,是桌面端大场景的默认选择;Forward+ 则把两者的优点合并,成为当下的主流。三者共享同一套光照公式(Phong模型与Blinn-Phong模型、PBR),区别只在"光照在哪里、什么时候算"。