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

Shader 实现原理
1. 整体思路 --- 纯代码建模的机器人
传统 3D 建模需要外部的 mesh 文件(顶点、法线、UV),但这个 shader 完全从零开始"雕刻"出 ED-209:
- SDF 基元库 --- 定义 box、cylinder、capsule、cone、prism 等基础形状的距离场函数
- 组合运算 --- 通过
min(并集)、max(交集)、max(a, -b)(差集)组合基元 - 刚体变换 --- 旋转、平移、对称(
abs(p.x)实现镜像)摆放各部件 - Ray Marching --- 从相机发射 ray,沿距离场逐步前进直到命中表面
- 光照计算 --- 法线、阴影、环境光遮蔽、镜面反射、雾效
整个机器人没有任何外部几何数据,所有形状都是数学函数实时计算的。
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,表示pa在ba方向上的比例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))--- 基础 boxheadSphere(p)--- Y 轴压缩到 0.8 的椭球,再整体缩放 0.8mix(..., ..., 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));
}
组装顺序:
- 整体位移(行走高度变化 + Z 轴位置)
- 腿部(最底层)
- 腰部(承上启下)
- 上半身旋转(扭转 + 俯仰)
- 头部 + 手臂(最上层)
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 * ...--- 归一化到最大值 1pow(..., 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、镜面、菲涅尔、雾效层层叠加,让纯数学模型有真实质感