sdf 抗锯齿

550 阅读7分钟

有符号距离场(Signed Distance Field, SDF)是一个描述空间中任意点到物体表面最短距离的数学函数,距离值带有符号:物体内部为负,外部为正,表面为零。SDF 在现代计算机图形学中有着广泛应用,包括实时光线追踪、软阴影计算、字体渲染和抗锯齿等场景。本文将系统介绍 SDF 的数学原理、常见形状的 SDF 实现,以及如何利用 SDF 实现高质量的抗锯齿效果。

基础形状的 SDF 实现

理解基础形状的 SDF 实现是掌握距离场技术的关键。本章将介绍圆形、矩形和圆角矩形这三种常见形状的 SDF 函数及其数学原理。以下内容参考自 Inigo Quilez 的 2D distance functions

圆形 (Circle)

圆形是最简单的 SDF 形状。对于一个圆心在原点、半径为 r 的圆,任意点 p 到圆的有符号距离可以通过以下方式计算:

float sdCircle(vec2 p, float r) {
    return length(p) - r;
}

这个函数的原理非常直观:length(p) 计算点 p 到原点的欧几里得距离,减去半径 r 就得到了到圆边界的有符号距离。当点在圆内时,length(p) < r,结果为负;当点在圆外时,length(p) > r,结果为正;当点在圆周上时,length(p) = r,结果为零。

矩形 (Box)

矩形的 SDF 比圆形复杂一些。对于一个中心在原点、半宽高为 b 的矩形,任意点 p 到矩形的 SDF 函数如下:

float sdBox(vec2 p, vec2 b) {
    vec2 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}

这个公式由两部分组成:

  • length(max(d, 0.0)):处理点在矩形外部的情况。max(d, 0.0) 将 d 的负分量置零,保留正分量,然后计算到矩形边界的欧几里得距离。当点在矩形外部时(角区域或边区域),这部分返回正距离;当点在内部时,d 的两个分量都为负,这部分返回 0。
  • min(max(d.x, d.y), 0.0):处理点在矩形内部的情况。max(d.x, d.y) 取 d 的两个分量中较大的那个(即离边界最近的距离),然后 min(..., 0.0) 确保只在点位于内部时(d 的分量为负)返回负距离,外部时返回 0。

这两部分的设计确保了:外部点只有第一部分贡献距离,内部点只有第二部分贡献距离,函数在整个空间连续且满足 SDF 的定义。

圆角矩形 (Rounded Box)

圆角矩形是矩形的扩展,在四个角上添加了圆角效果。SDF 函数如下:

float sdRoundedBox(vec2 p, vec2 b, float r) {
    vec2 d = abs(p) - b + r;
    return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - r;
}

与矩形的差异在于:

  1. 计算 d 时:abs(p) - b + r 而不是 abs(p) - b,相当于将矩形向内收缩了圆角半径 r
  2. 最后减去 r:向外偏移圆角半径,形成圆角效果

这种处理方式等价于:先计算点到一个缩小矩形的距离,然后通过减去 r 来"膨胀"这个矩形,让四个角变成圆弧。

例如:点 p 在 (2.5, 1.3),矩形半宽高为 (2, 1),圆角半径为 0.2

普通矩形的计算:

  • d = abs(2.5, 1.3) - (2, 1) = (0.5, 0.3)
  • 距离 = √(0.5² + 0.3²) ≈ 0.58

圆角矩形的计算:

  • d = abs(2.5, 1.3) - (2, 1) + 0.2 = (0.7, 0.5)(收缩矩形,为圆角留空间)
  • 到收缩矩形的距离 = √(0.7² + 0.5²) ≈ 0.86
  • 最终距离 = 0.86 - 0.2 = 0.66(减去圆角半径,形成圆角)

可以看到,圆角矩形的距离(0.66)比普通矩形的距离(0.58)更大,这是因为圆角"占据"了原本属于矩形角的空间,使得边界向外"膨胀"了。

SDF 抗锯齿的数学原理

SDF 抗锯齿是一种利用距离场信息实现高质量边缘渲染的技术。通过计算像素被形状覆盖的程度,而不是简单的内外二值判断,可以实现平滑的边缘过渡,消除锯齿现象。

传统像素化渲染的锯齿问题

