从原理到代码,吃透 Three.js 后期渲染流水线与局部泛光核心逻辑

91 阅读13分钟

从原理到代码,吃透 Three.js 后期渲染流水线与局部泛光核心逻辑

写 Three.js 3D 场景时,你是不是也遇到过这种困境:想让场景里的“高光主角”(比如霓虹灯、魔法特效、角色光效)脱颖而出,结果一加泛光,整个画面都糊成一片,背景和无关物体也跟着“发光污染”?

其实问题很简单——你没搞懂「后期渲染流水线」的逻辑,也没找对「局部泛光」的正确打开方式。今天咱们抛开晦涩的官方文档,用“拍照修图”的通俗逻辑,从原理讲到可直接复用的代码,新手也能轻松吃透!

1. 核心概念:用“拍照修图”讲明白,一看就懂

1.1 什么是后期渲染?

咱们先举个生活化的例子:你用手机拍风景,直出的“生图”可能灰蒙蒙、对比度不够;但只要用修图APP(比如美图秀秀)加个滤镜、调个亮度、磨个皮,瞬间就能出片——这就是「后期处理」。

Three.js 里的逻辑完全一样:

  • 普通渲染 = 手机直出“生图”:只把3D场景里的物体、光影如实渲染出来,没有任何美化,甚至可能有点“粗糙”;

  • 「后期渲染」= 修图流水线:把“生图”交给一系列“工具”(滤镜、调色、泛光)依次处理,最后输出干净、有质感的画面。

简单说:后期渲染,就是给你的3D场景“开美颜、加特效”的关键一步。

1.2 局部辉光(Selective Bloom):只给“主角”打光,不打扰配角

全局泛光 = 给整张照片加“柔光滤镜”,不管是主角、配角还是背景,全都跟着变模糊发光;

而「局部辉光」,就是精准“定向美颜”——只让你指定的区域发光,其他区域保持原样

举个游戏里的常见场景:玩家操控的角色身上有“神圣光环”,我们希望这个光环发光、有氛围感,但周围的地面、墙壁、小怪,依然保持清晰、不发光。这就是局部辉光的核心作用。

看下面这个官方示例,就能直观感受到局部辉光的效果(只有选中的物体发光,背景和其他物体完全干净):

2. 实现思路:4步搞定局部辉光,像搭积木一样简单

很多人觉得后期渲染难,其实是被“流水线”“通道”这些词吓住了。咱们把局部辉光的实现思路,转化成“修图步骤”,每一步都清晰可见,看完就能记住!

2.1 后期渲染流水线(局部辉光专属版)

咱们还是用“拍照修图”类比,整个流水线就4步,一步都不能少(建议收藏,实现时直接对照):

[生图] → 第一步:给“非主角”涂黑,只留“发光主角”(拍一张“发光专属生图”,背景也关掉)
        → 第二步:给“发光专属生图”加柔光(高斯模糊),让光效晕染开,有朦胧感
        → 第三步:还原“非主角”和背景,拍一张正常的场景图
        → 第四步:把“发光柔光图”和“正常场景图”叠加(主角发光,配角清晰)
        → 最终出片(显示到屏幕上)

思路很简单,接下来就是找“工具”——Three.js 已经给我们封装好了现成的 API,不用自己从零写算法。这些 API 就像流水线里的“工作人员”,每个都有明确的分工,咱们只要把它们按顺序“安排好”就行。

2.2 后期渲染 API:给每个“工作人员”定分工(生动类比,记牢不混淆)

下面这5个 API,是实现局部辉光的“核心团队”,每个 API 都用“车间分工”类比,结合实际场景说明用法,再也不用死记硬背参数!

2.2.1 EffectComposer(后期合成器)—— 车间主任 / 总导演

  • 「角色定位」:整个后期流水线的“总指挥”,负责管理所有“工序”,并按顺序执行它们;

  • 「通俗理解」:就像修图APP的“批量处理”功能,你把要做的滤镜(工序)排好序,它就会依次执行,不用你手动一步一步点;

  • 「关键注意」:咱们的局部辉光需要 两个总指挥

    • 总指挥1:专门处理“发光专属生图”(负责第一步、第二步);

    • 总指挥2:专门处理“叠加合成”(负责第三步、第四步、最终出片)。

2.2.2 RenderPass(渲染通道)—— 摄影师

  • 「角色定位」:最基础、最不可或缺的“工作人员”,核心工作就是“拍照”;

  • 「通俗理解」:不管你后续要加多少滤镜、做多少修改,首先得有一张“原始照片”——这个“拍照”的动作,就是 RenderPass 做的;

  • 「使用场景」:几乎所有后期流水线的第一步,都是让它先拍一张“生图”(场景渲染图),没有它,后面所有工序都是“对着黑纸修图”。

