霓虹沙尘暴特效 Three.js 实现

0 阅读11分钟

「开发领域」:前端开发 | AI 应用 | Web3D | 元宇宙
「技术栈」:JavaScript、React、ThreeJs、WebGL、Go
「经验经验」:8年+ 前端开发经验,专注于图形渲染和AI技术
「源码地址」shader.shuqin.cc
大家好!我是 [数擎Ai],一位热爱探索新技术的前端开发者,在这里分享Web3D和AI技术的干货与实战经验。如果你对技术有热情,欢迎关注我的文章,我们一起成长、进步!

效果预览

一个快乐、圆滚滚的黏土风格生物在草地上跳跃和四处张望。全部场景 —— 角色、地面、气泡、糖果 —— 都通过 Signed Distance Function(SDF)在 fragment shader 中实时建模和渲染,包含软阴影、环境光遮蔽、次表面散射等多光源照明系统。

Shader 实现原理

1. 整体思路:为什么用 SDF?

传统的角色动画需要建模、绑定、骨骼、蒙皮、烘焙等一系列流程。而 SDF 方法的核心思想是:「几何不是网格,而是数学函数」。每个物体都定义为一个距离场 —— 对于空间中的任意一点 p,函数返回该点到物体表面的最短距离。

这样做的好处是:

  • 「无限精度」:没有多边形数量限制,远近都一样光滑
  • 「天然支持布尔运算」:并、交、差都能通过数学操作实现
  • 「无缝变形」:两个物体之间的 morphing 就是距离场的插值
  • 「统一的渲染管线」:所有物体用同一种方式求交、求法线、着色

这个特效的完整场景(角色身体、头部、四肢、耳朵、眼睛、地面、气泡、糖果)全部在一个 map() 函数中定义,通过返回的 vec4 同时编码距离、材质 ID、辅助数据和遮罩。

2. SDF 基础图元

2.1 球体 —— 最简 SDF

float sdSphere(vec3 p, float s) {
  return length(p) - s;
}

几何意义:length(p) 是点到原点的距离,减去半径 s,得到**「有符号距离」**:

  • 内部:距离 < 0
  • 表面:距离 = 0
  • 外部:距离 > 0

2.2 椭球 —— 各向异性缩放

float sdEllipsoid(in vec3 p, in vec3 r) {
  float k0 = length(p / r);
  float k1 = length(p / (r * r));
  return k0 * (k0 - 1.0) / k1;
}

这是 Inigo Quilez 提出的**「近似椭球 SDF」**。精确椭球距离没有闭式解,但这个近似在视觉效果上足够好。

数学结构:

  • p / r 是**「按轴缩放」**:把椭球变换回单位球
  • k0 = length(p/r) 是变换后的径向距离
  • k1 = length(p/(r*r)) 是变换后的梯度幅值(用于修正距离)
  • 最终返回 k0 * (k0-1) / k1,在 k0 = 1(表面)处精确为 0

2.3 线段/胶囊 —— 肢体建模

vec2 sdStick(vec3 p, vec3 a, vec3 b, float r1, float r2) {
  vec3 pa = p - a, ba = b - a;
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return vec2(length(pa - ba * h) - mix(r1, r2, h*h*(3.0-2.0*h)), h);
}

这是手臂、腿、耳朵的建模基础:

  • ab 是线段两端点
  • h = clamp(dot(pa,ba)/dot(ba,ba), 0, 1) —— 投影参数,限制在 [0,1] 得到最近点
  • pa - ba*h 是点到线段的垂直向量
  • mix(r1, r2, h*h*(3.0-2.0*h)) —— 半径沿长度方向的**「平滑插值」**,h*h*(3-2h) 是 smoothstep 的等价形式(三次 Hermite 插值)

返回 vec2(distance, h),其中 h 后续用于控制 blend 的硬度。


3. 平滑布尔运算 —— Smooth Minimum

3.1 为什么要"平滑"?

普通的 min(a, b) 能实现并集,但在交界处会产生**「不连续的导数」**(尖锐的折痕)。生物的身体需要圆润过渡,因此需要 smin(smooth minimum)。

float smin(float a, float b, float k) {
  float h = max(k - abs(a - b), 0.0);
  return min(a, b) - h * h * 0.25 / k;
}

3.2 数学分析

