这个 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 有三种方式:

  1. 填入 URL:在 GUI 的 SVG URL 输入框粘贴链接,点击 Load from URL
  2. 上传本地文件:点击 Upload Custom SVG 选择本地 .svg 文件
  3. 修改默认值:把代码顶部的 INITIAL_SVG_URL 改成你的 SVG 地址,刷新即加载

写在最后

onBeforeCompile 是 Three.js 里被严重低估的能力。它的核心价值在于:你不需要理解整套 PBR 管线,只需要找到一个锚点,插入你关心的那几行 GLSL

这个液态金属案例展示了它能做到什么程度——从法线扰动到虹彩驱动,全部通过字符串注入完成,底层的光照、阴影、环境光遮蔽都完整保留。

如果你做过 Three.js 项目,遇到"想要某种效果但又不想从头写 ShaderMaterial"的时候,这个钩子值得优先考虑。

完整代码见 CodePen:https://codepen.io/sabosugi/pen/yyabKEP (opens in a new tab)