首页 / Three.js 入门教程 / 后期处理:给渲染结果加滤镜

Three.js 入门教程

后期处理:给渲染结果加滤镜

本教程共 40 篇 · 第 36 篇 · 更新于 2026-08-14 · 约 9 分钟阅读

Three.js后期处理EffectComposerUnrealBloomPassPostProcessingBloom

本节目标:理解后期处理的渲染管线,掌握 EffectComposer + RenderPass 的最小结构,会用 UnrealBloomPass 做泛光,认识常用 Pass 及参数。

前面所有章节,渲染器画完直接显示。这一章在”画完”和”显示”之间插一道工序:把画面当图片再加工一遍。泛光、景深、描边、胶片颗粒、故障特效,全是这么来的。这个工序叫后期处理(Post Processing)。 后期处理的经典应用:泛光、景深、色调映射、色差、暗角、胶片颗粒。游戏里”电影感”的画面,一大半来自后期而不是 3D 本身。

从渲染管线说起

后期处理的核心思路:不把场景直接画到屏幕,先画到一张离屏纹理上。

完整的管线是这样:

  1. 场景渲染到离屏纹理(render target),得到”原始画面”;
  2. 原始画面经过一个个处理单元(Pass)加工:模糊、调色、叠加效果;
  3. 前一个 Pass 的输出是后一个 Pass 的输入;
  4. 最后一个 Pass 把结果画到屏幕。

每个 Pass 本质上是一个全屏着色器:把上一张图当纹理采样,算完输出新图。所以后期处理的效果上限,取决于你写全屏着色器的能力——第 34 章的 GLSL 在这里直接派上用场。

EffectComposer:Pass 的管理者

three.js 用 EffectComposer 管理这条 Pass 链。它负责离屏纹理的创建、Pass 的调度、结果的输出。它是扩展模块,要从 addons 引入:

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

最小结构三步:

  1. new EffectComposer(renderer)——传入渲染器,内部自动建好离屏纹理;
  2. 依次 composer.addPass(...) 添加 Pass,顺序就是执行顺序;
  3. 渲染循环里调用 composer.render(),替代原来的 renderer.render(scene, camera)。 EffectComposer 内部维护两张离屏纹理:readBuffer 和 writeBuffer。每个 Pass 从 readBuffer 读上一张图,把结果写进 writeBuffer,然后两张交换,下一个 Pass 继续。这就是”流水线”的物理实现。Pass 链越长,图被搬运的次数越多,能用两个 Pass 解决的,别用三个。

完整示例:泛光(Bloom)

泛光是最有代表性的后期效果:亮的地方向四周溢光,霓虹灯、发光体立刻高级起来。three.js 用 UnrealBloomPass 实现。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>36 后期处理</title>
  <style>body { margin: 0; overflow: hidden; background: #000; }</style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
    }
  }
  </script>
</head>
<body>
  <script type="module">
    import * as THREE from 'three';
    import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
    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 { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100);
    camera.position.set(0, 2, 8);

    const renderer = new THREE.WebGLRenderer();
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.setSize(innerWidth, innerHeight);
    document.body.appendChild(renderer.domElement);

    const controls = new OrbitControls(camera, renderer.domElement);

    // 一圈自发光的小球
    const geometry = new THREE.SphereGeometry(0.4, 24, 24);
    for (let i = 0; i < 12; i++) {
      const hue = i / 12;
      const material = new THREE.MeshStandardMaterial({
        color: new THREE.Color().setHSL(hue, 0.8, 0.6),
        emissive: new THREE.Color().setHSL(hue, 0.8, 0.4),
        emissiveIntensity: 1.5
      });
      const mesh = new THREE.Mesh(geometry, material);
      const angle = (i / 12) * Math.PI * 2;
      mesh.position.set(Math.cos(angle) * 2.5, Math.sin(i * 1.7) * 1.2, Math.sin(angle) * 2.5);
      scene.add(mesh);
    }

    scene.add(new THREE.AmbientLight(0xffffff, 0.4));
    const light = new THREE.DirectionalLight(0xffffff, 2);
    light.position.set(3, 5, 4);
    scene.add(light);

    // 后期管线:渲染 → 泛光 → 输出
    const composer = new EffectComposer(renderer);
    composer.addPass(new RenderPass(scene, camera));

    const bloomPass = new UnrealBloomPass(
      new THREE.Vector2(innerWidth, innerHeight), // 分辨率
      1.2,  // strength 强度
      0.6,  // radius 半径
      0.6   // threshold 阈值
    );
    composer.addPass(bloomPass);
    composer.addPass(new OutputPass());

    window.addEventListener('resize', () => {
      camera.aspect = innerWidth / innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(innerWidth, innerHeight);
      composer.setSize(innerWidth, innerHeight); // 离屏纹理也要跟着变
    });

    renderer.setAnimationLoop(() => {
      controls.update();
      composer.render(); // 替代 renderer.render()
    });
  </script>
</body>
</html>