a < b,令 d = b - a > 0

  • d > k(两物体相距较远):h = 0smin = a,即只保留较近的那个
  • d < k(两物体接近):h = k - d > 0,在 min(a,b) 基础上**「再减去一个正值」**,使结果比两者都小

这个"额外减去"的量形成一个**「圆润的过渡 valley」**。参数 k 控制 blend 的宽度:

  • k 越大,过渡区域越宽,结果越"黏糊"
  • k 越小,越接近硬 min

在本特效中,身体各部分使用 k = 0.1 左右,产生有机的黏土质感。

3.3 重载的 vec2 smin

vec2 smin(vec2 a, vec2 b, float k) {
  float h = clamp(0.5 + 0.5 * (b.x - a.x) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

这个版本除了 blend 距离(x 分量),还 blend 第二个分量(材质参数 z)。h 是 blend 权重,来源于距离差的 sigmoid 映射。


4. 角色建模 —— 从 primitive 到有机体

4.1 身体 —— 弹性形变

float sy = 0.5 + 0.5 * p;
float compress = 1.0 - smoothstep(0.0, 0.4, p);
sy = sy * (1.0 - compress) + compress;
float sz = 1.0 / sy;

身体不是静态椭球,而是随时间**「弹性压缩」**:

  • p = 4t(1-t) 是抛物线运动的高度因子(跳跃相位)
  • sy 是垂直缩放:落地时 p 最大,身体被压扁
  • compress 在落地瞬间(p > 0)产生额外的压缩
  • sz = 1/sy 保持体积近似守恒(压扁变宽)

4.2 身体朝向 —— 切线空间变换

vec2 uu = normalize(vec2(1.0, -pp));
vec2 vv = vec2(-uu.y, uu.x);
q.yz = vec2(dot(uu, q.yz), dot(vv, q.yz));

pp 是跳跃轨迹的导数(速度方向)。uu 是速度方向的单位向量,vv 是其正交向量。这组基向量把身体从世界空间变换到**「轨迹切线空间」**,让椭球长轴始终沿着运动方向,产生"顺着惯性变形"的效果。

4.3 头部旋转 —— 程序化注视

float hr = sin(0.791 * atime);
hr = 0.7 * sign(hr) * smoothstep(0.5, 0.7, abs(hr));
h.xz = mat2(cos(hr), sin(hr), -sin(hr), cos(hr)) * h.xz;

头部不是被动跟随身体,而是有独立的**「周期性转头」**动画:

  • sin(0.791 * atime) 产生约 7.9 秒的周期(2π/0.791 ≈ 7.94
  • sign(hr) * smoothstep(0.5, 0.7, abs(hr)) 把正弦波变成"快速转头-停顿"的方波近似
  • 0.7 是最大转角(约 40 度)

4.4 眨眼

float blink = pow(0.5 + 0.5 * sin(2.1 * iTime), 20.0);

0.5 + 0.5*sin 映射到 [0,1]pow(..., 20) 把大部分值压到接近 0,只在 sin 接近 1 时短暂跳升。这产生**「极短促的眨眼」**:大部分时间 blink = 0,偶尔瞬间变为 1。


5. 动画系统 —— 时间的分形

整个动画由多个**「不同频率的时间信号」**叠加而成:

时间变量计算方式周期作用
t1 = fract(atime)小数部分1s跳跃相位
t2 = fract(atime + 0.8)偏移小数1s手臂摆动
t3 = fract(atime + 0.9)偏移小数1s耳朵扇动
t4 = abs(fract(atime*0.5)-0.5)/0.5三角波2s左右移动
t6 = cos(6.2831*(atime*0.5+0.25))余弦2s腿部蹬踏

这些时间信号通过**「相位偏移」**(+0.8+0.9)错开,避免所有部位同步运动显得机械。


6. 光线行进(Raymarching)

6.1 核心算法

float t = tmin;
for (int i = 0; i < 256 && t < tmax; i++) {
  vec4 h = map(ro + rd * t, time);
  if (abs(h.x) < (0.0005 * t)) {
    res = vec4(t, h.yzw);
    break;
  }
  t += h.x;
}

这是 SDF 渲染的标准方法:

  1. 从相机沿射线方向前进
  2. 当前点的 SDF 值 h.x 是**「安全步长」**(不会穿过任何表面)
  3. 直接前进 h.x 的距离
  4. 当距离足够小(abs(h.x) < 0.0005*t)时认为击中表面

步长阈值 0.0005*t 是**「自适应的」**:远处物体允许更大的误差,近处需要更高精度。

6.2 边界平面优化

float tp = (3.4 - ro.y) / rd.y;
if (tp > 0.0) tmax = min(tmax, tp);

地面在 y = 3.4 以下,如果射线向下,可以提前知道它不可能穿过这个平面以上。这是**「解析几何对 raymarching 的加速」**。

7. 法线计算 —— 从距离场到表面方向

vec3 calcNormal(in vec3 pos, float time) {
  vec3 n = vec3(0.0);
  for (int i = ZERO; i < 4; i++) {
    vec3 e = 0.5773 * (2.0 * vec3((((i + 3) >> 1) & 1), ((i >> 1) & 1), (i & 1)) - 1.0);
    n += e * map(pos + 0.001 * e, time).x;
  }
  return normalize(n);
}

这是**「四面体差分法」**,只采样 4 个点(而不是 6 点的中心差分):

  • 4 个采样方向是 (1,1,1)(1,-1,-1)(-1,1,-1)(-1,-1,1) 的归一化版本
  • 0.5773 = 1/√3 是归一化因子
  • 系数 0.001 是差分步长,与场景尺度匹配

关键技巧:#define ZERO 0 配合 for(int i=ZERO; i<4; i++) —— 在原始 ShaderToy 代码中使用的是 min(iFrame,0),目的是让编译器无法在编译期确定循环次数,从而**「阻止循环展开和函数内联」**。因为 map() 非常昂贵,内联 4 次会显著增加代码体积和寄存器压力。


8. 光照模型 —— 五光源系统

8.1 光源构成

vec3 sun_lig = normalize(vec3(0.6, 0.35, 0.5));

场景使用 5 种光源的线性组合:

光源计算公式颜色/强度物理意义
太阳直射sun_dif * sun_col * sun_sha(8.1, 6.0, 4.2)主光源,暖色调
天光sky_dif * sky_col * occ(0.5, 0.7, 1.0)半球环境光,冷色调
地面反射bou_dif * bou_col * occ(0.2, 0.7, 0.1)绿色草地的 color bleeding
背光bac_dif * bac_col * occ(0.45, 0.35, 0.25)rim light,勾勒轮廓
次表面散射sss_dif * sss_col * occ(3.25, 2.75, 2.5)透光效果,最强光源

8.2 软阴影

float calcSoftshadow(in vec3 ro, in vec3 rd, float time) {
  float res = 1.0;
  float t = 0.02;
  for (int i = 0; i < 50; i++) {
    float h = map(ro + rd * t, time).x;
    res = min(res, mix(1.0, 16.0 * h / t, hsha));
    t += clamp(h, 0.05, 0.40);
    if (res < 0.005 || t > tmax) break;
  }
  return clamp(res, 0.0, 1.0);
}

软阴影的核心思想:「半影区域的宽度与遮挡物距离成正比」16.0 * h / t 是 Penumbra 近似:

  • h 是阴影射线到最近表面的距离(越大 = 越远离遮挡物)
  • t 是沿阴影射线前进的距离
  • h/t 是视角张角,近似半影宽度

hsha 是气泡的阴影硬度因子(sqrt(siz)),大气泡投射更柔和的阴影。

8.3 环境光遮蔽(AO)

float calcOcclusion(in vec3 pos, in vec3 nor, float time) {
  float occ = 0.0;
  float sca = 1.0;
  for (int i = ZERO; i < 5; i++) {
    float h = 0.01 + 0.11 * float(i) / 4.0;
    vec3 opos = pos + h * nor;
    float d = map(opos, time).x;
    occ += (h - d) * sca;
    sca *= 0.95;
  }
  return clamp(1.0 - 2.0 * occ, 0.0, 1.0);
}

AO 计算的是**「局部几何的自遮挡」**:

  • 沿法线方向采样 5 个点,步长从 0.01 逐渐增加到 0.12
  • h - d 是"应该有空间但实际被占据"的量(d < h 表示表面比预期更靠近)
  • sca *= 0.95 是距离衰减:越远的遮挡贡献越小
  • 最终 1.0 - 2.0*occ 映射到 [0,1] 的可见度

8.4 菲涅尔与镜面反射

float fre = clamp(1.0 + dot(nor, rd), 0.0, 1.0);
float sky_spe = ks * smoothstep(0.0, 0.5, ref.y) * (0.04 + 0.96 * pow(fre, 4.0));

fre = 1.0 + dot(nor, rd) 是**「菲涅尔项的简化」**:入射角越倾斜(视线越贴近切线),dot(nor,rd) 越接近 -1,fre 越接近 0,镜面反射越弱;垂直入射时 fre = 1.0,反射最强。pow(fre, 4.0) 让过渡更锐利。

0.04 + 0.96 * pow(fre, 4.0) 是**「Schlick 近似」**的简化版,模拟非金属的菲涅尔行为(垂直入射反射率约 4%)。


9. 材质系统 —— vec4.y 的分层编码

map() 返回的 vec4 结构:

  • x:SDF 距离(用于 raymarching)
  • y:材质 ID(用于着色分支)
  • z:辅助数据(身体 UV、糖果高度等)
  • w:遮罩/环境光遮蔽因子
if (res.y > 4.5)      // candy
else if (res.y > 3.5) // eyeball (纯黑)
else if (res.y > 2.5) // iris (深灰)
else if (res.y > 1.5) // body (棕色渐变)
else                   // terrain (绿色)

材质 ID 用**「阈值比较」**而非精确相等,这样可以通过平滑 blend 产生过渡材质。

身体颜色的细微变化:

col = mix(vec3(0.144, 0.09, 0.0036), vec3(0.36, 0.1, 0.04), res.z * res.z);
col = mix(col, vec3(0.14, 0.09, 0.06) * 2.0, (1.0 - res.z) * smoothstep(-0.15, 0.15, -href));
  • res.z * res.z 是**「身体 UV 的二次映射」**,产生从腹部(暗)到背部(亮)的渐变
  • smoothstep(-0.15, 0.15, -href) 根据高度产生**「腹部阴影」**

10. 后期处理

10.1 色彩分级

col = col * vec3(1.11, 0.89, 0.79);  // 色偏:偏暖
 col = 1.35 * col / (1.0 + col);       // 色调压缩:Reinhard 变体
 col = pow(col, vec3(0.4545));         // Gamma 校正:2.2 的倒数

三步构成完整的色调映射管线:

  1. 「乘法色偏」(1.11, 0.89, 0.79) 增强红色、压低绿色,整体偏暖
  2. 「Reinhard 压缩」c/(1+c) 把高动态范围压缩到 [0,1],保留暗部细节的同时压制过曝
  3. 「Gamma 校正」:从线性空间转换到 sRGB 显示空间

10.2 S-curve 对比度

tot = tot * tot * (3.0 - 2.0 * tot);

这是 smoothstep(0,1,x) 的数学展开:3x² - 2x³。它在中间调(x=0.5)附近提升对比度,同时保护高光和阴影不裁切。

10.3 暗角

vec2 q = gl_FragCoord.xy / iResolution.xy;
tot *= 0.5 + 0.5 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.25);

16.0 * q.x * q.y * (1-q.x) * (1-q.y) 在屏幕中心达到最大值 1(当 q=0.5),四角为 0。pow(..., 0.25) 减弱暗角强度,使其更自然。


11. 相机系统 —— 程序化运镜

float cl = sin(0.5 * time);
float an = 1.57 + 0.7 * sin(0.15 * time);
vec3 ta = vec3(0.0, 0.65, -0.6 + time * 1.0 - 0.4 * cl);
vec3 ro = ta + vec3(1.3 * cos(an), -0.250, 1.3 * sin(an));

相机不是固定位置,而是有**「多层叠加运动」**:

  • time * 1.0:相机随角色向前推进
  • sin(0.15*time):以约 42 秒为周期缓慢环绕角色
  • sin(0.5*time):叠加一个更快的横向摆动(约 12.6 秒周期)
  • ta.y += 0.15*...*smoothstep(0.4,0.9,cl):跳跃时相机微微上抬
ro += 0.06 * sin(time * 12.0 + vec3(0.0, 2.0, 4.0)) * smoothstep(0.85, 1.0, abs(bou));

落地瞬间加入**「高频抖动」**(12Hz),模拟脚步冲击地面的震动感。smoothstep(0.85, 1.0, abs(bou)) 只在脚触地时触发。

总结

整个场景在一个 fragment shader 中完成,输入只有时间和分辨率。角色不是"画"出来的,而是**「从数学空间中雕刻出来的」** —— 每一个椭圆、每一次 smin blend、每一道光,都是代码直接定义的几何与光学。