第19章 延迟着色法:G-Buffer构建与多光源渲染

延迟着色法,说白了就是把光照计算往后拖。

传统的前向渲染,每个物体都要跟所有光源算一遍。场景里如果有100个物体、10盏灯,那就是1000次光照计算。很多计算其实是重复的——同一个像素被多个物体遮挡,最终只有最前面的那个可见,前面的计算全白费了。

延迟着色法换了个思路:先把场景的几何信息存起来,再统一做光照。嗯,这就像做饭先备菜,而不是边炒边切。

G-Buffer:场景的“快照”

G-Buffer就是用来存这些几何信息的缓冲区。我个人习惯至少存四张纹理:

  • 位置(Position):世界空间或视图空间的坐标
  • 法线(Normal):表面朝向
  • 颜色(Albedo):漫反射颜色
  • 材质属性(Specular + Shininess):高光强度和粗糙度

我在项目中遇到过一个问题:位置缓冲区用32位浮点纹理,显存消耗直接翻倍。后来改成16位浮点,配合视图空间压缩,效果几乎没差别,但带宽省了一半。

核心要点:G-Buffer的精度选择直接影响渲染质量和性能。移动端建议用16位浮点,PC端可以上32位。

G-Buffer构建流程

构建G-Buffer分两步走:

  1. 几何Pass:渲染所有物体,输出到多张渲染目标(MRT)
  2. 光照Pass:读取G-Buffer,逐像素计算光照

代码示例(几何Pass的片段着色器):

// 几何Pass - 片段着色器
#version 300 es
precision highp float;

in vec3 vPosition;
in vec3 vNormal;
in vec2 vTexCoord;

uniform sampler2D uDiffuseMap;

layout(location = 0) out vec4 gPosition;
layout(location = 1) out vec4 gNormal;
layout(location = 2) out vec4 gAlbedo;
layout(location = 3) out vec4 gMaterial;

void main() {
    // 位置:世界空间
    gPosition = vec4(vPosition, 1.0);
    
    // 法线:归一化后存到[0,1]范围
    gNormal = vec4(vNormal * 0.5 + 0.5, 1.0);
    
    // 漫反射颜色
    vec4 diffuse = texture(uDiffuseMap, vTexCoord);
    gAlbedo = diffuse;
    
    // 材质:r=高光强度, g=粗糙度
    gMaterial = vec4(0.5, 0.8, 0.0, 1.0);
}

小技巧:法线存成[0,1]范围,读取时再映射回[-1,1]。这样可以用8位纹理,省带宽。

多光源高效渲染

延迟着色法最大的优势就在这里。光照Pass只需要一个全屏四边形,对每个像素计算所有光源的贡献。

我曾经优化过一个夜景场景,32盏动态点光源。前向渲染直接掉到20帧,换成延迟着色法后稳定在55帧。为什么?因为光照计算量跟物体数量无关了,只跟屏幕分辨率有关。

光照Pass的核心逻辑:

// 光照Pass - 片段着色器
#version 300 es
precision highp float;

uniform sampler2D uPositionMap;
uniform sampler2D uNormalMap;
uniform sampler2D uAlbedoMap;
uniform sampler2D uMaterialMap;

uniform vec3 uLightPositions[32];
uniform vec3 uLightColors[32];
uniform float uLightRadii[32];

in vec2 vTexCoord;

out vec4 fragColor;

void main() {
    // 从G-Buffer读取数据
    vec3 worldPos = texture(uPositionMap, vTexCoord).xyz;
    vec3 normal = texture(uNormalMap, vTexCoord).xyz * 2.0 - 1.0;
    vec3 albedo = texture(uAlbedoMap, vTexCoord).rgb;
    vec4 material = texture(uMaterialMap, vTexCoord);
    
    float specularIntensity = material.r;
    float roughness = material.g;
    
    vec3 viewDir = normalize(uCameraPos - worldPos);
    vec3 finalColor = vec3(0.0);
    
    // 遍历所有光源
    for (int i = 0; i < 32; i++) {
        vec3 lightDir = uLightPositions[i] - worldPos;
        float distance = length(lightDir);
        
        // 光源衰减
        float attenuation = clamp(1.0 - distance / uLightRadii[i], 0.0, 1.0);
        attenuation *= attenuation;
        
        // 漫反射
        float diff = max(dot(normal, normalize(lightDir)), 0.0);
        vec3 diffuse = albedo * diff;
        
        // 高光(Blinn-Phong简化版)
        vec3 halfDir = normalize(normalize(lightDir) + viewDir);
        float spec = pow(max(dot(normal, halfDir), 0.0), 1.0 / roughness);
        vec3 specular = vec3(specularIntensity) * spec;
        
        finalColor += (diffuse + specular) * uLightColors[i] * attenuation;
    }
    
    fragColor = vec4(finalColor, 1.0);
}

注意:光源数量太多时,循环遍历会成为性能瓶颈。可以用光源剔除或分块渲染来优化。

优缺点分析

优点 缺点
多光源场景性能优异 不支持透明物体(需要额外处理)
光照计算与几何复杂度解耦 G-Buffer占用显存较大
便于实现后处理特效 带宽消耗高(多次纹理读取)
光源增减不影响几何Pass 抗锯齿实现复杂(需要特殊处理)

说白了,延迟着色法适合光源多、物体多的场景。如果你的游戏只有一两盏灯,前向渲染反而更简单。

知识体系结构图

延迟着色法知识体系 延迟着色法 G-Buffer 构建 位置 (Position) 法线 (Normal) 颜色 (Albedo) 光照 Pass 逐像素光照计算 光源遍历与衰减 最终颜色输出 缺点:不支持透明、显存大、带宽高 优点:多光源高效、几何与光照解耦

避坑指南

我曾经在项目里踩过一个坑:G-Buffer的纹理格式没选对,导致法线精度不够,光照出现条纹。后来改成RGBA16F才解决。

还有一次,我忘了在光照Pass里做光源剔除。32盏灯全部参与计算,移动端直接卡死。嗯,后来加了基于屏幕空间的光源裁剪,只计算可见光源,帧率翻了三倍。

总结一下:延迟着色法不是银弹。它适合光源多、物体多的场景,但透明物体、抗锯齿、显存带宽都是需要额外处理的坑。选不选它,取决于你的具体需求。


公众号:蓝海资料掘金营,微信deep3321