Three.js 后处理管线与自定义着色器:从基础渲染到电影级特效
一、后处理的"视觉跃迁":为什么 3D 场景总差一口气
Three.js 的基础渲染能呈现几何体、材质和光照,但画面总缺少一种"电影感"——缺少景深模糊、缺少光晕泛光、缺少色彩校正、缺少噪点颗粒。这些视觉效果都需要后处理(Post-processing)来实现。
后处理的原理是:将场景渲染到帧缓冲(FBO)而非屏幕,然后对帧缓冲中的图像应用一系列图像处理效果,最终输出到屏幕。Three.js 的 EffectComposer 封装了这一流程,通过组合多个 Pass 实现管线化的后处理。
二、后处理管线的架构
后处理管线由多个 Pass 串联组成,每个 Pass 读取上一级的输出,执行特定的图像处理,输出到下一级。
flowchart LR A[场景渲染 RenderPass] --> B[泛光效果 UnrealBloomPass] B --> C[自定义着色器 ShaderPass] C --> D[色彩校正 ShaderPass] D --> E[胶片颗粒 ShaderPass] E --> F[色调映射 OutputPass] F --> G[屏幕输出]管线的关键约束是 Pass 的顺序:泛光必须在色彩校正之前(否则泛光颜色会被错误映射),胶片颗粒必须在最后(否则颗粒会被后续处理模糊)。Pass 的数量也需控制——每个 Pass 都是一次全屏绘制,5 个 Pass 意味着 5 次全屏像素计算。
三、工程化实现
3.1 基础后处理管线
// postprocessing.ts import * as THREE from 'three'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'; import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; class PostProcessingPipeline { private composer: EffectComposer; constructor( renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.PerspectiveCamera ) { const size = renderer.getSize(new THREE.Vector2()); const pixelRatio = renderer.getPixelRatio(); this.composer = new EffectComposer(renderer); // Pass 1:场景渲染 const renderPass = new RenderPass(scene, camera); this.composer.addPass(renderPass); // Pass 2:泛光效果 const bloomPass = new UnrealBloomPass( new THREE.Vector2(size.x, size.y), 0.8, // 强度 0.3, // 半径 0.85 // 阈值 ); this.composer.addPass(bloomPass); // Pass 3:赛博朋克色彩校正 const cyberpunkColorShader = { uniforms: { tDiffuse: { value: null }, uTime: { value: 0 }, uIntensity: { value: 0.6 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform sampler2D tDiffuse; uniform float uTime; uniform float uIntensity; varying vec2 vUv; void main() { vec4 color = texture2D(tDiffuse, vUv); // 赛博朋克色调:增强青色和品红色 vec3 cyberpunkTint = vec3(0.0, 0.8, 1.0) * color.r + vec3(1.0, 0.0, 0.8) * color.b; color.rgb = mix(color.rgb, cyberpunkTint, uIntensity * 0.3); // 暗角效果 float vignette = 1.0 - smoothstep(0.4, 1.2, length(vUv - 0.5)); color.rgb *= mix(0.6, 1.0, vignette); // 扫描线效果 float scanline = sin(vUv.y * 800.0 + uTime * 2.0) * 0.03; color.rgb -= scanline; gl_FragColor = color; } `, }; const cyberpunkPass = new ShaderPass(cyberpunkColorShader); this.composer.addPass(cyberpunkPass); // Pass 4:胶片颗粒 const filmGrainShader = { uniforms: { tDiffuse: { value: null }, uTime: { value: 0 }, uGrainIntensity: { value: 0.05 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform sampler2D tDiffuse; uniform float uTime; uniform float uGrainIntensity; varying vec2 vUv; // 伪随机噪声 float random(vec2 st) { return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453); } void main() { vec4 color = texture2D(tDiffuse, vUv); float grain = random(vUv + uTime) * uGrainIntensity; color.rgb += grain - uGrainIntensity * 0.5; gl_FragColor = color; } `, }; const grainPass = new ShaderPass(filmGrainShader); this.composer.addPass(grainPass); // Pass 5:输出(色调映射 + 颜色空间转换) const outputPass = new OutputPass(); this.composer.addPass(outputPass); } render(deltaTime: number): void { // 更新时间 uniform const passes = this.composer.passes; for (const pass of passes) { if (pass instanceof ShaderPass && pass.uniforms.uTime) { pass.uniforms.uTime.value += deltaTime; } } this.composer.render(); } resize(width: number, height: number): void { this.composer.setSize(width, height); } }3.2 选择性泛光
// selective-bloom.ts // 只让特定物体泛光,其他物体不受影响 class SelectiveBloom { private bloomLayer: THREE.Layers; constructor() { // 创建泛光层:只有在该层的物体才会泛光 this.bloomLayer = new THREE.Layers(); this.bloomLayer.set(1); } // 将物体添加到泛光层 enableBloom(object: THREE.Object3D): void { object.layers.enable(1); // 子对象也需要启用 object.traverse((child) => { child.layers.enable(1); }); } // 从泛光层移除 disableBloom(object: THREE.Object3D): void { object.layers.disable(1); object.traverse((child) => { child.layers.disable(1); }); } }四、后处理管线的 Trade-offs
性能开销的累积效应:每个 Pass 都是一次全屏像素着色器执行。在 1920×1080 分辨率下,一个 Pass 约处理 200 万像素。5 个 Pass 的总计算量约 1000 万像素 × 着色器复杂度。在移动端 GPU 上,这可能导致帧率从 60fps 降到 30fps 以下。建议在移动端减少 Pass 数量或降低渲染分辨率。
Pass 顺序的依赖性:某些 Pass 的效果依赖前置 Pass 的输出。例如,泛光效果需要场景中的高亮区域,如果色彩校正在泛光之前,高亮阈值会失效。建议按照"渲染→泛光→色彩校正→特效→输出"的标准顺序排列 Pass。
自定义着色器的调试困难:GLSL 着色器无法断点调试,错误只能在运行时通过视觉异常发现。建议先在 ShaderToy 上验证着色器逻辑,再移植到 Three.js。同时使用console.log输出 uniform 值,确保数据传递正确。
帧缓冲的内存开销:每个 Pass 需要独立的帧缓冲,在 1080p 分辨率下每个 FBO 约 8MB。5 个 Pass 需要 40MB 额外显存。在显存有限的设备上,需要降低 FBO 分辨率或减少 Pass 数量。
五、总结
Three.js 后处理管线通过组合多个 Pass,将基础渲染升级为电影级视觉效果。落地路线上,建议先搭建 RenderPass + OutputPass 的最小管线,再逐步添加泛光、色彩校正等 Pass。关键原则:Pass 数量与性能成反比,Pass 顺序影响效果正确性,自定义着色器需要充分测试,移动端必须考虑性能降级。