这个 Three.js 技巧很少人用:不改材质源码,给它注入自定义 Shader
自定义 Shader 效果,是不是一直觉得门槛很高?从头写 ShaderMaterial,自己处理光照、法线、PBR——大多数人看到 GLSL 就头疼。
其实 Three.js 提供了一个很少被提到的钩子:onBeforeCompile。它让你可以在 MeshPhysicalMaterial 编译成 GLSL 之前,直接往里注入自定义代码,不需要放弃内置的 PBR 光照系统,只改你想改的部分。
本文用这个技巧,实现一个把任意 SVG 渲染成实时流动液态金属的效果:法线随噪声扰动,表面有虹彩色变,边缘保持锐利。代码在 CodePen 上可以直接运行。

核心思路:不替换材质,只"插队"编译
Three.js 的内置材质(MeshPhysicalMaterial、MeshStandardMaterial 等)在被 WebGL 编译前,会触发 onBeforeCompile 回调,把 shader 对象暴露出来。这个对象包含:
shader.vertexShader:顶点着色器源码字符串shader.fragmentShader:片元着色器源码字符串shader.uniforms:可以直接追加自定义的 uniform 变量
也就是说,你可以用字符串替换(.replace())的方式,找到着色器里的特定锚点,插入自己的代码。完整的 PBR 光照、环境光、clearcoat 都原样保留,只有你改的部分生效。
第一步:配置虹彩材质基础
液态金属效果的基础是 MeshPhysicalMaterial 的虹彩(Iridescence)特性,这是 Three.js r149+ 新增的 PBR 参数,模拟肥皂泡、CD盘、油膜那种随角度变色的光学效果:
const liquidMaterial = new THREE.MeshPhysicalMaterial({
color: 0xeeeeee,
metalness: 0.587, // 金属感
roughness: 0.452, // 粗糙度
clearcoat: 0.071, // 清漆层(增加表面光泽)
iridescence: 0.907, // 虹彩强度(0-1)
iridescenceIOR: 1.0,// 虹彩折射率,影响颜色分布
iridescenceThicknessRange: [759, 800], // 膜厚范围(nm)
dithering: true // 关键:消除颜色渐变的色阶感
});dithering: true 很容易被忽略,但它是消除细腻渐变中色阶锯齿的关键,在虹彩效果里必须开启。
第二步:用 onBeforeCompile 注入 Simplex 噪声
静态的虹彩材质只是起点。真正让表面"流动"的,是在片元着色器里用 Simplex 3D 噪声 实时扰动法线方向。
法线决定了 PBR 计算中光线的反射角,改变法线就相当于改变了表面的微观凹凸——虹彩颜色、高光位置都会随之变化。
liquidMaterial.onBeforeCompile = (shader) => {
// 1. 注入自定义 uniform
shader.uniforms.uTime = liquidMaterial.userData.uTime; // 动画时间
shader.uniforms.uDistortion = liquidMaterial.userData.uDistortion; // 扰动强度
// 2. 在片元着色器法线计算的锚点处插入代码
shader.fragmentShader = shader.fragmentShader.replace(
'#include <normal_fragment_begin>',
`
#include <normal_fragment_begin>
// 用三维噪声计算梯度方向,作为法线扰动量
float eps = 0.03;
float n0 = snoise(warpedP);
float nx = snoise(warpedP + vec3(eps, 0.0, 0.0));
float ny = snoise(warpedP + vec3(0.0, eps, 0.0));
float nz = snoise(warpedP + vec3(0.0, 0.0, eps));
vec3 noiseNormal = normalize(vec3(nx - n0, ny - n0, nz - n0));
vec3 viewNoiseNormal = normalize((viewMatrix * vec4(noiseNormal, 0.0)).xyz);
// 叠加到原始法线上,edgeMask 保护边缘不被扰动
normal = normalize(normal + viewNoiseNormal * uDistortion * edgeMask);
`
);
};锚点 #include <normal_fragment_begin> 是 Three.js 内部法线计算的起点。在它执行完之后插入扰动,就能保留原始几何法线作为基础,再叠加噪声扰动。
第三步:Domain Warping 让流动更自然
直接用噪声驱动会显得比较机械。流动感更自然的做法是 Domain Warping(域扭曲)——先用一层噪声扭曲采样坐标,再用扭曲后的坐标采样第二层噪声:
// 第一层:计算扭曲向量
vec3 warp;
warp.x = snoise(p + vec3(0.0, 0.0, uTime * 0.1));
warp.y = snoise(p + vec3(114.5, 22.1, uTime * 0.1));
warp.z = snoise(p + vec3(233.2, 51.5, uTime * 0.1));
// 第二层:用扭曲后的坐标计算最终噪声
vec3 warpedP = p + warp * 1.5;
float n0 = snoise(warpedP); // 这一层驱动法线三个分量用不同的偏移量(114.5, 22.1 等)采样,确保 xyz 方向的扰动不相关,形成真实的三维流动感。
第四步:Shape Mask 让流动沿轮廓走
如果噪声完全随机,边缘会显得割裂。项目里有个精妙的设计:用 Canvas 生成一张距离场纹理(Shape Mask),让流动方向跟随 SVG 轮廓走。
function generateShapeMaskTexture(shapes) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1024;
const ctx = canvas.getContext('2d');
// 模糊绘制 SVG 轮廓,生成软边距离场
ctx.filter = 'blur(45px)';
ctx.fillStyle = 'white';
// ...绘制 SVG 路径...
// 上传到 Three.js 纹理
liquidMaterial.userData.uShapeMask.value = new THREE.CanvasTexture(canvas);
}在 Shader 里,读取这张纹理的梯度方向,作为流动的"等高线切线":
// 读取距离场梯度(smoothDist 是5次采样的均值,消除8-bit量化锯齿)
vec2 maskGrad = vec2(maskR - maskL, maskT - maskB) / (2.0 * texEps.x);
// 沿轮廓切线方向流动
vec2 contourTangent = vec2(-maskGrad.y, maskGrad.x);
p.xy += contourTangent * (uTime * uSpeed * 0.5);这样流动会顺着字母或 Logo 的边缘走,视觉上非常连贯。
[配图:Shape Mask 纹理示意图]
可调参数说明
项目内置了 GUI 控制面板,主要参数:
| 参数 | 作用 | 推荐范围 |
|---|---|---|
| Ripple Scale | 噪声频率,值越小纹理越大块 | 0.002~0.005 |
| Distortion | 法线扰动强度,越大流动越夸张 | 1.0~2.5 |
| Edge Sharpness | 边缘保护,1.0 时边缘完全不被扰动 | 0.8~1.0 |
| Iridescence Intensity | 虹彩颜色强度 | 0.8~1.0 |
| Thickness Min/Max | 控制虹彩颜色分布,范围越大颜色越丰富 | 500~1000 |
自定义你的 SVG
默认渲染的是苹果 Logo。换成自己的 SVG 有三种方式:
- 填入 URL:在 GUI 的
SVG URL输入框粘贴链接,点击Load from URL - 上传本地文件:点击
Upload Custom SVG选择本地.svg文件 - 修改默认值:把代码顶部的
INITIAL_SVG_URL改成你的 SVG 地址,刷新即加载
写在最后
onBeforeCompile 是 Three.js 里被严重低估的能力。它的核心价值在于:你不需要理解整套 PBR 管线,只需要找到一个锚点,插入你关心的那几行 GLSL。
这个液态金属案例展示了它能做到什么程度——从法线扰动到虹彩驱动,全部通过字符串注入完成,底层的光照、阴影、环境光遮蔽都完整保留。
如果你做过 Three.js 项目,遇到"想要某种效果但又不想从头写 ShaderMaterial"的时候,这个钩子值得优先考虑。
完整代码见 CodePen:https://codepen.io/sabosugi/pen/yyabKEP (opens in a new tab)