Three.js案例
用 Three.js + 自定义 Shader 实现球体溶解特效,并结合后期处理

用 Three.js + 自定义 Shader 实现球体溶解特效,并结合后期处理

在 Three.js 的世界里,自定义 Shader 可以让你突破内置材质的限制,打造更具视觉冲击力的 3D 效果。

本文将带你完成一个 “溶解” 特效的小实验:一个金属球体逐渐被噪声吞噬,并在边缘区域呈现高亮发光的效果。

先来看下效果

初始化场景

初始化 Three.js 场景,设置相机、渲染器、控制器。

// 导入 Three.js 相关依赖
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/Addons.js";
 
let scale = 1.0;
 
// 1. 获取 canvas 元素
const canvasElement = document.getElementById("canvas"); // 原变量名: cnvs
 
// 2. 创建场景
const scene = new THREE.Scene();
 
// 3. 设置相机参数并实例化相机
const camera = new THREE.PerspectiveCamera(75, canvasElement.clientWidth / canvasElement.clientHeight, 0.001, 100);
camera.position.set(0, 1, 14); // 适当拉远一点,能看到球体全貌
 
// 4. 初始化渲染器
const renderer = new THREE.WebGLRenderer({
  canvas: canvasElement,
  antialias: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvasElement.clientWidth * scale, canvasElement.clientHeight * scale, false);
// 设置色调映射和色彩空间
renderer.toneMapping = THREE.CineonToneMapping;
renderer.outputColorSpace = THREE.SRGBColorSpace;
 
// 5. 设置场景背景色为纯黑,后面会改成立方体贴图
const blackColor = new THREE.Color(0x000000);
scene.background = blackColor;
 
// 6. 添加 OrbitControls 控制器
const orbitControls = new OrbitControls(camera, canvasElement);
  • PerspectiveCamera 创建透视相机,其中 fov = 75 可以用来控制视角范围大小。
  • renderer.setPixelRatio(window.devicePixelRatio) 能让画面在高分辨率屏幕上更清晰。
  • toneMapping = THREE.CineonToneMappingoutputColorSpace = THREE.SRGBColorSpace 能让你的渲染效果更接近自然光照。
  • OrbitControls 可以让你在浏览器中拖动、缩放查看 3D 场景,非常适合调试和展示。

加载背景图(立方体贴图)

下面我们为场景添加 立方体环境贴图,让背景更具沉浸感,并为物体提供真实的环境反射。

你需要准备六张贴图,对应正/负 X、正/负 Y、正/负 Z 六个方向,也可以从网上找一些免费资源或自行制作。这里我们用如下的六张图:

引入我们的图片

// 定义一个函数,用来生成六张立方体纹理的路径
function generateCubeUrls(prefix, postfix) {
  return [
    prefix + "posx" + postfix,
    prefix + "negx" + postfix,
    prefix + "posy" + postfix,
    prefix + "negy" + postfix,
    prefix + "posz" + postfix,
    prefix + "negz" + postfix,
  ];
}
 
// 通过函数得到六张图片地址
let cubeTexturePaths = generateCubeUrls("./assets/", ".png");
 
let cubeTexture; // 用于存储加载好的立方体贴图
 
// 异步加载立方体贴图
async function loadTextures() {
  const cubeTextureLoader = new THREE.CubeTextureLoader();
  cubeTexture = await cubeTextureLoader.loadAsync(cubeTexturePaths);
 
  // 将场景的背景和环境都设置为 cubeTexture
  scene.background = cubeTexture;
  scene.environment = cubeTexture;
 
  // 假设你有一个“loading”状态
  document.body.classList.remove("loading");
}
 
loadTextures();
  • CubeTextureLoader 一次性加载六张贴图,对应上下、左右、前后这六个方向。
  • scene.background = cubeTexture 会把整个场景背景替换成一个立方体环境;scene.environment = cubeTexture 可以让场景里的物体在渲染时使用这个贴图进行光照、反射等计算。
  • 背景图的分辨率、贴图品质都直接影响最终呈现的逼真度。

完成后,场景将以立方体贴图作为背景,瞬间拥有更沉浸的环境氛围。

加载球体

创建一个球体几何,并且赋予 “金属质感” 的材质,为后续溶解做准备。

// 1. 创建球体几何
const sphere = new THREE.SphereGeometry(4.5, 140, 140);
let meshGeo = sphere;
 
// 2. 创建材质
const phyMat = new THREE.MeshPhysicalMaterial();
phyMat.color = new THREE.Color(0x636363); // 初始颜色为灰色
phyMat.metalness = 2.0; // 金属度(可以根据需求调试)
phyMat.roughness = 0.0; // 粗糙度(越低越光滑)
phyMat.side = THREE.DoubleSide; // 双面渲染
 
// 3. 组合几何体和材质,生成网格
let mesh = new THREE.Mesh(meshGeo, phyMat);
scene.add(mesh);
  • SphereGeometry(4.5, 140, 140) 中较高的分段数(140, 140) 能让球体渲染得更平滑。
  • MeshPhysicalMaterial 相比 MeshStandardMaterial 有更多物理属性参数可调。一般来说 metalness 值建议在 0~1 之间,但你也可以尝试让它超过 1,看看渲染器呈现的效果。
  • 将球体加入场景后,即可在浏览器中通过 OrbitControls 进行旋转查看。

引入 Tweakpane 控制面板

为了方便调试和展示,我们使用 Tweakpane 来创建一个简单 UI 面板,实时调节一些关键参数,例如溶解进度 dissolveProgress

import { Pane } from "tweakpane";
 
// 定义要在面板上调试的参数
let tweaks = {
  dissolveProgress: -7.0, // 对应后面溶解效果的进度
  // 可以继续拓展更多,例如 autoDissolve: false
};
 
// 创建 Tweakpane
const pane = new Pane();
 
// 绑定并监听事件
let progressBinding = pane
  .addBinding(tweaks, "dissolveProgress", {
    min: -20,
    max: 20,
    step: 0.0001,
    label: "Progress",
  })
  .on("change", (obj) => {
    // 当用户在面板里拖动滑块,这里会被调用
    // 这里把最新的数值赋值给溶解进度
    dissolveUniformData.uProgress.value = obj.value;
  });
  • Tweakpane 的用法和 dat.GUI 类似,先创建 Pane,然后调用 addBinding(对象, '属性名', {可选配置}) 来创建滑块、输入框、颜色选择器等。
  • 如果想让溶解自动进行,可以额外加一个布尔值 autoDissolve,并在动画循环里根据是否选中来更新 uProgress

实现溶解效果(自定义 Shader)

接下来是本文的核心——自定义 Shader 来实现球体溶解。借助噪声函数 snoise,我们将生成的噪声值与一个名为 uProgress 的变量对比,小于阈值的像素将被丢弃,形成“腐蚀”感;并在临近边缘的区域渲染指定颜色,为它增添“燃烧”或“魔法”既视感。

在开始之前,我们需要先引入一个 noise 函数,用来做噪声效果,代码比较复杂,你可以直接拿来使用即可。具体实现可参考这段代码: https://github.com/calmound/threejs-example/blob/main/src/demos/dissolve/noise/snoise.glsl (opens in a new tab)

  1. 定义溶解所需的 uniforms

    import snoise from "./noise/snoise.glsl?raw";
     
    const dissolveUniformData = {
      uEdgeColor: { value: new THREE.Color(0x4d9bff) }, // 边缘颜色
      uFreq: { value: 0.25 }, // 噪声频率
      uAmp: { value: 16.0 }, // 噪声振幅
      uProgress: { value: -7.0 }, // 溶解进度
      uEdge: { value: 0.8 }, // 溶解边缘宽度
    };
  2. 在材质 onBeforeCompile 中做“二次加工” Three.js 提供了 onBeforeCompile 钩子,用于在材质编译时,动态修改或插入自定义着色器逻辑。

    function setupUniforms(shader, uniforms) {
      const keys = Object.keys(uniforms);
      for (let i = 0; i < keys.length; i++) {
        const key = keys[i];
        shader.uniforms[key] = uniforms[key];
      }
    }
     
    function setupDissolveShader(shader) {
      // 顶点着色器:添加一个变量 vPos 用来传递顶点坐标到片段着色器
      shader.vertexShader = shader.vertexShader.replace(
        "#include <common>",
        `#include <common>
         varying vec3 vPos;`
      );
      shader.vertexShader = shader.vertexShader.replace(
        "#include <begin_vertex>",
        `#include <begin_vertex>
         vPos = position;`
      );
     
      // 片段着色器:插入定义好的噪声 snoise,以及自定义 uniform
      shader.fragmentShader = shader.fragmentShader.replace(
        "#include <common>",
        `#include <common>
         varying vec3 vPos;
     
         uniform float uFreq;
         uniform float uAmp;
         uniform float uProgress;
         uniform float uEdge;
         uniform vec3  uEdgeColor;
     
         ${snoise} // 这里注入我们的 snoise 函数
        `
      );
     
      // 在片段着色器中加入溶解逻辑
      shader.fragmentShader = shader.fragmentShader.replace(
        "#include <dithering_fragment>",
        `#include <dithering_fragment>
     
         float noise = snoise(vPos * uFreq) * uAmp;
     
         // 如果噪声值小于进度阈值,直接 discard(丢弃该像素)
         if (noise < uProgress) discard;
     
         // 计算边缘范围:uProgress + uEdge
         float edgeWidth = uProgress + uEdge;
     
         // 当噪声值处于 [uProgress, edgeWidth] 时,渲染成特定边缘颜色
         if (noise > uProgress && noise < edgeWidth) {
           gl_FragColor = vec4(uEdgeColor, noise);
         } else {
           // 其它区域保持原本颜色(这里承接了物理材质默认的颜色)
           gl_FragColor = vec4(gl_FragColor.xyz, 1.0);
         }
        `
      );
    }
     
    // 把这两个函数应用到材质上
    phyMat.onBeforeCompile = (shader) => {
      setupUniforms(shader, dissolveUniformData);
      setupDissolveShader(shader);
    };
  3. 让 Tweakpane 滑块联动 uProgress
    在上一步中,dissolveUniformData.uProgress 就是溶解阈值。当我们在面板中拖动滑块时,即可同步更新物体的溶解程度。

  4. 可选:添加溶解动画
    如果想让溶解自己动起来,可以在渲染循环里让 uProgress 不断增加或减少。例如:

    // 在 Tweakpane 上再加一个 autoDissolve 开关
    tweaks.autoDissolve = false;
     
    function animateDissolve() {
      if (!tweaks.autoDissolve) return;
     
      let progress = dissolveUniformData.uProgress;
      if (progress.value > 14) progress.value = -7.0; // 超过14后回到起始
      progress.value += 0.08;
     
      // 同步到面板
      progressBinding.controller.value.setRawValue(progress.value);
    }

后期:Bloom 合成

Three.js 中,如果希望增加科幻、梦幻或炫光的视觉效果,Bloom 是最常用的后期处理之一。

具体而言,Bloom 会对场景中较亮的部分进行泛光处理,让明亮区域看起来有类似“发光”或“炫彩”的感觉。

在 Three.js 里,为了方便组合各种后期处理特效,我们经常用到一个核心工具:

  • EffectComposer:用它来管理和串联多个后期处理通道(Pass)。

  • RenderPass:将 3D 场景渲染为一张“基础”纹理。

  • UnrealBloomPass:执行 Bloom 计算,把场景中超阈值或高亮的区域渲染成带有光晕的效果。

  • ShaderPass / OutputPass:可用于自定义合成、最终输出等。

在本示例中,我们先将原场景渲染成一张普通的图像(通过 RenderPass),再将它经过 UnrealBloomPass 得到“高亮泛光”部分,最后再通过一个合成 Shader 将这两张图像融合到一起,叠加出最后的画面。

两个 EffectComposer 的思路

  • bloomEffectComposer:先进行一次渲染并使用 UnrealBloomPass 生成 Bloom 纹理,存储在离屏的 renderTarget 中。

  • combinedEffectComposer:再通过一个自定义的合成 Shader,把原图和 Bloom 结果叠加,得到最终输出。 之所以要分成两个,是为了更灵活地控制组合方式,并能分别修改原图和 Bloom 图的效果。

创建第一个 Composer(Bloom 纹理生成)

// 这是第一个 Composer,用于生成泛光纹理
const bloomEffectComposer = new EffectComposer(renderer);
const baseRenderPass = new RenderPass(scene, camera);
 
// UnrealBloomPass 的核心参数可根据需求做调试
const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth * scale, window.innerHeight * scale),
  0.5, // bloom 强度
  0.25, // bloom 半径
  0.2 // bloom 阈值
);
 