传统的像素化渲染通过判断像素中心点是否在形状内部来决定像素颜色:如果在内部则完全填充,如果在外部则完全透明。这种二值判断会在形状边缘产生明显的锯齿(Aliasing),因为像素要么完全属于形状,要么完全不属于,没有中间状态。

覆盖值(Coverage)概念

SDF 抗锯齿的核心思想是:利用距离场信息计算像素被形状覆盖的程度。对于边缘附近的像素,根据其 SDF 值计算一个 0 到 1 之间的覆盖值:

  • 覆盖值为 1 表示像素完全在形状内部
  • 覆盖值为 0 表示像素完全在形状外部
  • 覆盖值在 0 和 1 之间表示像素部分被形状覆盖

这种平滑过渡消除了硬边界,实现了抗锯齿效果。

覆盖值计算公式

对于像素点的 SDF 值 d,覆盖值通过以下公式计算:

<!--
1. 计算像素中心到边缘的距离 d
2. 使用 `fwidth(d)` 获取距离在屏幕空间的变化率(相邻像素的距离差)
3. 用 `smoothstep` 将距离转换为 0-1 的覆盖率
-->

float coverage = 1.0 - smoothstep(-w, w, d / max(fwidth(d), 0.00001));

公式的各个部分:

1. fwidth(d)

fwidth(d) 是 GLSL 中的导数函数,返回 d 在屏幕空间中的变化率:

fwidth(d) = abs(dFdx(d)) + abs(dFdy(d))

它表示 d 在相邻像素间的变化幅度。对于距离场,fwidth(d) 近似等于一个像素的宽度对应的距离值。这个值用于将距离场归一化到像素空间。

2. d / max(fwidth(d), 0.00001)

将 SDF 值归一化到像素空间。除以 fwidth(d) 后,d 的单位从世界空间距离转换为"像素数"。max(fwidth(d), 0.00001) 用于防止除零错误。

3. smoothstep(-w, w, ...)

smoothstep(edge0, edge1, x) 是一个平滑插值函数,当 x 在 [edge0, edge1] 区间内时,返回 0 到 1 之间的平滑过渡值。其定义为:

t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);

这个函数的特点是在 edge0 和 edge1 处导数为 0,使得过渡更加平滑自然。参数 w 控制过渡带的宽度:

  • w 越大,过渡带越宽,边缘越模糊
  • w 越小,过渡带越窄,边缘越锐利
  • 通常 w 设置为 10 左右可以获得较好的效果

4. 1.0 - ...

由于 smoothstep 在 d 为负(形状内部)时返回较小值,而我们希望内部的覆盖值为 1,因此用 1.0 - 来反转结果。

完整示例:圆形抗锯齿

以下是一个完整的圆形抗锯齿实现,可以在 Shadertoy 上运行:

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    // 归一化坐标,使圆位于画面中心
    vec2 uv = (2.0 * fragCoord - iResolution.xy) / min(iResolution.x, iResolution.y);

    // 圆的 SDF
    float d = length(uv) - 0.5; // 0.5 是圆的半径

    // 抗锯齿:计算覆盖值
    float coverage = 1.0 - smoothstep(-10.0, 10.0, d / max(fwidth(d), 0.00001));

    fragColor = vec4(coverage, coverage, coverage, 1.0);
}

在这个示例中:

  • length(uv) - 0.5 计算圆的 SDF
  • d / max(fwidth(d), 0.00001) 将距离归一化到像素空间
  • smoothstep(-10.0, 10.0, ...) 在边缘 ±10 像素范围内进行平滑过渡
  • 1.0 - ... 反转结果,使内部为 1,外部为 0

这种方法同样适用于矩形、圆角矩形以及任何其他 SDF 形状,只需将圆的 SDF 替换为对应形状的 SDF 函数即可。

SDF 的优势与注意事项

优势

  • 高质量抗锯齿:计算开销小,边缘过渡平滑自然
  • 分辨率无关:函数表示,任意缩放不失真
  • 灵活组合:通过 min/max 等简单运算实现形状的并集、交集、差集
  • 内存占用小:相比位图和顶点数据更节省空间
  • 直接提供距离信息:便于碰撞检测、软阴影、光线步进等应用

注意事项

  • 过渡带宽度(w)需要调整:过窄产生锯齿,过宽边缘模糊,通常设置为 10 左右
  • 复杂形状计算成本高:多个 SDF 组合会增加计算开销
  • fwidth() 的限制:依赖 GPU 导数计算,必须在片段着色器中使用