用 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.CineonToneMapping
与outputColorSpace = 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)
-
定义溶解所需的 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 }, // 溶解边缘宽度 };
-
在材质
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); };
-
让 Tweakpane 滑块联动 uProgress
在上一步中,dissolveUniformData.uProgress
就是溶解阈值。当我们在面板中拖动滑块时,即可同步更新物体的溶解程度。 -
可选:添加溶解动画
如果想让溶解自己动起来,可以在渲染循环里让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?
-
bloomEffectComposer
- 第一个 Composer 专门用来生成 Bloom 效果。
- 它的核心逻辑是:
- 先用
RenderPass(scene, camera)
得到场景的原始渲染结果。 - 再通过
UnrealBloomPass
对渲染结果进行亮度过滤和模糊扩散,生成一张带有光晕的纹理。
- 先用
- 最终,bloomEffectComposer 不会直接渲染到屏幕,而是渲染到一个离屏的 RenderTarget(
bloomEffectComposer.renderTarget2.texture
)。这样就能在后面再做组合。
-
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/JatinChopra/emissive-dissolve-effect (opens in a new tab)