// 将两个 Pass 添加到第一个 Composer 中
bloomEffectComposer.addPass(baseRenderPass);
bloomEffectComposer.addPass(bloomPass);
 
// 不直接渲染到屏幕,让它输出到内部的 render target
bloomEffectComposer.renderToScreen = false;

这里的 baseRenderPass 首先渲染出场景原图,接着 UnrealBloomPass 使用这张原图进行亮度提取和模糊,得到专门的 Bloom 纹理。

参数解释

const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth * scale, window.innerHeight * scale),
  0.5,  // strength
  0.25, // radius
  0.2   // threshold
);
  • strength:Bloom 强度,用来控制泛光的亮度权重,数值越大,越耀眼。
  • radius:Bloom 半径,控制泛光的模糊扩散范围。越大代表光晕蔓延得越远,某种程度上会让画面更柔和或更朦胧。
  • threshold:Bloom 阈值,只对高亮超过阈值的区域进行 Bloom。如果设置得较高,你需要非常明亮的像素才会触发泛光;如果过低,可能大部分画面都会被模糊包围,导致画面“发白”。

创建第二个 Composer(合成 Bloom 与原图)

const combinedEffectComposer = new EffectComposer(renderer);
 
// 再次使用 RenderPass,这次相当于得到“基准图”
const combineBaseRenderPass = new RenderPass(scene, camera);
 
