机械战警 Threejs实现

效果预览

机械战警(Robocop)中经典的 ED-209 机器人,完全由 SDF(有向距离场)建模并经由 Ray Marching 渲染。机器人从关闭的金属门后走出,双臂武器舱展开,在走廊中行走、转身并开火。场景包含完整的室内光照、动态阴影、枪口火焰和屏幕暗角效果。

👉点击查看《机械战警》完整源码与效果演示

Shader 实现原理

1. 整体思路 --- 纯代码建模的机器人

传统 3D 建模需要外部的 mesh 文件(顶点、法线、UV),但这个 shader 完全从零开始"雕刻"出 ED-209:

  1. SDF 基元库 --- 定义 box、cylinder、capsule、cone、prism 等基础形状的距离场函数
  2. 组合运算 --- 通过 min(并集)、max(交集)、max(a, -b)(差集)组合基元
  3. 刚体变换 --- 旋转、平移、对称(abs(p.x) 实现镜像)摆放各部件
  4. Ray Marching --- 从相机发射 ray,沿距离场逐步前进直到命中表面
  5. 光照计算 --- 法线、阴影、环境光遮蔽、镜面反射、雾效

整个机器人没有任何外部几何数据,所有形状都是数学函数实时计算的。

2. 旋转矩阵 --- 二维平面旋转

glsl 复制代码
mat2 rot(float a) {
    float c = cos(a),
          s = sin(a);
    return mat2(c, s, -s, c);
}

mat2(c, s, -s, c) 是标准的二维旋转矩阵,按列优先存储:

r 复制代码
| c   -s |
| s    c |

在 shader 中被大量使用,例如 p.xz *= rot(a)p 在 XZ 平面上旋转角度 a。ED-209 的腿部行走摆动、腰部扭转、手臂抬升、相机视角变换都依赖这个矩阵。

3. SDF 基元形状库

3.1 标准立方体 --- sdBox

glsl 复制代码
float sdBox(vec3 p, vec3 b) {
    vec3 q = abs(p) - b;
    return length(max(q, 0.)) + min(max(q.x, max(q.y, q.z)), 0.);
}

这是 IQ(Inigo Quilez)的经典实现:

  • abs(p) - b:把坐标原点移到 box 角落,负值表示在 box 内部
  • length(max(q, 0.)):外部点到 box 的欧氏距离
  • min(max(q.x, max(q.y, q.z)), 0.):内部点的修正(保证内部距离为负)

3.2 倒角立方体 --- sdChamferedCube

glsl 复制代码
float sdChamferedCube(vec3 p, vec3 r, float c) {
    float cube = sdBox(p, r);
    p.xz *= rot(.78525);
    r.xz *= -c / 1.41 + 1.41;
    return max(cube, sdBox(p, r));
}

机器人的小腿(shin)使用倒角立方体:

  • 先计算标准 box 距离
  • XZ 平面旋转 45°(0.78525 ≈ π/4
  • 新 box 的 XZ 半径缩小,与原始 box 取交集(max
  • 交集产生八边形截面,即"倒角"效果

3.3 胶囊体 --- sdCapsule

glsl 复制代码
float sdCapsule(vec3 p, vec3 a, vec3 b, float r) {
    vec3 pa = p - a,
         ba = b - a;
    return length(pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0., 1.)) - r;
}

数学本质是点到线段的距离减去半径

  • dot(pa, ba) / dot(ba, ba) --- 投影参数 t,表示 paba 方向上的比例
  • clamp(t, 0., 1.) --- 限制在线段两端点之间
  • pa - ba * t --- 点到线段的最短向量
  • 最后减 r 得到胶囊体的有向距离

ED-209 的手臂(shoulder 到 forearm)用胶囊体连接,形成圆润的圆柱过渡。

3.4 八边形 --- sdOctogon

glsl 复制代码
float sdOctogon(vec2 p, float r) {
    vec3 k = vec3(-.92387953, .38268343, .41421356);
    p = abs(p);
    p -= 2. * min(dot(k.xy, p), 0.) * k.xy;
    p -= 2. * min(dot(vec2(-k.x, k.y), p), 0.) * vec2(-k.x, k.y);
    p -= vec2(clamp(p.x, -k.z * r, k.z * r), r);
    return length(p) * sign(p.y);
}

机器人背部墙壁上的装饰图案使用八边形 SDF。核心思想是多次镜像折叠

  • abs(p) --- 第一象限对称
  • dot(k.xy, p) --- 沿 22.5° 方向投影,把第一象限再折叠一次
  • 最终把八边形问题简化为点到一条直线的距离

4. ED-209 身体建模

4.1 头部面罩 --- headVisor

glsl 复制代码
float headSphere(vec3 p) {
    return (length(p / vec3(1, .8, 1)) - 1.) * .8;
}

MarchData headVisor(vec3 p, float h, float bump) {
    bump *= sin(p.x * 150.) * sin(p.y * 150.) * .002;
    MarchData result;
    result.d = sdBox(p, vec3(1, h, 2));
    result.d = max(mix(result.d, headSphere(p), .57), -p.y) - bump;
    result.mat = vec3(.05);
    result.specPower = 30.;
    return result;
}

面罩是 box 和椭球体的混合mix):

  • sdBox(p, vec3(1, h, 2)) --- 基础 box
  • headSphere(p) --- Y 轴压缩到 0.8 的椭球,再整体缩放 0.8
  • mix(..., ..., 0.57) --- 57% 偏向椭球,形成圆润的额头
  • max(..., -p.y) --- 与 -p.y 取交集,切掉下半部分
  • bump --- 表面微凹凸纹理,模拟面罩的磨砂质感

