SDF 与距离场渲染:从 Raymarching 到字体、阴影与碰撞

符号距离场(SDF)用「到最近表面的有符号距离」隐式地描述几何体,天然支持光滑布尔运算、无网格变形与解析求交。本文从 SDF 的定义与组合运算讲起,剖析 Raymarching 球体追踪求交、网格到 SDF 的烘焙算法与存储格式,并覆盖它在字体渲染、软阴影、全局光照与碰撞检测中的落地,最后给出性能与精度权衡。

用多边形网格描述一个球,需要几百上千个三角形;用符号距离场(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 < EPSILONEPSILON 随距离缩放(远处放宽)
超距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 距离场生成算法

主流算法:

  1. 暴力法:对每个体素,遍历所有三角形求最小距离——O(N·M),慢但正确;
  2. 扫描线 / Jump Flooding:先用栅格化标记表面体素,再用 Jump Flooding 传播距离——GPU 友好;
  3. 窄带 + 稀疏:只计算表面附近的窄带(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 在受限环境下的取舍。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 移动端渲染与功耗优化:TBDR、带宽与热节流治理
  2. 集群前向渲染与光源管理:Forward+、Tiled 与 Clustered 光照
  3. 着色器编译与 SPIR-V 工具链:从源码到 GPU 指令的完整链路