和普通渲染的区别集中在 composer 三行:

  1. new RenderPass(scene, camera)——第一个 Pass,把场景渲染进离屏纹理,必须最先添加;
  2. new UnrealBloomPass(resolution, strength, radius, threshold)——泛光;
  3. new OutputPass()——最后输出到屏幕,处理色彩空间和色调映射。

UnrealBloomPass 的三个参数

泛光效果好不好,全看三个参数:

  • strength:发光强度,越大光晕越亮越夸张,常用 0.5~1.5;
  • radius:光晕扩散半径,范围 0~1,越大越柔和;
  • threshold:亮度阈值,只有超过这个亮度的像素才发光。0 表示整个画面都发光,0.6 表示只有亮部发光。
Tip

threshold 是泛光的灵魂。场景里想突出霓虹灯,把阈值调到只有灯管亮度能超过;想让整体朦胧,阈值调低。小球示例用了 emissive 自发光,亮度超过阈值,所以只有小球发光,背景保持干净。
泛光的原理也顺带讲清:UnrealBloomPass 先把超过阈值的亮部抠出来,生成一组逐级缩小的 mip 纹理,每级做一次模糊,再按权重叠加回原图。大范围光晕在高分辨率上模糊很贵,它选择在小图上模糊再放大,这就是它又快又好的原因。

OutputPass 不能省

r152 起 three.js 默认开启色彩管理(第 5 章讲过),渲染结果要先从线性空间转换到 sRGB,还要应用渲染器的色调映射。这个转换原来由渲染器直接完成,但走 EffectComposer 后,画面经过了离屏纹理,最后的转换必须由 OutputPass 补上。

Note

忘记加 OutputPass 的典型症状:画面发灰、发暗,颜色不对。任何 EffectComposer 管线都以 OutputPass 结尾,这是 r185 的标准配置。

常用 Pass 一览

three.js 的 addons 里预置了几十个 Pass,都在 three/addons/postprocessing/ 下:

Pass效果备注
RenderPass渲染场景管线第一个
OutputPass输出到屏幕管线最后一个
UnrealBloomPass泛光最常用
ShaderPass套用任意着色器配合 shaders/ 下的现成 Shader
OutlinePass物体描边参数 edgeStrength 等
BokehPass景深虚化参数 focus、aperture
FXAAPass / SMAAPass抗锯齿后期管线里替代 antialias
GlitchPass故障抖动赛博朋克风
FilmPass胶片颗粒复古风
SSAOPass环境光遮蔽接触阴影
AfterimagePass残影拖尾运动感
HalftonePass半调网点打印风
LUTPass颜色查找表电影调色

OutlinePass 和 BokehPass 的参数值得记一下。OutlinePass 常用 edgeStrength 描边强度、edgeGlow 发光、edgeThickness 粗细,还有 visibleEdgeColor 可见边颜色;描边对象要收集进它的 selectedObjects 数组。BokehPass 用 focus 对焦距离和 aperture 光圈大小控制虚化,模拟真实镜头。 ShaderPass 值得单独说:它把一个现成 Shader 包成 Pass,是最灵活的通用件。比如 RGBShiftShader(色彩偏移)、DotScreenShader(网点)、SobelOperatorShader(边缘检测)。第 33、34 章学的 GLSL 可以直接写进自定义 Shader,再交给 ShaderPass:

import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { RGBShiftShader } from 'three/addons/shaders/RGBShiftShader.js';

const rgbShift = new ShaderPass(RGBShiftShader);
rgbShift.uniforms['amount'].value = 0.0015;
composer.addPass(rgbShift);

性能与踩坑

后期处理有代价:每个 Pass 都是一次全屏绘制。Pass 越多,像素被处理的次数越多:

  1. 移动端尽量少用 Pass,或调低内部分辨率;
  2. 初始化分辨率用 new THREE.Vector2(innerWidth, innerHeight),注意离屏纹理尺寸,太大会很卡;
  3. renderer.setSize() 之后必须 composer.setSize(),否则纹理尺寸跟不上窗口;
  4. 开了 EffectComposer,渲染器的 antialias 对中间纹理不生效,需要抗锯齿就用 FXAAPass 或 SMAAPass。
  5. 高分屏上 devicePixelRatio 会让离屏纹理尺寸暴涨,移动端尤其明显。composer 会按渲染器的像素比创建纹理,性能吃紧时把 pixelRatio 限制在 2 以内。
Tip

排查后期问题先做减法:只留 RenderPass + OutputPass,画面正常说明管线没问题,再逐个加 Pass 二分定位。这招比盯着几十行 shader 报错快得多。

另一条路线:TSL 后期

第 35 章讲过 TSL。r185 里后期处理有了新写法:pass(scene, camera) 返回一个 Pass 节点,直接和 TSL 节点组合:

import { pass, grayscale } from 'three/tsl';

const scenePass = pass(scene, camera);
const output = grayscale(scenePass); // 转灰度

这段代码用 WebGLRenderer 或 WebGPURenderer 都能跑。EffectComposer 是成熟稳定的老路线,示例多、资料多;TSL 是新方向,代码更简洁。入门推荐先用 EffectComposer 理解管线概念,TSL 后期等熟悉节点体系后再玩。