2.2.3 UnrealBloomPass(虚幻泛光通道)—— 柔光滤镜师

  • 「角色定位」:局部辉光的“核心灵魂”,专门负责给“发光专属生图”加柔光、做晕染;

  • 「通俗理解」:就像修图时的“柔光滤镜”,只针对画面中“特别亮”的部分,让它向周围扩散,产生朦胧的光感——名字里的“Unreal”,就是因为它模仿了虚幻引擎的经典泛光效果,氛围感拉满;

  • 「核心参数(必记,直接用)」:

    • strength(强度):光晕有多亮(建议取值 0.1~2,太大会过曝);

    • radius(半径):光晕扩散多远(建议取值 0.1~1,太大光效会糊);

    • threshold(阈值):亮度超过多少才开始发光(0 = 有点亮就发光,1 = 只有极亮才发光,建议取值 0~0.5)。

  • 「使用场景」:专门处理“发光专属生图”,给主角的光效加“朦胧感”。

2.2.4 ShaderPass(着色器通道)—— 自定义修图师

  • 「角色定位」:“灵活选手”,当现成的滤镜(比如 UnrealBloomPass)满足不了需求时,就靠它自定义修图逻辑;

  • 「通俗理解」:就像修图时的“自定义调色”,你可以自己写规则(比如“只保留红色”“把两张图叠加”),实现个性化效果;

  • 「使用场景」:咱们的第四步——把“发光柔光图”和“正常场景图”叠加起来,现成的 API 做不到,就靠它写一段简单的着色器代码实现。

2.2.5 OutputPass(输出通道)—— 色彩校正员

  • 「角色定位」:流水线的“最后一道把关人”,负责“色彩校准”;

  • 「通俗理解」:你修完的照片,可能因为“色彩空间不匹配”,在电脑屏幕上看起来发灰、过曝(就像手机拍的 RAW 图,不调色就很难看);OutputPass 就负责把颜色转换成显示器能正确显示的格式,让最终画面更通透、更真实;

  • 「关键注意」:Three.js 新版必须加!不加的话,画面大概率会发灰,影响最终效果。

3. 代码实现:可直接复用,注释拉满(新手也能看懂)

思路和“工作人员”都介绍完了,接下来就是“组队开工”——把上面的 API 按流水线顺序组合起来,写一段可直接复用的 TypeScript 代码。

重点说明:代码已经封装成「SelectiveGlow 类」,你只要传入 renderer、scene、camera,就能快速启用局部辉光;还提供了 enableGlow / disableGlow 方法,一键控制某个物体是否发光,不用修改核心逻辑。

selective-glow.ts(完整可复用代码)

// 定义一个接口,用于约束有材质的3D物体(避免类型报错)
interface MaterialObject extends Object3D {
  material?: Material | Material[]
  isMesh?: boolean // 判断是否是网格物体(只有网格有材质,才能修改)
}

// 顶点着色器:传递UV坐标(用于采样纹理,不用改)
const finalVertexShader = /*glsl*/ `
  varying vec2 vUv; // 传递UV坐标给片元着色器
  void main() {
    vUv = uv;
    // 计算顶点最终位置(Three.js 固定写法)
    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
  }
`

// 片元着色器:核心逻辑——将正常场景图和发光柔光图叠加
const finalFragmentShader = /*glsl*/ `
  // 接收两个纹理:正常场景图(baseTexture)、发光柔光图(bloomTexture)
  uniform sampler2D baseTexture;
  uniform sampler2D bloomTexture;
  varying vec2 vUv; // 接收顶点着色器传递的UV坐标

  void main() {
    // 采样两张纹理的颜色
    vec4 baseColor = texture2D( baseTexture, vUv ); // 正常场景颜色
    vec4 bloomColor = texture2D( bloomTexture, vUv ); // 发光柔光颜色
    
    // 核心:叠加两张纹理(正常场景 + 发光效果,实现局部辉光)
    gl_FragColor = baseColor + bloomColor;
  }
`

// 定义辉光专属图层ID(用于区分“发光物体”和“非发光物体”)
const BLOOM_SCENE = 1

/**
 * 局部辉光封装类(可直接复用,一键启用/禁用某个物体的辉光)
 * @param renderer - Three.js 渲染器
 * @param scene - 3D场景
 * @param camera - 相机
 */
class SelectiveGlow {
  // 传入的核心参数(渲染器、场景、相机)
  private readonly renderer: WebGLRenderer
  private readonly scene: Scene
  private readonly camera: Camera

  // 两个“总指挥”(后期合成器)
  private readonly bloomComposer: EffectComposer // 负责处理“发光专属生图”和柔光
  private readonly finalComposer: EffectComposer // 负责叠加合成和最终出片