// 创建一个自定义 Shader,用来叠加 bloomTexture
const combineShaderPass = new ShaderPass(
  new THREE.ShaderMaterial({
    uniforms: {
      baseTexture: { value: null },
      bloomTexture: {
        // 从第一个 Composer 里获取它的结果纹理
        value: bloomEffectComposer.renderTarget2.texture,
      },
      bloomStrength: { value: 8.0 },
    },
    vertexShader: `
      varying vec2 vUv;
      void main(){
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
      }
    `,
    fragmentShader: `
      uniform sampler2D baseTexture;
      uniform sampler2D bloomTexture;
      uniform float bloomStrength;
      varying vec2 vUv;
      void main(){
        // 取原图
        vec4 baseEffect = texture2D(baseTexture, vUv);
        // 取 Bloom 生成的图
        vec4 bloomEffect = texture2D(bloomTexture, vUv);
        // 简单把它们做加法叠加,并乘以 bloomStrength
        gl_FragColor = baseEffect + bloomEffect * bloomStrength;
      }
    `,
  })
);
 
const finalOutputPass = new OutputPass();
 
// 按序将 Pass 添加到第二个 Composer
combinedEffectComposer.addPass(combineBaseRenderPass);
combinedEffectComposer.addPass(combineShaderPass);
combinedEffectComposer.addPass(finalOutputPass);

