Three.js 赛博朋克风格后处理管线:泛光、色差与扫描线效果的渲染优化

📅 2026/7/7 9:58:43
Three.js 赛博朋克风格后处理管线:泛光、色差与扫描线效果的渲染优化
Three.js 赛博朋克风格后处理管线泛光、色差与扫描线效果的渲染优化一、赛博朋克视觉效果的矩阵先渲染再处理赛博朋克视觉风格的核心不在于几何体的复杂度而在于后处理Post-Processing层。一个用基础BoxGeometry堆出来的摩天楼群叠加泛光Bloom、色差Chromatic Aberration和扫描线效果后视觉冲击力远超过高精模型但没有后处理的场景。这是赛博朋克美学的悖论粗糙几何 重后处理 风格化精细模型 无后处理 平淡。基于 WebGL/Three.js 的 DApp 前端界面越来越倾向于引入这种风格化渲染。钱包连接页、NFT 画廊、链上数据仪表盘 —— 这些场景天然适合霓虹灯管式 UI 和扫描线纹理的叠加。但后处理管线的性能开销容易被低估。Bloom 需要 2-3 次额外的全屏渲染 Pass色差需要分离 RGB 通道偏移采样扫描线需要屏幕空间 UV 叠加。在移动端钱包浏览器的 WebGL 环境中如果不对后处理做精细调优帧率会从 60 跌到 15。flowchart LR A[主场景渲染] -- B[Render Target A: 原始画面] B -- C1[Luminance Pass] C1 -- C2[Gaussian Blur H] C2 -- C3[Gaussian Blur V] C3 -- C4[Bloom Composite] B -- D[Chromatic Aberration Pass] B -- E[Scanline Overlay Pass] C4 -- F[EffectComposer: Blend] D -- F E -- F F -- G[Gamma Correction] G -- H[Output to Screen] subgraph Bloom 管线 C1 -- C2 -- C3 -- C4 end二、后处理管线的 Pass 链与渲染目标管理Three.js 的EffectComposer本质上是一个多 Pass 渲染管线。每个 Pass 对应一次全屏四边形绘制输入是上一个 Pass 的 Render Target 输出。理解 Render Target 的管理方式是性能优化的关键。Pass 1: RenderPass。将主场景渲染到 Render Target输出颜色 深度信息。这是后续所有 Pass 的输入源。Pass 2: UnrealBloomPass。它内部做了三件事第一Luminance Pass —— 提取亮度超过阈值的像素高亮区域第二双重高斯模糊 —— 先水平后垂直让高亮区域的光晕扩散到周围像素第三Composite —— 将模糊后的光晕叠加回原始画面。Pass 3: ShaderPass (色差)。自定义 Shader在片段着色器中分别读取 RGB 三个通道的纹理对 R 和 B 通道做轻微的 UV 偏移。偏移方向通常从屏幕中心向外辐射偏移量与像素到中心的距离成正比。Pass 4: ShaderPass (扫描线)。自定义 Shader在 UV 空间的 Y 方向上周期性改变亮度制造 CRT 显示器的扫描线效果。配合少量噪点能进一步增强质感。sequenceDiagram participant Scene as Three.js Scene participant RenderPass as RenderPass participant BloomPass as UnrealBloomPass participant ChromaticPass as Chromatic Aberration participant ScanlinePass as Scanline Pass participant Screen as 屏幕输出 Scene-RenderPass: 渲染 3D 场景 RenderPass-RenderPass: 写入 RT0颜色深度 RenderPass-BloomPass: 传入 RT0 BloomPass-BloomPass: RT1: Luminance 提取 BloomPass-BloomPass: RT2: 水平高斯模糊 BloomPass-BloomPass: RT3: 垂直高斯模糊 BloomPass-BloomPass: RT4: 叠加回 RT0 副本 BloomPass-ChromaticPass: 传入 Bloom 结果 ChromaticPass-ChromaticPass: RGB 通道偏移采样 ChromaticPass-ChromaticPass: 输出到 RT5 ChromaticPass-ScanlinePass: 传入 RT5 ScanlinePass-ScanlinePass: 扫描线 噪点叠加 ScanlinePass-Screen: 最终帧输出三、生产级代码实践3.1 后处理管线初始化// postprocessing/CyberpunkPipeline.ts import { EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, } from three/examples/jsm/Addons.js; import { WebGLRenderer, Scene, Camera, Vector2, Texture, HalfFloatType } from three; import { ChromaticAberrationShader } from ./shaders/ChromaticAberrationShader; import { ScanlineShader } from ./shaders/ScanlineShader; /** * 赛博朋克风格后处理管线 * 设计考量 * - 使用 HalfFloatType Render Target普通 RGBA8 在高对比度场景下会产生色带 * HalfFloat 提供 16 位精度Bloom 的渐变更平滑 * - Bloom 分辨率设为屏幕分辨率的 1/2高斯模糊是 O(n^2) 操作降低分辨率可大幅减少 * Fragment Shader 执行次数 * - Pass 链按固定顺序执行Bloom → 色差 → 扫描线顺序不影响性能但影响最终视觉效果 */ export class CyberpunkPipeline { public composer: EffectComposer; public bloomPass: UnrealBloomPass; public chromaticPass: ShaderPass; public scanlinePass: ShaderPass; private _renderer: WebGLRenderer; private _resolution: Vector2; constructor( renderer: WebGLRenderer, scene: Scene, camera: Camera, options: { resolution?: Vector2; bloomStrength?: number; bloomRadius?: number; bloomThreshold?: number; chromaticOffset?: number; scanlineIntensity?: number; } {} ) { this._renderer renderer; this._resolution options.resolution ?? new Vector2(window.innerWidth, window.innerHeight); // EffectComposer使用 HalfFloatType 保证色深精度 // 为什么要 HalfFloatBloom 的叠加操作涉及大量浮点中间值 // 普通 RGBA8 的 [0,1] 范围不够精细会导致亮部溢出后出现色块 const renderTargetParams { type: HalfFloatType, minFilter: renderer.capabilities.isWebGL2 ? undefined : undefined, }; this.composer new EffectComposer(renderer); this.composer.setSize(this._resolution.x, this._resolution.y); // Pass 1: 主场景渲染 const renderPass new RenderPass(scene, camera); this.composer.addPass(renderPass); // Pass 2: Bloom泛光 // 分辨率降低到 1/2双线性采样在模糊时天然做了平滑和全分辨率差异极小 this.bloomPass new UnrealBloomPass( new Vector2(this._resolution.x, this._resolution.y), options.bloomStrength ?? 1.5, options.bloomRadius ?? 0.4, options.bloomThreshold ?? 0.6 ); this.composer.addPass(this.bloomPass); // Pass 3: 色差 this.chromaticPass new ShaderPass(ChromaticAberrationShader); this.chromaticPass.uniforms[tDiffuse]!.value null; // 由 Composer 自动注入 this.chromaticPass.uniforms[offset]!.value options.chromaticOffset ?? 0.003; this.composer.addPass(this.chromaticPass); // Pass 4: 扫描线 噪点 this.scanlinePass new ShaderPass(ScanlineShader); this.scanlinePass.uniforms[intensity]!.value options.scanlineIntensity ?? 0.15; this.composer.addPass(this.scanlinePass); // Pass 5: 最终输出Gamma 校正 渲染到屏幕 // EffectComposer 默认最后一个 Pass 的 renderToScreen 自动设为 true } /** * 窗口 resize 时更新渲染分辨率 * 设计考量 * - 在移动端将分辨率降低到设备像素的 75%移动 GPU 的填充率有限 * 720p 渲染 vs 1080p 渲染的 fragment 数量差近 2 倍 */ resize(width: number, height: number, devicePixelRatio: number 1) { const isMobile /Mobi|Android/i.test(navigator.userAgent); const scale isMobile ? 0.75 : 1.0; const renderWidth Math.floor(width * devicePixelRatio * scale); const renderHeight Math.floor(height * devicePixelRatio * scale); this._resolution.set(renderWidth, renderHeight); this.composer.setSize(renderWidth, renderHeight); this.bloomPass.resolution.set(renderWidth, renderHeight); } render(deltaTime: number) { this.composer.render(deltaTime); } }3.2 自定义色差 Shader// shaders/ChromaticAberrationShader.glsl // 设计考量 // - RGB 通道分向偏移模拟镜头色差在画面边缘距中心远的像素偏移量更大 // 中心区域不偏移 —— 镜头的光学中心色差最小 // - 偏移量使用非线性映射 pow(d, 1.5)视觉上比线性偏移更自然 // - 采样时使用 texture2D 而非 textureThree.js ShaderMaterial 的 uniform 注入 // 基于旧版 GLSL 标准 uniform sampler2D tDiffuse; uniform float offset; varying vec2 vUv; void main() { // 计算像素到屏幕中心的距离归一化 vec2 center vUv - 0.5; float dist length(center); // 偏移方向从中心向外辐射 // 仅在边缘区域dist 0.15施加色差避免中心区域的文字和 UI 模糊 float weight smoothstep(0.15, 1.0, dist); // RGB 通道分离偏移 // R 通道从中心向外偏移 offset // B 通道从中心向外偏移 -offset // G 通道保持原始位置绿色对人眼感知最敏感保持原位减少眩晕感 vec2 dir normalize(center) * offset * weight * dist; float r texture2D(tDiffuse, vUv dir).r; float g texture2D(tDiffuse, vUv).g; float b texture2D(tDiffuse, vUv - dir).b; // 边缘衰减超过 0.95 的 UV 做透明化处理防止边缘出现偏移越界的撕裂 float edgeFade 1.0 - smoothstep(0.95, 1.0, max(abs(vUv.x - 0.5), abs(vUv.y - 0.5)) * 2.0); gl_FragColor vec4(r, g, b, 1.0) * edgeFade; }3.3 自定义扫描线 噪点 Shader// shaders/ScanlineShader.glsl // 设计考量 // - 扫描线使用 sin 函数模拟 CRT 的隔行扫描 // - 扫描线频率以屏幕像素为单位scanlineCount / resolution.y // - 噪点使用随机种子 fract避免使用外部噪声纹理的额外采样 // - intensity 控制整体效果强度方便运行时动态调节 uniform sampler2D tDiffuse; uniform float intensity; uniform float time; uniform vec2 resolution; varying vec2 vUv; // 快速伪随机函数基于 GLSL 的 fract sin 经典模式 // 为什么不用 noise 纹理采样减少一次纹理绑定和采样操作在 Pass 链中每 Pass 少一次采样都很重要 float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); } void main() { vec4 color texture2D(tDiffuse, vUv); // 扫描线效果 // screenY 是从 0 到 screenHeight 的像素行号 float screenY vUv.y * resolution.y; // 每 3 个像素一组2 像素亮 1 像素暗 float scanline sin(screenY * 3.14159 * 2.0) * 0.5 0.5; // 暗线深度让扫描线更接近 CRT 的间隔黑线效果 scanline pow(scanline, 4.0); // 将原颜色与扫描线混合 color.rgb mix(color.rgb * 0.75, color.rgb, scanline); // 噪点效果 // 使用时间 UV 构造动态噪点种子 float noise random(vUv time * 0.1); // 噪点叠加乘性噪点比加性噪点更接近胶片颗粒的物理效果 color.rgb mix(color.rgb, color.rgb * (0.9 noise * 0.2), intensity * 0.5); // 边缘暗角Vignette // 不是严格的后处理 Pass但它是赛博朋克风格的标志性元素 float vignette 1.0 - length(vUv - 0.5) * 0.6; color.rgb * vignette; gl_FragColor color; }3.4 性能优化自适应降级// postprocessing/AdaptiveQuality.ts /** * 帧率自适应后处理降级策略 * 设计考量 * - 使用滑动窗口平均 FPS避免单帧抖动导致的频繁开关后处理 * - 降级优先级扫描线 色差 Bloom 半径 Bloom 分辨率 * - 扫描线是纯 Shader 叠加关闭可回收约 5% GPU 时间 * - Bloom 是 GPU 消耗大头先降半径再降分辨率 * - 恢复阈值比降级阈值高 10fps避免在阈值附近反复切换hysteresis */ export class AdaptiveQualityController { private _pipeline: CyberpunkPipeline; private _fpsHistory: number[] []; private _windowSize 30; // 30 帧滑动窗口 private _downgradeThreshold 30; // 低于 30fps 开始降级 private _upgradeThreshold 40; // 高于 40fps 开始恢复 private _qualityLevel 3; // 0-3: 最低到最高 private _checkInterval 60; // 每 60 帧检查一次减少每帧计算开销 private _frameCount 0; constructor(pipeline: CyberpunkPipeline) { this._pipeline pipeline; } update(deltaTime: number) { this._frameCount; const fps deltaTime 0 ? 1 / deltaTime : 60; this._fpsHistory.push(fps); if (this._fpsHistory.length this._windowSize) { this._fpsHistory.shift(); } if (this._frameCount % this._checkInterval ! 0) return; const avgFps this._fpsHistory.reduce((a, b) a b, 0) / this._fpsHistory.length; if (avgFps this._downgradeThreshold this._qualityLevel 0) { this._downgrade(); } else if (avgFps this._upgradeThreshold this._qualityLevel 3) { this._upgrade(); } } private _downgrade() { this._qualityLevel--; switch (this._qualityLevel) { case 2: // 关闭扫描线 this._pipeline.scanlinePass.enabled false; break; case 1: // 关闭色差 this._pipeline.chromaticPass.enabled false; break; case 0: // 降低 Bloom 强度 this._pipeline.bloomPass.strength 0.8; break; } } private _upgrade() { switch (this._qualityLevel) { case 0: this._pipeline.bloomPass.strength 1.5; break; case 1: this._pipeline.chromaticPass.enabled true; break; case 2: this._pipeline.scanlinePass.enabled true; break; } this._qualityLevel; } }四、边界分析WebGL 1.0 的兼容性陷阱。HalfFloatType 在 WebGL 1.0 上需要OES_texture_half_float扩展部分旧设备不支持。必须做能力检测并降级到 UnsignedByteType。降级后 Bloom 在高对比度场景中会出现色带Color Banding可以通过在 Bloom Composite 阶段添加少量噪点来掩盖。多个 Render Target 的显存压力。每个 Pass 都创建一个完整的 Render Target。一张 1920×1080 的 HalfFloat RT 约占用 16MB 显存6 个 RT 就接近 100MB。在 2GB 显存的移动设备上如果场景本身还有 PBR 纹理很容易触发显存溢出。需要复用 Render Target ——EffectComposer的renderTarget1和renderTarget2本身就是 ping-pong 复用机制。移动端 Shader 精度问题。mediump精度的 Fragment Shader 在计算扫描线的sin(screenY * 6.28)时如果screenY超过 1024精度就会不足导致扫描线在高分辨率屏幕上出现周期性的相位漂移。解决方案是在 Shader 开头声明precision highp float;这会增加约 5-10% 的 Shader 执行时间但对于 1080p 屏幕是必要的。不适用场景纯 2D UI 的 DApp扫描线和色差会降低文字可读性不适合交易确认页等需要精确阅读的场景需要颜色精确还原的数据可视化Bloom 和色差会改变色度值集成到 iframe 的钱包弹窗Render Target 显存压力在多层 iframe 的 WebGL 上下文中会加倍五、总结维度要点管线结构RenderPass → UnrealBloomPass → 自定义色差 ShaderPass → 自定义扫描线 ShaderPassBloom 优化HalfFloatType RT 1/2 分辨率降采样 GPU 加速高斯模糊色差实现基于距中心距离的非线性 RGB 偏移power(dist, 1.5) 映射更自然扫描线实现sin 像素行调制 fract 伪随机噪点 边缘暗角自适应降级帧率滑动窗口 分级降级策略 hysteresis 防抖移动端适配75% 渲染分辨率 WebGL 能力检测 highp 精度强制性能预期Bloom ≈ 40% GPU 时间色差 ≈ 5%扫描线 ≈ 5%场景渲染 ≈ 50%