MarchData 是自定义结构体,同时存储距离 d、材质颜色 mat 和镜面反射强度 specPower。这种设计让不同部件可以携带各自的材质信息。

4.2 头部下半部分 --- headLower

glsl 复制代码
MarchData headLower(vec3 p) {
    vec3 op = p;
    // 镜像面罩生成下巴
    MarchData r = headVisor(p * vec3(.95, -1.4, .95), 1., 0.);
    // ...
}

下巴不是独立建模的,而是把面罩上下翻转(Y 轴取反并放大 1.4 倍),再与侧板、"翅膀"、嘴部格栅、脸颊切口组合。这展示了 SDF 建模的优势:复用已有部件,通过简单的坐标变换派生新形状。

嘴部格栅使用差集实现:

glsl 复制代码
r.d = max(r.d, -sdBox(p + vec3(0, 0, 1.5), vec3(...)));

max(a, -b) 是从 a 中挖去 b 的经典 SDF 差集操作。

4.3 枪荚 --- gunPod

glsl 复制代码
MarchData gunPod(vec3 p) {
    // 旋转弹仓
    r.d = sdCappedCone(pp, vec3(0), vec3(0, 0, -.1), .35 - .1, .35);
    r.d = min(r.d, sdCappedCylinder(p, .35, .4));

    // 三角凸起
    r.d = min(r.d, sdTriPrism(pp, vec2(.1, .5)));

    // 方形支架
    pp.xy *= rot(.78525);
    float d = sdBox(pp, vec3(.1 - bump, .38 - bump, .34)) - .02;

    // 枪管
    d = min(d, sdCappedCylinder(pp, .06, .15));
}

枪荚是多个基元的并集:

  • sdCappedCone + sdCappedCylinder --- 旋转弹仓(圆柱底部加圆台)
  • sdTriPrism --- 三角形装饰凸起
  • sdBox(旋转 45°)--- 八边形截面的支架
  • sdCappedCylinder --- 两根枪管

枪口火焰是条件动态生成的:

glsl 复制代码
if (fs > .5) {
    d = sdCappedCylinder(pp, .01 + pp.z * .05, fract(...) * .5 + .9);
    glow += .1 / (.01 + d * d * 4e2);
}

开火时(edShoot > 0),枪管前端生成一个随机长度的锥形火焰体,同时向 glow 变量累加能量。glow 是全局变量,最终叠加到画面亮度上。

4.4 腿部 --- legs

glsl 复制代码
MarchData legs(vec3 p) {
    p.yz *= rot(legAngle * sign(p.x));
    // ...
    silver = sdBox(p, vec3(.07, .05, 1.2));
    r.d = min(r.d, sdChamferedCube(cp.xzy, vec2(.28, .5).xyx, .18));
    r.d = min(r.d, foot(cp));
}

