前向渲染与延迟渲染
需要预先学习:
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);
思路直接:每个片元、每个光源算一遍。于是总光照成本大致是:
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、半透明、逐物体优化),又让光源数量不再成为瓶颈,复杂度变成 。集群着色(clustered shading)在屏幕网格上再按深度分层,光源裁剪更精细,是现代引擎(Unity HDRP、Unreal)的默认方案。
6. 小结
前向渲染把几何与光照揉在一个 Pass 里,简单直接,适合光源少的场景;延迟渲染用 G-Buffer 把光照推迟到屏幕空间,用带宽换多光源能力,是桌面端大场景的默认选择;Forward+ 则把两者的优点合并,成为当下的主流。三者共享同一套光照公式(Phong模型与Blinn-Phong模型、PBR),区别只在"光照在哪里、什么时候算"。



