用多边形网格描述一个球,需要几百上千个三角形;用符号距离场(Signed Distance Field, SDF)描述同一个球,只需要一个函数:length(p) - r。这个函数告诉你「点 p 到球面有多远」,负值在内部、正值在外部、零值恰好在表面。这种隐式几何的表达方式带来了三个网格难以企及的能力:光滑的布尔运算(并集、交集、圆角过渡都是一行代码)、任意精度下的无限细节(不依赖三角形密度)、以及解析式的求交(Raymarching 而非光线-三角形测试)。
SDF 早已渗透到实时渲染的各个角落:UI 字体用它做抗锯齿,游戏用它做软阴影与 AO,SDFGI 用它做全局光照,物理引擎用它做碰撞查询。本文系统梳理 SDF 的定义、构造、渲染与应用。
一、什么是符号距离场
一句话:SDF 是一个把空间点映射到「到最近表面距离」的函数,符号表示内外,等值面(零集)就是物体表面。
1.1 定义与直观理解
对二维空间中的圆(圆心 c、半径 r):
float sdCircle(vec2 p, vec2 c, float r) {
return length(p - c) - r; // <0 内部, =0 表面, >0 外部
}
这个函数就是圆的 SDF。它有几个好性质:
- 梯度模长为 1:距离场是「单位梯度的」,这保证了 Raymarching 步进的安全性(下一步至少能走这么远而不穿透表面);
- 可微:梯度方向就是表面法线方向,
normalize(gradient(sdf))即法线; - 可组合:多个 SDF 可以像布尔运算一样组合。
1.2 2D 与 3D SDF
| 维度 | 典型用途 | 求交方式 |
|---|---|---|
| 2D SDF | 字体、UI 图标、2D 效果 | 屏幕空间求交、圆角矩形 |
| 3D SDF | 体积建模、Raymarching 场景、碰撞 | 球体追踪(Sphere Tracing) |
1.3 与体素、隐式曲面的关系
SDF 是隐式曲面(Implicit Surface)的一个特例——用「距离」而非「密度」定义内外。相比体素(Voxel)只记录 0/1 占据状态,SDF 额外记录了「离表面多远」,这让它能做软边、圆角与插值,也是它能做软阴影与平滑混合的根本原因。
1.4 SDF 与网格的对比
| 维度 | 三角网格 | 符号距离场 |
|---|---|---|
| 表达 | 显式(顶点 + 面) | 隐式(距离函数) |
| 布尔运算 | 需要重建拓扑,复杂 | 一行 min/max |
| 圆角过渡 | 几乎无法优雅实现 | smin 一行 |
| 细节层次 | 依赖三角形密度 | 依赖采样精度 |
| 求交 | 光线-三角形测试 | 球体追踪 |
| 材质贴图 | 天然 UV | 需额外映射 |
| 显存 | 与三角形数相关 | 与体素分辨率相关 |
一句话总结:SDF 擅长程序化、有机、需要布尔与圆角的形状;网格擅长精确的硬表面与贴图。
二、SDF 的构造与组合运算
一句话:SDF 的强大在于「用算术表达几何」——并集取 min,交集取 max,差集取负再交,圆角过渡用平滑 min。
2.1 基本图元的解析 SDF
float sdSphere (vec3 p, float r) { return length(p) - r; }
float sdBox (vec3 p, vec3 b) { vec3 q = abs(p) - b; return length(max(q,0.0)) + min(max(q.x,max(q.y,q.z)),0.0); }
float sdTorus (vec3 p, vec2 t) { vec2 q = vec2(length(p.xz)-t.x, p.y); return length(q)-t.y; }
float sdPlane (vec3 p, vec3 n, float h) { return dot(p,n) - h; }
float sdCapsule (vec3 p, vec3 a, vec3 b, float r) {
vec3 pa = p-a, ba = b-a;
float h = clamp(dot(pa,ba)/dot(ba,ba), 0.0, 1.0);
return length(pa - ba*h) - r;
}
这些函数都是「到集合最近点的距离」,模长梯度为 1(或接近 1)。
2.2 布尔运算
| 运算 | 公式 | 效果 |
|---|---|---|
| 并集(Union) | min(d1, d2) | 两形状合并 |
| 交集(Intersection) | max(d1, d2) | 两形状重叠部分 |
| 差集(Subtraction) | max(d1, -d2) | 从 d1 挖去 d2 |
float opUnion(float d1, float d2) { return min(d1, d2); }
float opIntersection(float d1, float d2) { return max(d1, d2); }
float opSubtraction(float d1, float d2) { return max(d1, -d2); }
2.3 平滑混合(smin)
硬边的布尔运算会产生锐利接缝,用**平滑最小值(Smooth Minimum)**可以得到圆角过渡:
// 多项式 smin,k 控制过渡半径
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
smin 是 SDF 建模的灵魂——它让「两个球融合成一个花生」这种效果变得轻而易举,而这在网格上几乎无法优雅实现。
2.4 变换与重复
对空间坐标做逆变换即可实现物体变换与重复:
// 平移
float d = sdSphere(p - vec3(1.0, 0.0, 0.0), 0.5);
// 旋转(对 p 施加逆旋转)
float d = sdBox(rotateY(p, angle), vec3(1.0));
// 无限重复(对坐标取模)
float d = sdSphere(mod(p + 0.5, 2.0) - 0.5, 0.5);
// 有限重复
vec3 q = p - 2.0 * clamp(round(p / 2.0), -3.0, 3.0);
float d = sdSphere(q, 0.5);
坐标变换与 图形学数学基础 中的矩阵/四元数知识直接相关。
三、Raymarching 求交
一句话:球体追踪(Sphere Tracing)沿光线逐步前进,每步前进的距离等于当前位置的 SDF 值——因为「半径这么大范围内一定没有表面」。
3.1 球体追踪原理
给定光线起点 ro 与方向 rd,迭代:
float rayMarch(vec3 ro, vec3 rd, float maxDist) {
float t = 0.0;
for (int i = 0; i < MAX_STEPS; ++i) {
vec3 p = ro + rd * t;
float d = map(p); // 场景 SDF
if (d < EPSILON) return t; // 命中
t += d; // 安全前进
if (t > maxDist) break; // 未命中
}
return -1.0;
}
正确性依赖 SDF 的 Lipschitz 连续性:|map(a) - map(b)| <= |a - b|。只要满足(梯度模长 ≤ 1),每步前进 d 就不会穿透表面。若 SDF 被非均匀缩放破坏了这一性质,需要乘一个保守系数。
3.2 步进循环与终止条件
| 条件 | 阈值 | 说明 |
|---|---|---|
| 命中 | d < EPSILON | EPSILON 随距离缩放(远处放宽) |
| 超距 | t > maxDist | 超出相机远平面 |
| 步数上限 | i >= MAX_STEPS | 防死循环,通常 64~128 |
| 掠射角 | 光线近平行表面 | 步进极慢,需额外限制 |
3.3 法线与光照
法线用 SDF 的数值梯度(中心差分)求:
vec3 calcNormal(vec3 p) {
const vec2 e = vec2(0.001, 0.0);
return normalize(vec3(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx)));
}
拿到法线后,光照、反射、AO 都可以用标准模型计算。软阴影则是沿光线向光源做一次「阴影 Raymarching」,用最小距离估计遮挡程度——这是 SDF 相对阴影贴图的独特优势(无需阴影贴图即可得到软阴影)。
3.4 性能优化
Raymarching 是纯计算密集型,优化手段:
- 包围盒加速:先用 AABB 求出光线与场景包围盒的进出点,缩小
t范围; - 自适应 EPSILON:
EPSILON = max(baseEps, t * relEps),远处放宽; - 减少 map 调用:法线用 4 次 map,阴影再 1 次,每次渲染动辄十几次 map 调用,务必让 map 函数精简;
- 降低分辨率 + 上采样:Raymarching 对分辨率敏感,可半分辨率渲染再重建。
3.5 与光栅化的混合
纯 Raymarching 场景成本高,生产上更常见的是混合:常规几何用光栅化渲染,特殊效果(体积雾、软阴影、程序化装饰)用 SDF + Raymarching。两者共用一个深度缓冲,让 SDF 渲染的像素能与光栅化几何正确遮挡。这与光线追踪领域「光栅化为主、光追为辅」的混合策略同构。
四、网格到 SDF 的烘焙
一句话:解析 SDF 适合程序化建模,但真实资产是网格——需要把网格离线烘焙成三维距离场纹理,运行时采样。
4.1 为什么要烘焙
解析 SDF 无法表达任意网格(一个复杂角色模型没有解析式)。烘焙(Baking)把网格转成一张三维距离场纹理,让运行时能以 O(1) 采样获得「离表面多远」。
4.2 距离场生成算法
主流算法:
- 暴力法:对每个体素,遍历所有三角形求最小距离——O(N·M),慢但正确;
- 扫描线 / Jump Flooding:先用栅格化标记表面体素,再用 Jump Flooding 传播距离——GPU 友好;
- 窄带 + 稀疏:只计算表面附近的窄带(Narrow Band),远处用大格近似。
Jump Flooding 伪代码:
初始化:表面体素记 0,其余记 ∞
for step = size/2, size/4, ..., 1:
每个体素查看 3×3×3 邻居中「距离 ± step」的体素
更新自身到最近表面的估计
4.3 存储格式
| 格式 | 优点 | 缺点 |
|---|---|---|
| 稠密 3D 纹理 | 采样简单、硬件三线性插值 | 显存大(512³ × 16bit ≈ 256 MB) |
| 稀疏体素八叉树 | 显存省 | 采样复杂、需遍历 |
| 窄带 + 网格 | 兼顾 | 实现复杂 |
| 2D 距离场切片 | 兼容性好 | 深度精度有限 |
4.4 精度与分辨率
- 16 位半精度通常足够(距离范围有限);
- 分辨率 128³ 适合小物体,512³ 适合场景级;
- 多级距离场(Cascaded SDF) 用不同分辨率的层级覆盖不同距离,远处用低分辨率,兼顾精度与显存。
4.5 距离场的编码
若距离范围已知(如 ±8 个单位),可把距离归一化后编码进 8 位:
// 编码:把 [-range, range] 映射到 [0, 1]
float encode(float d, float range) { return clamp(d / (2.0 * range) + 0.5, 0.0, 1.0); }
// 解码
float decode(float e, float range) { return (e * 2.0 - 1.0) * range; }
8 位编码把显存降到 1/2,但精度只有 2*range/255,适合精度要求不高的软阴影与 AO。距离场采样通常配合三线性插值,因此编码误差会在插值中平滑,观感可接受。
五、SDF 的应用
一句话:SDF 的价值不止于渲染几何——字体抗锯齿、软阴影、全局光照、碰撞检测都能从「距离」这一信息中获益。
5.1 字体渲染
这是 SDF 最广为人知的应用。把字形烘焙成 2D SDF 纹理后:
- 任意缩放不糊(距离场可插值);
- 抗锯齿天然(对距离做 smoothstep);
- 描边、发光、阴影都从距离派生。
float d = texture(uSdfTex, uv).r; // 采样距离
float alpha = smoothstep(0.5 - w, 0.5 + w, d); // w 控制边缘宽度
5.2 软阴影与 AO
沿光线向光源步进,记录最小距离与行进距离之比,比值越小遮挡越强——一次 Raymarching 就得到软阴影。AO 同理,沿法线半球采样若干方向做距离估计。
5.3 全局光照与反射
SDFGI(如 Godot 的实现)用场景 SDF 做屏幕外的间接光与反射——SDF 允许快速判断「这个方向上有没有遮挡」,比光栅化的屏幕空间方案覆盖面更广。相关思路与 实时全局光照 中的探针、SSGI 方案互为补充。
5.4 碰撞检测与物理
SDF 是完美的碰撞查询结构:查询点到表面的距离与法线都是一次采样。软体、粒子与 SDF 的碰撞只需判断 sdf(p) < r,非常廉价。这与 光线追踪与混合渲染
中的 BVH 加速结构形成了有趣的对照——一个是显式加速结构,一个是隐式距离函数。
5.5 UI 图标与圆角
现代 UI 框架(Flutter、Jetpack Compose、SwiftUI)大量使用 SDF 做圆角、描边与阴影:一个圆角矩形只需要 sdRoundBox,任意半径修改只是改一个参数,无需重新生成几何。这种「分辨率无关(Resolution Independent)」的特性,正是矢量 UI 与 SDF 结合的价值所在。
六、性能与精度权衡
一句话:SDF 渲染的成本与「像素数 × 步数 × map 复杂度」成正比,精度与显存成正比——两头都要精打细算。
6.1 步进成本
一次 Raymarching 的开销可估算为:
总成本 ≈ 像素数 × 平均步数 × (map 函数指令数 + 法线采样 4 次 + 阴影采样 1 次)
例:1080p × 60 步 × 20 条指令 ≈ 2.5 G 次运算/帧
map 函数里每多一个 sin/length,成本都会线性放大。这也是为什么生产级的 SDF 场景都追求「用最少的图元表达形状」。
降低成本的另一条路是减少重复求值:一次像素可能需要求交、求法线、求阴影,每次都要调用 map。把 map 的结果缓存、或用解析法线代替数值法线,都能显著减少调用次数。
6.2 分支与占用率
Raymarching 的循环次数因像素而异(掠射角光线步数多),导致 SIMT 内的线程发散:一个 warp 里所有线程必须等最慢的那个。缓解手段:
- 设置较小的步数上限(如 64);
- 用保守的
t范围裁剪; - 对远处像素降低采样密度。
6.3 何时不该用 SDF
| 场景 | 建议 |
|---|---|
| 高精度硬表面模型 | 用网格 + 光栅化,SDF 精度不够 |
| 需要精确 UV 与材质贴图 | 网格更自然 |
| 大量重复的静态几何 | 网格 + 实例化更高效 |
| 程序化/有机形状 | SDF 的强项 |
| 需要软阴影/AO/圆角 | SDF 的强项 |
| 需要精确的硬边与倒角 | 网格 + 倒角工具链更可靠 |
一条实用的经验:如果一个效果需要「无限分辨率」或「光滑融合」,优先考虑 SDF;如果它需要「精确材质与拓扑」,优先考虑网格。很多现代引擎的做法是二者共存——网格负责主体,SDF 负责装饰、体积效果与软阴影。
6.4 调试 SDF 场景
SDF 的调试有独特技巧:
- 距离可视化:把
map(p)的返回值映射成灰度输出,能直观看到距离场的分布; - 步数热力图:输出每个像素的迭代次数,红色区域就是步进慢的「重灾区」;
- 法线可视化:
normal * 0.5 + 0.5输出,检查法线连续性; - 逐图元禁用:把某个图元从 map 中移除,二分定位是哪个图元拖慢了性能;
- 步数统计输出:把平均步数写入一个 1×1 的缓冲,在 HUD 上实时观察,比逐帧截屏更高效。
小结
SDF 的核心思想是用距离函数代替几何图元,由此获得可组合、可微、可插值三大优势。要点回顾:
- 构造:解析图元用一行函数,组合用 min/max/smin,变换用坐标逆变换;
- 渲染:球体追踪每步前进 SDF 值,法线用梯度,软阴影用二次步进;
- 烘焙:真实网格用离线距离场纹理,Jump Flooding 是 GPU 友好的生成算法;
- 应用:字体、软阴影、SDFGI、碰撞检测都能直接受益于「距离」信息。
落地建议:程序化形状用解析 SDF,真实资产用烘焙距离场,两者可混合使用。若要在 Web 端尝试,可参考 WASM + WebGPU 游戏 与 Godot 2D 光照着色器预算 中的实践,理解 SDF 在受限环境下的取舍。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。