腿部建模的关键技巧:

  • sign(p.x) --- 利用 X 轴符号区分左右腿,同一套代码驱动两条腿
  • legWalkAngle(f) --- 正弦函数驱动的行走摆动,左右腿相位相反
  • sdChamferedCube --- 倒角立方体构成小腿装甲
  • foot(cp) --- 脚部复用 toe 函数,通过坐标变换生成双趾结构

大腿部分的银色金属条与主体材质不同:

glsl 复制代码
if (silver < r.d) {
    r.d = silver;
    r.mat = vec3(.8);
}

通过比较距离决定像素属于哪个材质,实现多材质混合

4.5 腰部 --- waist

glsl 复制代码
MarchData waist(vec3 p) {
    r.d = max(sdCappedCylinder(pp.zyx, .5, .5), p.y + .15);
    // 胸腔
    d = sdBox(p, vec3(bump, .15, bump));
    // 髋关节
    r.d = min(r.d, max(sdCappedCylinder(...), -pp.y));
}

腰部是连接上下半身的关键:

  • max(cylinder, p.y + 0.15) --- 圆柱与半空间的交集,切出腰部轮廓
  • bump = 0.5 - abs(sin(p.y * 40.)) * 0.03 --- 正弦波纹制造表面凹凸的装甲板效果
  • 髋关节用 max(cylinder, -p.y) 切出斜面,模拟机械关节的倾斜角度

4.6 整体组装 --- ed209

glsl 复制代码
MarchData ed209(vec3 p) {
    p.yz += vec2(legWalkAngle(2.) * .2 + .1, -edZ());
    MarchData r = legs(p);
    // ...
    gunsUp = smoothstep(0., 1., clamp((stretch - .66) * 6., 0., 1.));
    gunsForward = smoothstep(0., 1., clamp((stretch - .83) * 6., 0., 1.)) + fireShock() * .5;
    r = minResult(r, waist(p));
    p.yz *= rot(.1 * (-edDown + legWalkAngle(2.) + ...));
    p.xz *= rot(edTwist * .2);
    return minResult(minResult(minResult(r, headLower(p)), headVisor(p, .8, 1.)), arms(p));
}

组装顺序:

  1. 整体位移(行走高度变化 + Z 轴位置)
  2. 腿部(最底层)
  3. 腰部(承上启下)
  4. 上半身旋转(扭转 + 俯仰)
  5. 头部 + 手臂(最上层)

stretch 是动画参数(0~1),控制机器人的展开状态:

  • 0.66~0.83 --- gunsUp 从 0 到 1,手臂抬起
  • 0.83~1.0 --- gunsForward 从 0 到 1,枪管前伸

5. 房间场景

glsl 复制代码
MarchData room(vec3 p) {
    float doorHole = sdBox(p, frameInner + vec3(0, 0, 1));
    float backWall = length(p.z - 8.);
    r.d = min(backWall, max(length(p.z), -doorHole + .1));
    // ...
    d = min(doorFrame, max(door, -max(sdBox(...), -sdBox(...))));
}

房间由以下几部分组成:

  • 后墙 --- length(p.z - 8.) 是到平面 z = 8 的距离
  • 门框 --- box 的差集切出门洞
  • --- 可旋转打开的双扇门,旋转轴在门的一侧
  • 装饰图案 --- 八边形组合的 corporate logo

门的开启动画:

glsl 复制代码
p.xz *= rot(doorOpen * 2.1);

doorOpen 从 0 到 1,门绕 Y 轴旋转 2.1 弧度(约 120°),形成向外打开的效果。

6. 场景总距离场 --- map

glsl 复制代码
MarchData map(vec3 p) {
    MarchData r = minResult(room(p), ed209(p));
    float gnd = length(p.y + 3.);
    if (gnd < r.d) {
        r.d = gnd;
        r.mat = vec3(.1);
    }
    return r;
}

map(p) 是 Ray Marching 的核心,返回场景中任意点的最近距离和材质:

  • minResult(room(p), ed209(p)) --- 房间和机器人的并集
  • length(p.y + 3.) --- 地面(平面 y = -3 的距离)
  • 三者取最小值,构成完整场景

7. 光照系统

7.1 阴影 --- calcShadow

