Skip to main content

03. 后期处理 (Post-processing) 艺术

后期处理(Post-processing)是提升 3D 场景质感的关键步骤。它就像是给渲染好的照片进行 PS:调整色调、添加发光、模糊背景等。在 Three.js 中,这通过 Off-screen Rendering(离屏渲染)实现。

1. 核心原理:Ping-Pong 缓冲

普通的渲染是将场景直接画到屏幕(Default Framebuffer)上。 后期处理则是:

  1. Read Buffer: 将场景渲染到一个纹理(RenderTarget)上,而不是屏幕。
  2. Pass (Shader): 读取这个纹理,经过 Shader 处理(比如把每个像素变亮),输出到另一个纹理。
  3. Write Buffer: 将处理后的纹理再次作为输入,进行下一个效果处理...
  4. Screen: 最后一个 Pass 将结果绘制到屏幕上。

这通常需要两个 Buffer 交替读写,称为 Ping-Pong 技术。

2. Three.js 的 EffectComposer

Three.js 提供了 EffectComposer 来管理这个流程。需要引入额外的库文件。

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';

// 1. 创建 Composer
const composer = new EffectComposer(renderer);

// 2. 添加基础渲染通道 (RenderPass)
// 作用:将场景和相机渲染出来的画面作为这一帧的底图
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

// 3. 添加特效通道 (例如辉光 Bloom)
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, // 强度
0.4, // 半径
0.85 // 阈值
);
composer.addPass(bloomPass);

// 4. 渲染循环中替代 renderer.render
function animate() {
requestAnimationFrame(animate);
// renderer.render(scene, camera); // 不要用这个了
composer.render(); // 用这个
}

3. 常用 Pass 解析

  • RenderPass: 必选。负责把 3D 场景画出来。
  • ShaderPass: 万能 Pass。接受一个自定义 ShaderMaterial,处理输入的纹理。
  • UnrealBloomPass: 虚幻引擎风格的辉光,效果很好但性能开销大。
  • SAOPass (Screen Space Ambient Occlusion): 屏幕空间环境光遮蔽,增加角落阴影,提升立体感。
  • OutlinePass: 描边效果,常用于选中物体高亮。
  • SMAAPass / FXAAPass: 抗锯齿。注意:使用了 EffectComposer 后,Renderer 自带的 antialias: true 往往失效或效果不佳,通常需要在最后加一个抗锯齿 Pass。

4. 自定义 ShaderPass

最强大的功能是编写自己的后处理效果。例如,实现一个“黑白老电影”滤镜。

import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';

// 1. 定义 Shader
const GrayscaleShader = {
uniforms: {
"tDiffuse": { value: null }, // 固定的纹理输入名,代表上一帧的画面
"amount": { value: 1.0 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float amount;
varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
gl_FragColor = vec4(vec3(gray), color.a);
}
`
};

// 2. 创建 Pass
const grayscalePass = new ShaderPass(GrayscaleShader);
composer.addPass(grayscalePass);

5. 深度纹理 (Depth Texture) 的应用

有些高级后处理需要知道每个像素的深度(距离相机的远近),例如景深(Depth of Field)、SSAO、软粒子。

// 在创建 RenderTarget 时启用深度纹理
const target = new THREE.WebGLRenderTarget(width, height);
target.depthTexture = new THREE.DepthTexture();
target.depthTexture.type = THREE.UnsignedShortType;

const composer = new EffectComposer(renderer, target);

之后在你的 Shader 中就可以通过 uniform 读取这个深度图,重构出像素的世界坐标。

6. 色彩空间 (Color Space)

在后处理链的最后,通常需要进行 Gamma 校正。 Three.js 的 OutputPass (较新版本) 或 GammaCorrectionShader 用于将线性颜色空间转换回 sRGB 空间以供显示器正确显示。

顺序建议: RenderPass -> 物理特效(Bloom/SSAO) -> 风格化特效(色调映射) -> 抗锯齿(FXAA/SMAA) -> OutputPass (Gamma校正)

性能提示

后处理非常消耗 GPU,因为每个 Pass 都要遍历所有像素。

  1. 尽量合并 Pass(把多个简单的 Shader 逻辑写在一个 Shader 里)。
  2. 控制 RenderTarget 的分辨率(composer.setPixelRatio),对于模糊类效果(Bloom),半分辨率往往足够。

7. 前沿技术:基于 Node 系统的现代后期处理 (WebGPU / TSL)

传统的 EffectComposer 存在一个致命的性能瓶颈:多 Pass 意味着多次读写全屏纹理(Ping-Pong 带来的内存带宽消耗巨大)。如果你的后处理链包含 5 个特效,场景不仅被渲染了 1 次,全屏图像还会被读写 5 次。

在拥抱 WebGPU 和 TSL(Three.js Shading Language)的现代 Three.js 架构中,后处理迎来了革命性的优化:Node-based Post Processing

通过节点系统(NodeMaterial),Three.js 可以在编译阶段智能地将多个后处理效果(如色彩校正、对比度、甚至某些模糊变体)合并为一个单一的 Compute Shader 或 Fragment Shader 过程(称为 Uber Shader 机制)。这意味着:

  • 内存带宽大幅降低:多个特效只需一次全屏纹理读写即可完成!
  • 更简单的 API:开发者通过拼接节点来组合特效,而无需手动管理繁琐的 RenderPass 和 Ping-Pong 流程。
import { pass, color, normalWorld } from 'three/nodes';
// 现代 WebGPU 渲染器结合 TSL 节点进行后处理
const scenePass = pass(scene, camera);

// 提取法线信息或颜色进行处理(全部在一个材质节点网络中完成)
const postProcessingNode = scenePass.getTextureNode().add( color( 0.1, 0.1, 0.1 ) );
// 最后输出到屏幕

(更多关于 TSL 的细节将在第 05 节中探讨)


思考与练习

  1. 为什么使用 EffectComposer 后,通常需要额外添加 SMAAPassFXAAPass,而不是依赖渲染器初始化的 antialias: true 选项?
  2. 如果你的游戏需要同时实现 “全屏泛光(Bloom)” 和 “仅选中物体发光(Outline)”,在使用 EffectComposer 时应该如何设计 RenderTarget 和 Pass 的层级?如何避免全屏物体都受到 Bloom 的影响?
  3. "Ping-Pong 缓冲" 为什么在传统 WebGL 后处理中是必须的?它带来了哪些性能隐患?
  4. 现代基于 TSL 和 Node 系统的后处理,是如何解决传统多 Pass 架构带来的内存带宽瓶颈的?