核心逻辑:

  • 先做一次新的 RenderPass 获取当前帧的场景原图(有可能你想基于最新场景去合成)。
  • combineShaderPass 中,将此“基准图”与第一份 Bloom 纹理相加。
  • bloomStrength 让你可以在合成过程中进一步放大(或缩小) Bloom 效果,以此实现更夸张或更保守的视觉呈现。
  • 最后通过 OutputPass 输出到屏幕上。
为什么需要两个 EffectComposer?
  1. bloomEffectComposer

    • 第一个 Composer 专门用来生成 Bloom 效果。
    • 它的核心逻辑是:
      1. 先用 RenderPass(scene, camera) 得到场景的原始渲染结果。
      2. 再通过 UnrealBloomPass 对渲染结果进行亮度过滤和模糊扩散,生成一张带有光晕的纹理。
    • 最终,bloomEffectComposer 不会直接渲染到屏幕,而是渲染到一个离屏的 RenderTarget(bloomEffectComposer.renderTarget2.texture)。这样就能在后面再做组合。
  2. combinedEffectComposer

    • 第二个 Composer 用来合成 原图Bloom 图
    • 首先,它也会做一次基本的渲染(RenderPass)来得到原图;然后,它通过我们自定义的 ShaderPass 把原图和 Bloom 图做混合叠加,形成最终输出。
    • 之所以不在同一个 Composer 里直接合并,是为了在 Bloom Pass 之外,依然可以获得场景原始渲染的图像,并且可以灵活调节二者的叠加方式与强度。

在渲染循环中依次执行

渲染循环里需要分别渲染这两个 Composer。典型做法是:

function animate() {
  // ... 其他更新逻辑,例如 orbitControls.update() 等
 
  // 1. 先渲染 bloomEffectComposer,让它得到 Bloom 纹理
  bloomEffectComposer.render();
 
  // 2. 再用 combinedEffectComposer 合并“原图 + Bloom”并输出
  combinedEffectComposer.render();
 
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

为什么要先渲染 bloomEffectComposer
因为我们的自定义 combineShaderPass 要拿着第一个 Composer 输出的纹理(bloomEffectComposer.renderTarget2.texture)来进行叠加。如果顺序颠倒,第二个 Composer 没能拿到正确的 Bloom 结果,就无法完成正确的合成。

总结

UnrealBloomPass 能为你的场景增添华丽的炫光,尤其适用于科幻、魔法、霓虹城市等氛围的演绎。它的三个关键参数(strength、radius、threshold)能够共同决定最后光晕的强弱、范围和触发门槛。

此外,利用两个 EffectComposer 分阶段处理,让你在后期合成时可以有更大的自由度去做叠加或定制化效果。如果想进一步探索,还可以结合其它后期处理,或者借助多层渲染,实现仅对特定物体做 Bloom。

通过这部分的讲解,你应该能对 Bloom 的原理和在 Three.js 中的实现方法有更清晰的认识,并在未来的项目中更灵活地使用它来打造独特的视觉效果。

代码

https://github.com/calmound/threejs-example/blob/main/src/demos/dissolve/index.js (opens in a new tab)

参考

https://github.com/JatinChopra/emissive-dissolve-effect (opens in a new tab)