glsl 复制代码
float calcShadow(vec3 p, vec3 lightPos) {
    vec3 rd = normalize(lightPos - p);
    float res = 1., t = .1;
    for (float i = 0.; i < 30.; i++) {
        float h = map(p + rd * t).d;
        res = min(res, 12. * h / t);
        t += h;
        if (res < .001 || t > 25.) break;
    }
    return clamp(res, 0., 1.);
}

经典 IQ 软阴影算法:

  • 从表面点 p 向光源发射 ray
  • 每步记录 h / t(距离 / 已走路径),越小说明 ray 越接近遮挡物
  • res = min(res, 12. * h / t) --- 积累遮挡因子
  • 最终 res = 1 表示完全无遮挡,res = 0 表示完全在阴影中

7.2 法线 --- calcNormal

glsl 复制代码
vec3 calcNormal(vec3 p, float t) {
    float d = .01 * t * .33;
    vec2 e = vec2(1, -1) * .5773 * d;
    return normalize(e.xyy * map(p + e.xyy).d + ...);
}

利用距离场的梯度近似法线:

  • 在点 p 周围采样 4 个对称偏移点的距离值
  • 加权组合得到梯度方向
  • d = 0.01 * t * 0.33 --- 法线采样步长随距离增大,避免远处锯齿

7.3 环境光遮蔽 --- ao

glsl 复制代码
float ao(vec3 p, vec3 n, float h) {
    return clamp(map(p + h * n).d / h, 0., 1.);
}

极度简化的 AO:沿法线方向前进 h,检查距离场值。如果前方空间开阔(d ≈ h),返回 1(无遮蔽);如果前方很快有表面(d << h),返回接近 0(被遮蔽)。

7.4 综合光照 --- applyLighting

glsl 复制代码
vec3 applyLighting(vec3 p, vec3 rd, float d, MarchData data) {
    float primary = max(0., dot(sunDir, n));
    float bounce = max(0., dot(-sunDir, n)) * .3;
    float spe = pow(max(0., dot(rd, reflect(sunDir, n))), data.specPower) * 2.;
    float fre = smoothstep(.7, 1., 1. + dot(rd, n));
    float fog = exp(-length(p) * .05);

    primary *= mix(.2, 1., calcShadow(p, vec3(10, 10, -10)));
    return mix(data.mat * ((primary + bounce) * ao(p, n, .33) + spe)
               * vec3(2, 1.6, 1.7), vec3(.01), fre) * fog;
}

光照包含五个分量:

  • 主光源 --- 平行光(方向 sunDir),Lambert 漫反射
  • 阴影衰减 --- 主光源乘以阴影因子
  • 反弹光 --- 反向光源的 30%,模拟环境填充光
  • 镜面反射 --- Phong 模型,specPower 控制光泽度(身体 30,地面 1)
  • 菲涅尔 --- 边缘增亮,smoothstep 在视角接近切线时混入浅色
  • 雾效 --- exp(-length(p) * 0.05),指数衰减的景深雾

颜色乘数 vec3(2, 1.6, 1.7) 给光源添加暖色调(偏黄红)。


8. Ray Marching 主循环

glsl 复制代码
vec3 getSceneColor(vec3 ro, vec3 rd) {
    vec3 p;
    float d = .01;
    MarchData h;
    for (float steps = 0.; steps < 120.; steps++) {
        p = ro + rd * d;
        h = map(p);
        if (abs(h.d) < .0015 * d) break;
        if (d > 64.) return vec3(0);
        d += h.d;
    }
    return applyLighting(p, rd, d, h) + fireShock() * .3 + glow;
}

标准的 Sphere Tracing 算法:

  • d 是 ray 上已累积的距离
  • h.d 是当前点到最近表面的安全距离
  • d += h.d --- 向前迈一大步(不会穿过表面)
  • abs(h.d) < 0.0015 * d --- 自适应命中阈值,远处允许更大误差
  • 最多 120 步,超过 64 单位无命中返回黑色(天空/背景)

最终颜色 = 光照计算 + 开火闪光 + 枪口 glow


9. 相机路径与场景切换

