第 10 节:RenderTarget 与后处理管线——FBO 的高级玩法
后处理(Post-processing)是现代 3D 应用的"调味料"——模糊、Bloom、色调映射、抗锯齿、景深……这些都基于同一个核心:离屏渲染 + 全屏 Shader。
🏗️ WebGLRenderTarget:FBO 的封装
RenderTarget 本质上是一组纹理 + 一个 Framebuffer 对象:
WebGLRenderTarget
├── width, height
├── texture: Texture (颜色附件)
├── depthTexture: DepthTexture (深度附件)
├── depthBuffer, stencilBuffer (是否创建)
├── samples (MSAA 抗锯齿)
└── format / type / internalFormat (像素格式)
1. 创建与绑定
// 简化源码
constructor(width, height, options = {}) {
this.width = width;
this.height = height;
this.texture = new Texture(width, height);
this.depthTexture = options.depthTexture || null;
// 创建 FBO
this.glFramebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, this.glFramebuffer);
// 绑定颜色附件
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.texture.image.data, 0);
// 绑定深度附件
if (this.depthTexture) {
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.depthTexture.image.data, 0);
}
// 检查 FBO 完整性
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
console.error('Framebuffer incomplete!');
}
}
2. 离屏渲染流程
// 1. 绑定 RenderTarget
renderer.setRenderTarget(target);
renderer.render(scene, camera);
// 现在场景被渲染到 target.texture 而不是 canvas
// 2. 切换回 canvas
renderer.setRenderTarget(null);
renderer.render(fullScreenScene, fullScreenCamera);
// fullScreenScene 用一个覆盖整个屏幕的 quad,shader 中采样 target.texture
🔄 Ping-Pong:双缓冲后处理
后处理的关键技巧:不能同时读写同一个纹理(GPU 限制)。需要两个 RenderTarget 交替:
┌────────────┐ 第1遍 ┌────────────┐
│ 场景渲染 │ ─────▶ │ RT_A │
│ (Scene) │ │ (亮度提取) │
└────────────┘ └─────┬──────┘
│
│ 采样
▼
┌────────────┐ 第2遍 ┌────────────┐
│ 最终输出 │ ◀───── │ RT_B │
│ (Canvas) │ │ (模糊混合) │
└────────────┘ └────────────┘
Three.js 的 EffectComposer 就是 Ping-Pong 的工程化封装:
// 简化源码
class EffectComposer {
constructor(renderer, renderTarget) {
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
}
render(deltaTime) {
for (const pass of this.passes) {
pass.render(this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive);
// 交换读写缓冲
if (pass.needsSwap) {
const tmp = this.readBuffer;
this.readBuffer = this.writeBuffer;
this.writeBuffer = tmp;
}
}
}
}
🧩 Pass 系统
EffectComposer 的核心是 Pass 链——每个 Pass 是一段独立的后处理逻辑:
| Pass | 功能 |
|---|---|
RenderPass | 渲染主场景到第一个 RT |
ShaderPass | 用户自定义 Shader 处理 |
BloomPass | 高斯模糊提取亮区 |
OutputPass | 色调映射 + 颜色空间转换 |
FXAAPass | 抗锯齿 |
Pass 链示例
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera)); // 1. 渲染场景
composer.addPass(new BloomPass(0.5, 0.4, 0.85)); // 2. Bloom 效果
composer.addPass(new ShaderPass(grayscaleShader)); // 3. 灰度处理
composer.addPass(new OutputPass()); // 4. 色调映射
// 渲染
composer.render(deltaTime);
🎨 深度纹理的高级用法
DepthTexture 把深度信息也存为纹理,给后处理使用:
// 创建带深度纹理的 RenderTarget
const target = new WebGLRenderTarget(width, height);
target.depthTexture = new DepthTexture(width, height);
target.depthTexture.format = DepthFormat;
target.depthTexture.type = UnsignedShortType;
典型应用
1. SSAO(屏幕空间环境光遮蔽)
// 在 SSAO Shader 中采样深度
float depth = texture(uDepthTexture, vUv).r;
vec3 worldPos = reconstructWorldPos(vUv, depth);
2. DoF(景深)
// 离相机越远越模糊
float depth = texture(uDepthTexture, vUv).r;
float blur = smoothstep(uNear, uFar, depth);
finalColor = mix(sharpColor, blurredColor, blur);
3. 软粒子
// 粒子与场景物体的交界面更柔和
float sceneDepth = texture(uDepthTexture, vUv).r;
float particleDepth = gl_FragCoord.z;
float fade = clamp((sceneDepth - particleDepth) * 10.0, 0.0, 1.0);
📦 MRT(多渲染目标)
WebGL 2.0 / WebGL 1.0 EXT 扩展支持同时渲染到多个纹理:
const target = new WebGLMultipleRenderTargets(width, height, 2); // 2 个目标
// target.texture[0]: 漫反射颜色
// target.texture[1]: 法线
// 片段着色器中
layout(location = 0) out vec4 outColor;
layout(location = 1) out vec3 outNormal;
应用:延迟渲染(Deferred Shading)——把颜色、法线、材质 ID 一次写入多张贴图,主渲染 pass 再统一光照计算。
🎯 Mini 实现
50 行实现一个最简后处理管线:
class MiniComposer {
constructor(gl, width, height) {
this.rtA = createRT(gl, width, height);
this.rtB = createRT(gl, width, height);
this.read = this.rtA;
this.write = this.rtB;
}
pingPong(pass, scene, camera) {
pass.render(this.read.texture, this.write.fbo);
// 交换
[this.read, this.write] = [this.write, this.read];
}
}
// 用法
const composer = new MiniComposer(gl, canvas.width, canvas.height);
composer.pingPong(scenePass, scene, camera); // 渲染场景
composer.pingPong(blurPass, scene, camera); // 模糊
composer.pingPong(finalPass, scene, camera); // 输出到 canvas
🆚 OGL 对比
OGL 的 RenderTarget 极简:
// OGL: src/RenderTarget.js (~80 行)
class RenderTarget {
constructor(gl, { width, height, target = gl.TEXTURE_2D } = {}) {
this.gl = gl;
this.target = target;
this.texture = new Texture(gl, { width, height, target, ... });
this.framebuffer = gl.createFramebuffer();
this.depthBuffer = gl.createRenderbuffer();
}
}
OGL 不提供 EffectComposer——你得自己写 Ping-Pong 逻辑。代码量小,但所有后处理都要自己组装。
🎓 深度思考
为什么后处理会拖慢性能?
每个 Pass 都要渲染整个屏幕的像素。如果屏幕是 1920×1080,每个 Pass 就是 ~200 万次片元着色器调用。3 个 Pass = 600 万次。如何优化后处理性能?
- 降分辨率:用半分辨率 RenderTarget,最终拉伸(视觉损失小)
- Pass 合并:把多个效果写在一个 Shader 里
- 避免读取深度:深度采样比颜色采样慢
WebGL 1.0 + EffectComposer 的局限?
没有 MRT(需要扩展)、没有inversesqrt等高级 GLSL 函数、没有texelFetch。WebGL 2.0 全面支持。