Three.js 后处理管线与自定义着色器:从基础渲染到电影级特效
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 顺序影响效果正确性自定义着色器需要充分测试移动端必须考虑性能降级。