glsl 复制代码
float time = mod(iTime, 55.);
if (time < 12.) {
    // 场景1:ED-209 从门后走出
    doorOpen = smoothstep(0., 1., time / 5.);
    stretch = remap(time, 7., 10., 0., 1.);
}
else if (time < 25.) {
    // 场景2:在走廊中行走
    edWalk = smoothstep(0., 1., remap(t, 3., 8., 0., 1.));
}
else if (time < 37.) {
    // 场景3:转身,武器收回
    stretch = remap(t, 2., 5., 1., 0.);
}
else if (time < 55.) {
    // 场景4:警戒模式,开火
    edTwist = remap(t, 3., 3.2, 0., 1.) * stretch;
    edDown = remap(t, 3.2, 3.4, 0., 1.) * stretch;
    edShoot = t <= 9.5 ? remap(t, 4., 9.5, 0., 1.) : 0.;
}

总时长 55 秒,分为 4 个场景:

时间 场景 动作
0-12s 出场 门打开,ED-209 从收起状态展开
12-25s 行走 沿走廊行走,相机跟随
25-29s 停顿 短暂站立
29-37s 转身 转身,武器收回
37-55s 警戒 武器展开,扫视,开火

remap(t, a, b, c, d) 把时间 t 从区间 [a, b] 线性映射到 [c, d],实现动画的精确时间控制。

相机位置 ro 和朝向 lookAt 每个场景独立设置,形成剪辑式镜头语言:

  • 出场:低角度仰视
  • 行走:侧面跟拍
  • 转身:正面近景
  • 开火:斜后方,能看到枪口火焰

10. 暗角与输出

glsl 复制代码
vec3 vignette(vec3 col, vec2 fragCoord) {
    vec2 q = fragCoord.xy / iResolution.xy;
    col *= .5 + .5 * pow(16. * q.x * q.y * (1. - q.x) * (1. - q.y), .4);
    return col;
}

暗角(vignette)模拟真实镜头的亮度衰减:

  • q.x * q.y * (1 - q.x) * (1 - q.y) --- 四边形对称函数,中心最大、四角为零
  • 16 * ... --- 归一化到最大值 1
  • pow(..., 0.4) --- 压低暗角强度,保留更多细节
  • 0.5 + 0.5 * ... --- 映射到 [0.5, 1],避免完全黑角

最终输出经过 Gamma 校正:

glsl 复制代码
gl_FragColor = vec4(vignette(pow(col * dim, vec3(.4545)), gl_FragCoord.xy), 1);

pow(x, 0.4545)pow(x, 1/2.2),把线性颜色空间转换到 sRGB。

dim 是场景切换时的淡入淡出:

glsl 复制代码
dim = 1. - cos(min(1., 2. * min(abs(time - startScene), abs(time - endScene))) * 1.5705);

利用 cos 在 0 附近的平滑特性,在每个场景的头尾各 0.5 秒内做淡入淡出。


总结

这个 shader 展示了程序化 SDF 建模的极致:

  • 无外部数据 --- 从 0 开始用数学函数"捏"出一个复杂机器人,所有部件都是代码
  • 组合优于雕刻 --- box、cylinder、capsule 等基元通过 min/max 组合,差集挖孔,混合做倒角
  • 动画即参数 --- 行走、转身、开火、开门全部通过时间变量驱动 SDF 的旋转和平移
  • 光照即物理 --- 阴影、AO、镜面、菲涅尔、雾效层层叠加,让纯数学模型有真实质感
相关推荐
贵州数擎科技有限公司3 小时前
霓虹沙尘暴的 Three.js 实现
前端·webgl
GISer_Jing8 小时前
深入解析 Three.js:从架构设计到 WebGPU 渲染革命
javascript·信息可视化·webgl
贵州数擎科技有限公司2 天前
曼德勃罗集的 Three.js 实现
webgl·three.js
大松鼠君2 天前
GLSL 动画动作万能规律表
webgl·three.js
小飞侠是个胖子2 天前
底层博弈:在高阶 WebGL 开发中平衡视觉极限与渲染性能
webgl
郝学胜-神的一滴2 天前
中级OpenGL教程 006:高光反射原理与 Shader 实现
c++·unity·godot·图形渲染·three.js·opengl·unreal
李剑一3 天前
520了,程序员就得有点儿独特的浪漫
前端·three.js
贵州数擎科技有限公司3 天前
分形金字塔的 Ray Marching 实现
webgl·three.js
谢小飞3 天前
Three.js三球轮播沉浸式落地页开发
前端·three.js