❝
「开发领域」:前端开发 | 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);
}
这是手臂、腿、耳朵的建模基础:
a、b是线段两端点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 = 0,smin = 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 渲染的标准方法:
- 从相机沿射线方向前进
- 当前点的 SDF 值
h.x是**「安全步长」**(不会穿过任何表面) - 直接前进
h.x的距离 - 当距离足够小(
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.11, 0.89, 0.79)增强红色、压低绿色,整体偏暖 - 「Reinhard 压缩」:
c/(1+c)把高动态范围压缩到[0,1],保留暗部细节的同时压制过曝 - 「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、每一道光,都是代码直接定义的几何与光学。