19 基础二维 SDF 图形

前置知识:第 18 章的负内正外约定、距离填充与等距线。
本章示例:圆、矩形、圆角矩形和胶囊形,完整源码。

学完本章能做什么

你将能根据“点到最近边界”的几何关系,读懂圆、轴对齐矩形、圆角矩形与线段胶囊形的二维 SDF;能解释矩形在角外为何需要二维长度,以及胶囊形为什么要先找线段上的最近点。

先看现象

示例默认显示矩形的距离等值线。外侧靠近直边时等值线近似平行直线,绕过角点时变成四分之一圆。切换到圆角矩形,角从尖角变成弧;切换到胶囊形,长直段两侧是平行线,两端是半圆。无论切换哪种形状,填充和描边模式都只读取同一个 d,不需要为每种形状另写一套颜色判断。

示例仍在 3:2 的正对相机平面上绘制。片元坐标为 p=(vUv-0.5)*vec2(1.5,1.0),x/y 均以整块平面的高度为单位,原点在中心。网格只有 1 × 1 个分段;尺寸控件改变的是片元距离函数参数,不是顶点位置。

1. 先复用圆

// 片元阶段;p 和 r 属于同一二维平面度量。
float sdCircle( vec2 p, float r ) {
  return length( p ) - r;
}

圆的最近边界在从原点指向 p 的径向上,因而到中心距离减半径就得到有符号距离。r 要大于零;变成零时形状退化为一个点,负半径不再是本教程的“圆”。这个公式也为圆角和胶囊端点提供了直觉:离一个点固定距离的轨迹是圆弧。

2. 轴对齐矩形:先利用对称性

以原点为中心的矩形,半宽 b.x、半高 b.y。先把四个象限折叠到右上象限,再减去半尺寸:

// 片元阶段;b 为矩形半尺寸,两个分量均大于 0。
vec2 q = abs( p ) - b;

q.x > 0 表示点越过竖边;q.y > 0 表示点越过横边。若两者都大于零,点在角外,到最近角点的距离应是 length(q),不能只取 max(q.x,q.y)。若只有一个分量为正,另一分量钳到零后,长度正好是到直边的垂直距离。若两个分量都不大于零,点在矩形内部,最近边界距离由离得更近的那条边决定。

把这些情况合在一条公式里:

// 片元阶段;精确的轴对齐矩形 SDF。
float sdBox( vec2 p, vec2 b ) {
  vec2 q = abs( p ) - b;
  return length( max( q, 0.0 ) ) + min( max( q.x, q.y ), 0.0 );
}

第一项只负责外部:max(q,0) 把未越过的方向置零,角外保留两个正分量。第二项只负责内部:若两项都为负,max(q.x,q.y) 是较接近零的负数,也就是到最近边的负距离;一旦有正分量,min(...,0) 就变成零。

例如 b=(0.30,0.20):中心 p=(0,0) 的距离是 -0.20;右边界 p=(0.30,0) 是 0;角外 p=(0.40,0.30) 的距离是 √(0.10²+0.10²)≈0.1414。若错误地只用 max(q.x,q.y),角外会给出 0.10,其零边界虽然仍是矩形,却低估了真实距离。

3. 圆角矩形:从缩小的矩形向外扩张

设最终外包矩形半尺寸为 b,圆角半径为 r。先画一个半尺寸为 b-r 的核心矩形,再把它的边界向外扩 r:

// 片元阶段;0 <= r <= min(b.x,b.y)。
float sdRoundBox( vec2 p, vec2 b, float r ) {
  return sdBox( p, b - vec2( r ) ) - r;
}

在直边方向,这个减 r 会把核心矩形的边推回到外包尺寸 b;在角附近,核心角点周围的等距线是半径 r 的圆弧。若直接写 sdBox(p,b)-r,整个形状外包尺寸会增大 r,与控件标注的半尺寸不符。示例将 r 限制为不超过 min(b.x,b.y);超过它后 b-r 的某个分量变成负数,核心矩形的含义失效。

例如 b=(0.36,0.22)、r=0.08,核心半尺寸是 (0.28,0.14)。最右端仍在 x=0.36,上边仍在 y=0.22,只有角被圆弧替换。r=0 时退回普通矩形。

4. 胶囊形:到线段的距离减半径

水平胶囊的中轴是一条从 a 到 b 的线段。对任意点 p,先投影到直线,再把投影参数限制在 [0,1],得到线段上的最近点:

// 片元阶段;a 与 b 是不同的二维端点,r > 0。
float sdCapsule( vec2 p, vec2 a, vec2 b, float r ) {
  vec2 segment = b - a;
  float t = clamp( dot( p - a, segment ) /
                   dot( segment, segment ), 0.0, 1.0 );
  vec2 closest = a + t * segment;
  return length( p - closest ) - r;
}

当投影落在线段中间,最近点跟随 p 横向移动,边界是两条平行直线;投影落在端点之外时,t 被夹到 0 或 1,边界变成端点周围的半圆。示例设 a=(-uHalfSize.x,0)、b=(uHalfSize.x,0),再以 uRadius 向四周扩张。于是胶囊总宽度是 2*uHalfSize.x + 2*uRadius,总高度是 2*uRadius;“半长度”指中心线段的一半,不包含端部半圆。若 a=b,分母为零;当前控件保证 uHalfSize.x >= 0.08,实际项目应单独处理退化线段,使它成为圆。

5. 用同一距离显示函数检查形状

示例先按形状选择一个 d,然后共用后面的填充和描边:

// 片元阶段;d 已由选中的二维 SDF 返回。
float aa = max( fwidth( d ), 0.0001 );
float fill = 1.0 - smoothstep( -aa, aa, d );
float outline = 1.0 - smoothstep( -aa, aa, abs( d ) - 0.0125 );

填充以零边界为中心做像素宽度过渡;描线的完整宽度约 0.025 个平面高度单位,并在边界两侧对称。等距线每隔 0.08 个单位画一圈。它们是显示映射,不改变形状函数。对圆角矩形,观察外侧角等距线能检查弧线是否合理;对胶囊,观察端点外侧的等距线能确认投影确实被限制在线段上。

这里各形状函数在给定参数约束下返回精确的二维欧氏有符号距离。fwidth 只估计屏幕上一个像素对应的距离变化;它使边缘更平滑,但不能让极细形状在任何缩放下都完全稳定。第 20 章将进一步处理多个 SDF 的变换与组合,并区分“零边界正确”和“距离数值仍精确”这两件事。

常见问题

本章小结

圆用中心距离减半径;矩形把角外的二维距离与内部的最近边距离合在一起;圆角矩形从缩小的核心矩形扩张;胶囊形从线段最近点向外扩张。四者最终都返回负内、零边界、正外的同一种距离,因此可以共享填充、描边与等距线代码。

本章练习