18 有符号距离函数的直觉
前置知识:第 3 章的 UV 与坐标空间、第 5 章的软边、第 7 章的比例修正。
本章示例:有符号距离的直觉,完整源码。
学完本章能做什么
你将能把一个二维形状描述为“每个点到边界还有多远”,区分内部负值、边界零值和外部正值,并从同一个距离值得到填充、描边和等距线。第 19 章再把这个方法用于不同形状。
先看现象
打开示例,青色是圆内,深色是圆外,金色细线表示距离恰好为零的边界。切到“等距线”,能看到离边界相同距离的点排成一圈圈曲线;切到填充与描边,形状本身没有变化,改变的只是把距离映射成颜色的方法。拖动平面宽高比,圆仍保持正常比例。
这里的平面是 PlaneGeometry(3.0, 2.0, 1, 1),始终只有两片三角形。圆由片元着色器逐像素绘制,并未改变三维网格轮廓、深度或表面法线。
1. 从无符号距离到有符号距离
普通距离,例如 length(p - center),只告诉我们点离中心有多远,永远不小于零。SDF(signed distance function,有符号距离函数)则以形状边界为参照:
d(p) < 0 点在形状内部
d(p) = 0 点在边界上
d(p) > 0 点在形状外部
|d(p)| 点到最近边界的距离(对精确 SDF)
“符号”解决内外判断,“距离的大小”让描边宽度、边缘过渡和等距线有统一单位。一个函数只有负内正外的符号,并不自动成为精确的欧氏距离:如果输出被任意平方或乘上变化的比例,零边界可能还在,但 |d| 不再代表真实距离。
先看一维区间 [-r, r]:abs(x) - r 在区间内为负,在两端为零,区间外为正,并且绝对值就是到最近端点的距离。二维圆把 abs(x) 换成到中心的二维长度:
// 片元阶段;p 与 radius 都用平面高度作长度单位。
float sdCircle( vec2 p, float radius ) {
return length( p ) - radius;
}
对半径 0.28 的圆,中心 p=(0,0) 得到 d=-0.28;p=(0.28,0) 得到 0;p=(0.38,0) 得到 0.10。圆外的 0.10 是点到最近圆周的距离,圆内中心的 0.28 是中心到圆周的距离。对圆来说,这个公式是精确 SDF。
2. 坐标单位决定“距离”是否可信
示例的平面正对相机,初始宽高比为 1.5。直接使用 vUv - 0.5 会把平面的宽和高都当作 1:同样的 UV 数值在横向对应更长的实际距离,结果会把“圆”画成椭圆。先按平面的显示宽高比修正:
// 片元阶段;p 的 x/y 都以整块平面的高度为 1 个单位。
vec2 p = ( vUv - 0.5 ) * vec2( uAspect, 1.0 );
float d = sdCircle( p, uRadius );
初始 uAspect=1.5,于是 p.x 从 -0.75 到 0.75,p.y 从 -0.5 到 0.5。半径 0.28 指平面高度的 28%。JavaScript 同时改变 mesh.scale.x 与 uAspect,保持屏幕上的测量一致。这里的比值来自绘图平面,不是整个浏览器窗口,也不是图片原始比例。若把平面斜向相机,透视投影仍会让正圆在屏幕上看起来倾斜或压扁;那是三维投影,并不改变平面自身的 SDF 距离。
3. 一个距离值生成填充
最直接的硬边填充是判断符号:
// 片元阶段;d 是以平面高度为单位的有符号距离。
float fill = 1.0 - step( 0.0, d );
vec3 color = mix( outsideColor, insideColor, fill );
step(0.0,d) 在 d < 0 时返回 0,在 d >= 0 时返回 1;前面的 1.0 - 使内部为 1。边界在一个像素内突然变化,斜边或曲线容易锯齿。若直接使用固定宽度,例如 smoothstep(-0.01,0.01,d),它表示平面上的固定长度,不随画面缩放成固定像素宽度。
示例的像素抗锯齿模式用片元导数估计一个像素跨过多少距离:
// 片元阶段;fwidth 仅用于片元 shader。
float aa = max( fwidth( d ), 0.0001 );
float fill = 1.0 - smoothstep( -aa, aa, d );
fwidth(d) 约为屏幕横、纵相邻片元差值绝对值之和;max 给近乎不变化的区域一个正下限。过渡区间围绕 d=0,所以不会把圆的实际边界整体推向内或外。这个做法是常见的像素宽度近似,不等于解析的像素覆盖率。抗锯齿只改变边缘颜色,不会让两三角形平面变成真正的圆形几何。
4. 同一个距离值生成描边与等距线
在精确 SDF 中,abs(d) 是到零边界的距离。把“离边界小于半个线宽”当作描边内部:
// 片元阶段;uStroke 是完整描边宽度,单位同 d。
float strokeDistance = abs( d ) - 0.5 * uStroke;
float stroke = 1.0 - smoothstep( -aa, aa, strokeDistance );
线条在原边界两侧各占一半。增加 uStroke 会同时向内和向外扩张,而原来的 d=0 圆周位置不变。只想向形状内部描边时,需要使用不同的距离区间,例如 -width < d < 0。
等距线是 d = c 的曲线:c=0 是边界,c>0 在外侧,c<0 在内侧。示例以 0.08 为间隔,让接近整数倍距离的位置变亮;两侧颜色仍保留符号信息。圆的等距线是同心圆,间距按同一坐标单位测量。若换成第 19 章的矩形,角附近的等距线会出现圆弧,因为那里的最近边界点是角点。
5. SDF 的边界与用途
SDF 是一个标量场,不是特定颜色、遮罩或网格。当前示例使用它做填充、描边和等距线;后续章节会继续使用距离做形状组合、变换和效果。只要形状函数返回的仍是准确距离,统一的描边宽度才有明确意义。如果对坐标做非等比拉伸,或随意对 d 做算术,零边界可能正确而距离数值不再精确;第 20 章会详细处理这些变换。
屏幕空间的细线还有像素采样限制:当描边宽度小于一个像素时,画面会依赖抗锯齿与分辨率;把平面网格切成更多段不能提高片元 SDF 的分辨率。
常见问题
- **圆外显示为负:**检查
length(p) - radius的减法顺序;符号约定应是负内正外。 - **圆在宽平面上变椭圆:**先把 UV 转换到一致的物理度量,横向乘平面宽高比。
- **描边只在一侧:**检查是否用了
abs(d);对称描边需要到零边界的无符号距离。 - **硬边有锯齿:**在片元阶段用
fwidth(d)估计过渡宽度,再用smoothstep。 - **软边随缩放变宽:**固定的距离宽度是平面单位;需要接近固定像素宽度时改用导数估计。
- **把 SDF 当作真实圆形网格:**这里只改变片元颜色;平面轮廓和深度仍是原几何。
本章小结
有符号距离把内外、边界和离边界的远近合成一个数。圆的 length(p)-r 是精确示例;统一坐标度量后,同一个 d 可以生成硬边填充、像素宽度软边、对称描边和等距线。下一章把距离公式扩展到矩形、圆角矩形和胶囊形。