  // 辉光专属图层(只有加入这个图层的物体,才会被处理成发光效果)
  private readonly bloomLayer: Layers
  // 变黑材质(用于将“非发光物体”涂黑,拍“发光专属生图”时用)
  private readonly darkMaterial: MeshBasicMaterial
  // 存储原始材质(用于后续还原“非发光物体”,避免材质丢失)
  private readonly materials: Map<MaterialObject, Material | Material[]> = new Map()

  // 构造函数:初始化所有“工作人员”和流水线
  constructor(renderer: WebGLRenderer, scene: Scene, camera: Camera) {
    this.renderer = renderer
    this.scene = scene
    this.camera = camera

    // 1. 初始化辉光图层(用于标记发光物体)
    this.bloomLayer = new Layers()
    this.bloomLayer.set(BLOOM_SCENE) // 给辉光图层设置唯一ID

    // 2. 初始化“变黑材质”(非发光物体用这个材质,拍发光专属生图)
    this.darkMaterial = new MeshBasicMaterial({ 
      color: 'black', // 颜色设为黑色
      side: DoubleSide // 双面显示(避免物体背面漏光)
    })

    // 3. 获取渲染器尺寸(用于设置后期合成器的尺寸,和场景一致)
    const { width, height } = this.renderer.domElement

    // 4. 初始化“摄影师”(渲染通道)—— 拍原始场景生图
    const renderPass = new RenderPass(scene, camera)
    
    // 5. 初始化第一个总指挥:bloomComposer(处理发光专属生图)
    this.bloomComposer = new EffectComposer(this.renderer)
    this.bloomComposer.renderToScreen = false // 不直接显示(只作为中间产物)
    this.bloomComposer.addPass(renderPass) // 第一步:拍原始生图

    // 6. 初始化“柔光滤镜师”(UnrealBloomPass)—— 给发光生图加柔光
    const bloomPass = new UnrealBloomPass(
      new Vector2(width, height), // 渲染尺寸(和场景一致)
      1.5, // strength(强度,可调整)
      0.4, // radius(半径,可调整)
      0.85 // threshold(阈值,可调整)
    )
    // 调整辉光参数(根据自己的场景微调,新手直接用默认值)
    bloomPass.threshold = 0
    bloomPass.strength = 0.5
    bloomPass.radius = 0.5
    this.bloomComposer.addPass(bloomPass) // 第二步:给发光生图加柔光

    // 7. 初始化“自定义修图师”(ShaderPass)—— 叠加两张纹理
    const finalPass = new ShaderPass(
      new ShaderMaterial({
        uniforms: {
          // baseTexture:正常场景图(占位符,后续由finalComposer自动注入)
          baseTexture: { value: null },
          // bloomTexture:发光柔光图(来自bloomComposer的输出)
          bloomTexture: { value: this.bloomComposer.renderTarget2.texture },
        },
        vertexShader: finalVertexShader, // 顶点着色器(固定)
        fragmentShader: finalFragmentShader, // 片元着色器(叠加逻辑)
        defines: {},
      }),
      'baseTexture' // 告诉ShaderPass,将上一步的输出命名为baseTexture(对应着色器中的uniform)
    )
    finalPass.needsSwap = true // 启用纹理交换(确保叠加逻辑正常执行)

    // 8. 启用抗锯齿(MSAA)—— 让画面更细腻,避免锯齿感
    const renderTarget = new WebGLRenderTarget(width, height, {
      samples: 4, // 抗锯齿采样数(4倍足够,太高影响性能)
    })

    // 9. 初始化第二个总指挥:finalComposer(叠加合成 + 最终出片)
    this.finalComposer = new EffectComposer(this.renderer, renderTarget)
    this.finalComposer.addPass(renderPass) // 第三步:拍正常场景图
    this.finalComposer.addPass(finalPass) // 第四步:叠加正常图和发光图

    // 10. 初始化“色彩校正员”(OutputPass)—— 最后校准颜色,避免发灰
    const outputPass = new OutputPass()
    this.finalComposer.addPass(outputPass) // 第五步:最终出片,显示到屏幕
  }

  /**
   * 调整后期合成器尺寸(当窗口 resize 时调用,避免画面拉伸)
   * @param width - 新宽度
   * @param height - 新高度
   */
  setSize(width: number, height: number) {
    this.bloomComposer.setSize(width, height)
    this.finalComposer.setSize(width, height)
  }

  /**
   * 私有方法:将非辉光层的物体变黑(拍发光专属生图时调用)
   * @param obj - 3D物体
   */
  private readonly darkenNonBloomed = (obj: Object3D) => {
    const matObj = obj as MaterialObject
    // 只处理:网格物体 + 有材质 + 不在辉光图层中的物体
    if (matObj.isMesh && matObj.material && !this.bloomLayer.test(obj.layers)) {
      this.materials.set(matObj, matObj.material) // 保存原始材质(后续还原)
      matObj.material = this.darkMaterial // 替换成变黑材质
    }
  }

