第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分两步走:
- 几何Pass:渲染所有物体,输出到多张渲染目标(MRT)
- 光照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的纹理格式没选对,导致法线精度不够,光照出现条纹。后来改成RGBA16F才解决。
还有一次,我忘了在光照Pass里做光源剔除。32盏灯全部参与计算,移动端直接卡死。嗯,后来加了基于屏幕空间的光源裁剪,只计算可见光源,帧率翻了三倍。
总结一下:延迟着色法不是银弹。它适合光源多、物体多的场景,但透明物体、抗锯齿、显存带宽都是需要额外处理的坑。选不选它,取决于你的具体需求。
公众号:蓝海资料掘金营,微信deep3321