Skip to main content

第 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 逻辑。代码量小,但所有后处理都要自己组装


🎓 深度思考

  1. 为什么后处理会拖慢性能?
    每个 Pass 都要渲染整个屏幕的像素。如果屏幕是 1920×1080,每个 Pass 就是 ~200 万次片元着色器调用。3 个 Pass = 600 万次

  2. 如何优化后处理性能?

    • 降分辨率:用半分辨率 RenderTarget,最终拉伸(视觉损失小)
    • Pass 合并:把多个效果写在一个 Shader 里
    • 避免读取深度:深度采样比颜色采样慢
  3. WebGL 1.0 + EffectComposer 的局限?
    没有 MRT(需要扩展)、没有 inversesqrt 等高级 GLSL 函数、没有 texelFetch。WebGL 2.0 全面支持。


📚 延伸阅读