PHASE 1 · 用数学作画

1.2SDF:用距离雕刻形状

圆、盒、线段与布尔组合

Shader 里没有「画圆 API」。业界的标准答案是一个优雅得过分的思想:SDF(Signed Distance Function,有符号距离函数)——不描述形状的轮廓,而是回答「任意一点离形状表面多远」。一旦形状变成了距离,画边框、加圆角、做发光、形状融合,全都变成对一个 float 的简单运算。这是 Phase 1 的核心章节。

1SDF:形状的距离表示法

SDF 是一个函数:输入点的坐标 p,输出一个带符号的距离 d:

  • d > 0:p 在形状外部,值就是到表面的距离;
  • d = 0:p 恰好在表面上;
  • d < 0:p 在内部,绝对值是到表面的距离。

最简单的例子是圆:点到圆心的距离减去半径,就是点到圆周的有符号距离——三个性质自动全部满足:

GLSL · 圆的 SDF
float sdCircle(vec2 p, float r) {
    return length(p) - r;
}

// 用法:mask = 内部为 1,带 2 像素抗锯齿边
float d = sdCircle(p, 0.3);
float mask = smoothstep(0.005, -0.005, d);   // 注意参数反转:d 越小越"在里面"

为什么绕这个弯?因为「距离」是个富信息量的中间产物。step 只能告诉你「在不在圆里」,d 还告诉你「离边多远」——离边 0.01 的点可以涂描边色,离边 0.1 的点可以涂光晕,d 加 0.05 相当于形状膨胀 0.05(圆角就是这么来的)。形状一旦「距离化」,后处理全是免费的。

2亲眼看见距离场

用 0.2 章学的「把数值画成颜色」调试法,直接看 d 长什么样。这种可视化是 SDF 大师 Inigo Quilez 的标志性画法:外部橙色、内部蓝色、表面白线、等距离条纹:

看懂这幅图,SDF 就通了:形状不是那条白线,而是整个渐变的场;白线只是 d=0 的等高线。后面的所有花样,都是对这个场做文章。

3常用形状小词典

常用 2D 形状的 SDF 都被前人推导好了(iq 的网站收录了 60 多个,见 4.2 章)。日常够用的是这四个,建议存成代码片段:

GLSL · 形状词典
// 圆:到圆心距离 - 半径
float sdCircle(vec2 p, float r) {
    return length(p) - r;
}

// 矩形:b 是半宽高(中心到边的距离)
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);
}

// 圆角矩形:先把矩形缩小 r,再整体膨胀 r —— 圆角是"膨胀"送的
float sdRoundBox(vec2 p, vec2 b, float r) {
    return sdBox(p, b - r) - r;
}

// 线段 ab,粗细由外部决定(d - 半宽)
float sdSegment(vec2 p, vec2 a, vec2 b) {
    vec2 pa = p - a, ba = b - a;
    float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); // p 在 ab 上的投影比例
    return length(pa - ba * h);        // 点到投影点的距离
}

不必背推导,但 sdRoundBox 的思路值得咀嚼一遍:对任何 SDF 减去一个常数 r,等于形状向外膨胀 r,而膨胀天然带圆角。这就是「距离化」的红利——圆角矩形不用重新推公式,一次减法而已。同理,sdSegment(...) - 0.02 就是一根粗 0.04、两端自带圆头的线条。

Android 视角 squircle

Material 3 的 Shapes、iOS 图标的超椭圆(squircle)、各种「可morph的图标形状」,引擎内部都是 SDF 或等价的距离表示。你在 Compose 里写 RoundedCornerShape(16.dp) 时,底层某处就有一个 sdRoundBox 的亲戚在跑。

4布尔组合与 smin

两个形状的距离场,用 min/max 一拼,就是形状的布尔运算——这大概是图形学里性价比最高的三行代码:

GLSL · 布尔三件套 + 平滑并集
float dUnion     = min(d1, d2);    // 并集:离哪个近算哪个
float dIntersect = max(d1, d2);    // 交集:必须同时在两个内部
float dSubtract  = max(d1, -d2);   // 差集:在 1 内且不在 2 内(挖洞)

// 平滑并集(smooth min):两个形状接近时像水珠一样融合
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 的招牌魔法:普通 min 的拼接处有生硬的折角,smin 在两个距离值接近时(差距小于 k)让它们互相「让一让」,拼接处便鼓起一段光滑的过渡——这就是你见过的「金属球融合」「岩浆灯」效果的全部原理:

5描边、发光与阴影

形状「距离化」之后,这些常见需求全部变成一行 float 运算:

GLSL · 距离场的后处理词典
float fill    = smoothstep(aa,  -aa, d);              // 实心填充
float outline = smoothstep(0.012, 0.008, abs(d));     // 描边:|d| 小的窄带
float glow    = exp(-8.0 * max(d, 0.0));              // 外发光:距离的指数衰减
float shadow  = smoothstep(0.15, 0.0, sdShape(p - vec2(0.03, -0.03))); // 影子:偏移再算一遍

关键在 abs(d):它把「内负外正」折叠成「离表面的绝对距离」,于是表面两侧对称的一圈就能被 smoothstep 选出来——这就是描边。发光则利用指数衰减 exp(-k·d):表面处为 1,离得越远越暗,k 控制光晕的松紧。霓虹灯效果三行就出来了:

这套玩法能一路通到 3D

SDF 在 3D 里同样成立(length(p) - r 里的 p 换成 vec3 就是球),配合 Ray Marching 算法就能渲染完整 3D 场景——Shadertoy 上那些不可思议的单文件 3D 作品全是这条技术线。4.3 章会带你入门。

本章小结

  • SDF 输入坐标输出有符号距离:外正、内负、表面为零;形状是场,轮廓只是 d=0 等高线。
  • 四个基础形状:sdCircle / sdBox / sdRoundBox / sdSegment;SDF 减常数 = 膨胀 = 免费圆角。
  • 布尔三件套:min 并 / max 交 / max(d1,-d2) 差;smin 让形状像水珠一样融合。
  • 后处理词典:abs(d) 描边、exp(-k·d) 发光、坐标偏移重算 = 阴影。
  • 调不出来就先切回距离场可视化(第 2 节的调试着色),看场,别猜。

动手练习

  1. max(d1, -d2) 做一个「月牙」:两个错位的圆相减。再给它加上霓虹描边。
  2. 用 sdRoundBox 画一个 App 图标形状(带大圆角的方形),外面加一圈柔和投影(提示:偏移 + exp 衰减 + 压暗)。
  3. 把 smin Demo 改成三个球:再加一个上下运动的小圆,三者互相融合。注意 smin 只能两两调用,嵌套即可。