  /**
   * 私有方法:还原所有非辉光层物体的原始材质(拍完发光生图后调用)
   */
  private readonly restoreMaterial = () => {
    // 遍历保存的原始材质,逐一还原
    for (const [obj, material] of this.materials) {
      obj.material = material
    }
    this.materials.clear() // 清空缓存,避免内存泄漏
  }

  /**
   * 核心方法:执行后期渲染流水线(在动画循环中调用,替代原来的 renderer.render())
   */
  render() {
    // 1. 第一步:将非辉光层物体变黑,隐藏背景(拍发光专属生图)
    this.scene.traverse(this.darkenNonBloomed) // 遍历所有物体,处理非辉光物体
    const currentBackground = this.scene.background // 保存原始背景
    this.scene.background = new Color(0x000000) // 背景设为黑色(避免背景发光)

    // 2. 第二步:渲染发光专属生图,并加柔光(由bloomComposer处理)
    this.bloomComposer.render()

    // 3. 第三步:还原非辉光物体材质和原始背景(拍正常场景图)
    this.restoreMaterial()
    this.scene.background = currentBackground

    // 4. 第四步:叠加正常场景图和发光柔光图,最终出片(由finalComposer处理)
    this.finalComposer.render()
  }

  /**
   * 静态方法:启用某个物体的辉光效果(外部调用,一键启用)
   * @param object - 要启用辉光的3D物体
   */
  static enableGlow(object: Object3D) {
    object.layers.enable(BLOOM_SCENE) // 将物体加入辉光图层
  }

  /**
   * 静态方法:禁用某个物体的辉光效果(外部调用,一键禁用)
   * @param object - 要禁用辉光的3D物体
   */
  static disableGlow(object: Object3D) {
    object.layers.disable(BLOOM_SCENE) // 将物体移出辉光图层
  }
}

export default SelectiveGlow // 导出类,方便外部引入使用

代码使用说明(3步上手,新手必看)

封装好的类,使用起来非常简单,只要3步,就能给你的3D场景加上局部辉光:

  1. 「引入类」:import SelectiveGlow from './selective-glow';

  2. 「初始化」:在创建好 renderer、scene、camera 后,实例化类: // 初始化局部辉光 const selectiveGlow = new SelectiveGlow(renderer, scene, camera);

  3. 「启用辉光 + 动画循环」:给需要发光的物体启用辉光,并用 selectiveGlow.render() 替代原来的 renderer.render(): `// 给某个物体启用辉光(比如场景中的“灯光模型”) const glowObject = scene.getObjectByName('glow-model'); if (glowObject) { SelectiveGlow.enableGlow(glowObject); }

// 动画循环(替代原来的 renderer.render(scene, camera)) function animate() { requestAnimationFrame(animate);

// 执行后期渲染流水线(自动处理局部辉光) selectiveGlow.render(); }`

关键注意点(避坑必看)

  • 窗口 resize 时,一定要调用 selectiveGlow.setSize(width, height),否则画面会拉伸;

  • 只有「Mesh 网格物体」能启用辉光(因为只有网格有材质,Sprite 精灵也可以,可自行修改接口约束);

  • 辉光参数(strength、radius、threshold)可以根据自己的场景微调,建议从小值开始慢慢加,避免过曝;

  • 如果画面发灰、颜色不正常,检查是否加了 OutputPass(新版 Three.js 必须加)。

4. 核心逻辑复盘:记住这3个关键点,再也不会忘

看到这里,你已经掌握了 Three.js 局部泛光的核心玩法,最后复盘3个关键点,帮你巩固记忆,避免踩坑:

  1. 「流水线逻辑」:局部辉光的核心是“分两步拍图 + 叠加”——先拍“发光专属图”加柔光,再拍“正常场景图”,最后叠加;

  2. 「API 分工」:两个合成器(总指挥)+ 五个通道(工作人员),每个 API 各司其职,按顺序组合就能实现效果;

  3. 「代码复用」:封装好的 SelectiveGlow 类,可直接复用,重点记住 enableGlow / disableGlow 方法,一键控制物体辉光。

其实 Three.js 后期渲染并不难,只要把抽象的“流水线”转化为通俗的“修图步骤”,把 API 类比成“工作人员”,再结合可复用的代码,新手也能快速上手。

赶紧把代码复制到你的项目里,给你的3D场景加个局部辉光,让“主角”瞬间脱颖而出吧!如果遇到问题,可对照代码注释排查,也可以留言讨论~