片元着色器几何造型:符号距离场渲染

对应月影课程:第 14 章(如何使用片元着色器进行几何造型)

源码参考:https://github.com/akira-cn/graphics/tree/master/shaping-functions

目标:掌握用片元着色器绘制圆、直线、线段、三角形的原理,理解符号距离场(SDF)方法论。


0. 前置知识铺垫:你需要先知道这些

在深入学习片元着色器几何造型之前,让我们先确认几个基础概念。

0.1 什么是片元着色器(Fragment Shader)

片元着色器是 GPU 渲染管线的最后一个可编程阶段。它的任务是:为屏幕上的每一个像素决定最终颜色

类比:片元着色器就像一个"涂色工人"
  - 顶点着色器画好了形状的轮廓(三角形)
  - 片元着色器负责给轮廓内的每一个像素点上色
  - 每个像素点独立计算,互不影响(这是 GPU 并行计算的基础)

0.2 什么是 UV 坐标

UV 坐标是将屏幕/纹理映射到 [0, 1] 范围的标准化坐标系:

  • U(水平):左 = 0,右 = 1
  • V(垂直):下 = 0,上 = 1(或下 = 1,上 = 0,取决于约定)
类比:UV 坐标就像地图的经纬度
  - 不管地图多大,经度总是 0°~360°
  - 不管屏幕分辨率是 1920×1080 还是 800×600,UV 总是 0~1
  - 这让着色器代码与分辨率无关

0.3 为什么需要学习 SDF

如果不懂这个会怎样?

  • 你只能在 Canvas 2D 中用 arc()lineTo() 画简单图形,无法做复杂效果
  • 你无法实现动态变形、融合、描边等高级视觉效果
  • 你看不懂 ShaderToy 上那些酷炫的效果是怎么实现的
  • 你无法在 3D 场景中做精确的几何裁剪和遮罩

学了之后能做什么?

  • 用纯 Shader 绘制任意复杂形状
  • 实现图形的动态变形、融合、描边、发光效果
  • 制作进度条、按钮、图标等 UI 元素的 Shader 动画
  • 在 3D 场景中添加 2D 遮罩、轮廓线、外壳效果

一、片元着色器的颜色控制

1.1 纯色绘制

void main() {
    // gl_FragColor 是片元着色器的输出:当前像素的颜色
    // vec4(0.0, 0.0, 0.0, 1.0) = RGBA,其中 A=1.0 表示完全不透明
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);  // 纯黑色
}
类比:就像给整面墙刷同一种颜色的油漆
  - 不管墙上有多少块砖(像素),每块都刷成黑色
  - 结果是:整个屏幕都是黑色的

1.2 UV 坐标渐变

varying vec2 vUv;  // 从顶点着色器传入的 UV 坐标 [0,1]

void main() {
    // vUv.x 从左到右 0→1,直接作为红色通道
    // 左边:vUv.x = 0,红色 = 0(无红色)
    // 右边:vUv.x = 1,红色 = 1(全红色)
    gl_FragColor.rgb = vec3(vUv.x, 0.0, 0.0);
    gl_FragColor.a = 1.0;
}
类比:就像把温度计放在屏幕上
  - 左边是"低温"(黑色)
  - 右边是"高温"(红色)
  - 中间是渐变的温度过渡

1.3 棋盘格图案(floor + fract + mod)

void main() {
    vec2 st = vUv * 10.0;        // 将 UV 放大 10 倍,把 [0,1] 映射到 [0,10]
                                 // 这样屏幕上会出现 10×10 的网格
    vec2 idx = floor(st);        // floor 向下取整,得到每个像素的"网格编号"
                                 // 例如:st = (3.7, 5.2) → idx = (3, 5)
    vec2 grid = fract(st);       // fract 取小数部分,得到像素在网格内的位置
                                 // 例如:st = (3.7, 5.2) → grid = (0.7, 0.2)

    vec2 t = mod(idx, 2.0);      // mod 取模,判断网格编号的奇偶性
                                 // 奇数编号 → t = 1,偶数编号 → t = 0

    // 奇数行/列翻转坐标,形成棋盘效果
    // 原理:相邻网格的坐标被翻转,产生黑白交替的视觉效果
    if(t.x == 1.0) grid.x = 1.0 - grid.x;  // 奇数列:水平翻转
    if(t.y == 1.0) grid.y = 1.0 - grid.y;  // 奇数行:垂直翻转

    gl_FragColor.rgb = vec3(grid, 0.0);  // 绿红渐变棋盘
    gl_FragColor.a = 1.0;
}

关键函数解析

函数 作用 示例
floor(x) 向下取整 floor(3.7) = 3.0
fract(x) 取小数部分 fract(3.7) = 0.7
mod(x, y) 取模 mod(5.0, 2.0) = 1.0
自问自答:
Q: 为什么棋盘格需要翻转坐标?
A: 想象一个 2×2 的棋盘:
     左上格子:坐标正常 → 渐变从 (0,0) 到 (1,1)
     右上格子:坐标翻转 → 渐变从 (1,0) 到 (0,1)
   两个格子的渐变方向相反,形成棋盘图案。

Q: 为什么用 mod 判断奇偶?
A: mod(idx, 2.0) 的结果:
   - 偶数:mod(0,2)=0, mod(2,2)=0, mod(4,2)=0...
   - 奇数:mod(1,2)=1, mod(3,2)=1, mod(5,2)=1...
   这样我们就能区分奇偶行列了。

二、绘制圆:distance + step + smoothstep

2.1 为什么用距离来定义圆

在数学中,圆的定义是:到定点(圆心)的距离等于定长(半径)的所有点的集合

在片元着色器中,我们无法"画线",但我们可以:计算每个像素到圆心的距离,根据距离决定颜色

类比:就像往平静的湖面扔一块石头
  - 石头落水点 = 圆心
  - 波纹 = 到圆心距离相等的点
  - 每一圈波纹就是一个"等距线"
  - 我们要做的就是:根据像素在哪一圈波纹上,给它涂不同颜色

2.2 模糊圆(距离渐变)

void main() {
    // distance(p1, p2) 返回两点间的欧几里得距离
    // 公式:distance(a, b) = sqrt((a.x-b.x)² + (a.y-b.y)²)
    float d = distance(vUv, vec2(0.5));  // 到中心 (0.5, 0.5) 的距离

    // 距离越小越黑(d≈0 时黑色),距离越大越白(d≈0.7 时白色)
    // vec3(1.0) = (1,1,1) 即白色,乘以 d 后变成灰度
    gl_FragColor.rgb = d * vec3(1.0);
    gl_FragColor.a = 1.0;
}

原理

圆心 (0.5, 0.5),半径 r 的圆上所有点满足:
  distance(vUv, vec2(0.5)) = r

因此:
  d < r  → 点在圆内
  d = r  → 点在圆上
  d > r  → 点在圆外
自问自答:
Q: 为什么圆心是 (0.5, 0.5) 而不是 (0, 0)?
A: UV 坐标的范围是 [0, 1],(0.5, 0.5) 是屏幕正中心。
   如果用 (0, 0),圆会在左上角。

Q: 为什么最大距离约为 0.7?
A: 屏幕对角线的一半:sqrt(0.5² + 0.5²) = sqrt(0.5) ≈ 0.707
   这是屏幕中心到角落的距离。

2.3 清晰圆(step 阶梯函数)

void main() {
    float d = distance(vUv, vec2(0.5));  // 到圆心的距离

    // step(edge, x): x < edge 返回 0,x >= edge 返回 1
    // 这里:d < 0.2 返回 1(白色),d >= 0.2 返回 0(黑色)
    // 注意参数顺序!step(edge, x) 不是 step(x, edge)
    gl_FragColor.rgb = step(d, 0.2) * vec3(1.0);
    gl_FragColor.a = 1.0;
}

step 函数图像

  1 |________
    |
    |
  0 |    edge
    +--------→ x
类比:step 就像一个"开关"
  - 输入(距离)小于阈值 → 开(白色)
  - 输入(距离)大于阈值 → 关(黑色)
  - 没有中间状态,只有 0 或 1

2.4 抗锯齿圆(smoothstep)

void main() {
    float d = distance(vUv, vec2(0.5));  // 到圆心的距离

    // smoothstep(edge0, edge1, x):
    //   x <= edge0 → 0
    //   x >= edge1 → 1
    //   之间 → 平滑的三次插值(Hermite 插值)
    // 这里:d 从 0.2 到 0.21 时,颜色从白平滑过渡到黑
    gl_FragColor.rgb = smoothstep(d, d + 0.01, 0.2) * vec3(1.0);
    gl_FragColor.a = 1.0;
}

为什么 smoothstep 能抗锯齿?

step 的边界是突变的:
  像素 A (d=0.199) → 白色
  像素 B (d=0.201) → 黑色
  边界出现锯齿(因为像素是离散的,边界处黑白交替)

smoothstep 的边界是渐变的(约 2~3 像素宽):
  像素 A (d=0.195) → 白色
  像素 B (d=0.200) → 灰色(过渡)
  像素 C (d=0.205) → 黑色
  边界变得柔和,视觉上消除锯齿
类比:step 就像用剪刀剪纸,边缘锋利但可能有毛边
     smoothstep 就像用水彩画边缘,自然过渡没有锯齿

     显示器像素是离散的(像马赛克)
     step 的边缘正好落在像素之间 → 锯齿
     smoothstep 的边缘跨越多个像素 → 平滑

2.5 函数对比表

函数 返回值 边界特性 用途
step(edge, x) 0 或 1 突变 硬边缘裁剪
smoothstep(e0, e1, x) 0~1 平滑过渡 渐变 抗锯齿边缘

2.6 初学者常见错误

错误 原因 正确做法
step(x, edge) 参数顺序写反 记错参数顺序 step(edge, x):x < edge 返回 0
smoothstep 边界太宽 过渡范围太大导致模糊 通常用 0.01 ~ 0.02 的过渡范围
忘记乘 vec3(1.0) step 返回 float,不能直接赋给 rgb 始终 step(...) * vec3(1.0)

三、绘制直线:叉乘求距离

3.1 为什么需要叉乘

我们已经会用 distance() 画圆了,但直线呢?直线没有"圆心",我们需要另一种方法计算"点到直线的距离"。

**叉乘(Cross Product)**就是解决这个问题的数学工具。

3.2 什么是叉乘

叉乘是两个向量的一种运算,结果是一个新向量(在 3D 中)或一个标量(在 2D 中)。

对于 2D 向量 a = (ax, ay) 和 b = (bx, by):

cross(a, b) = ax * by - ay * bx

几何意义:
  |cross(a, b)| = |a| * |b| * sin(θ)
                = 以 a, b 为邻边的平行四边形面积

如果 b 是单位向量(|b| = 1):
  |cross(a, b)| = |a| * sin(θ) = 点 a 到直线 b 的距离
类比:叉乘就像计算"平行四边形的面积"
  - 两个向量 a 和 b 像两根相邻的木棍
  - 叉乘的绝对值 = 以这两根木棍为边的平行四边形的面积
  - 面积 = 底 × 高,如果底边长度为 1,面积就是"高" = 点到直线的距离
自问自答:
Q: 为什么叉乘能求点到直线的距离?
A: 想象一个平行四边形:
     - 一边沿着直线方向(长度 = |b|)
     - 另一边从直线上一点指向目标点(长度 = |a|)
     - 面积 = 底 × 高 = |b| × 距离
     - 所以:距离 = 面积 / |b| = |cross(a, b)| / |b|
   如果 b 是单位向量(|b|=1),距离 = |cross(a, b)|

Q: 为什么要把 2D 向量升到 3D 再叉乘?
A: GLSL 的 cross() 函数只接受 vec3。
   我们把 (x, y) 变成 (x, y, 0),叉乘结果的 z 分量就是 2D 叉乘值。
   这是 GLSL 的语法限制,不是数学需要。

3.3 过原点的直线

void main() {
    // line = (1, 1, 0) 表示方向向量 (1, 1)
    // 这条直线过原点,方向是 45°(因为 x=y)
    vec3 line = vec3(1.0, 1.0, 0.0);

    // cross(vec3(vUv, 0), normalize(line)).z:
    //   步骤1:将 UV 坐标升维到 3D → vec3(vUv, 0.0)
    //   步骤2:normalize(line) 将直线方向归一化为单位向量
    //   步骤3:cross(a, b) 计算叉乘
    //   步骤4:取 .z 分量 = 平行四边形面积(底边为 1,所以 = 高 = 距离)
    float d = abs(cross(vec3(vUv, 0.0), normalize(line)).z);

    // 距离小于 0.01 的像素着色为白色
    // (1.0 - smoothstep(0.0, 0.01, d)) 的效果:
    //   d ≈ 0 时 → 1.0(白色)
    //   d > 0.01 时 → 0.0(黑色)
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
}
自问自答:
Q: 为什么用 abs() 取绝对值?
A: 叉乘的符号表示点在直线的哪一侧(左侧为正,右侧为负)。
   我们画直线时只关心"距离",不关心"在哪一侧",所以取绝对值。

Q: 为什么用 1.0 - smoothstep(...)?
A: smoothstep(0.0, 0.01, d) 在 d≈0 时返回 0,d>0.01 时返回 1。
   但我们想要的是:d≈0 时白色(1),d>0.01 时黑色(0)。
   所以用 1.0 减去它,实现"反转"。

3.4 用鼠标控制直线方向

uniform vec2 uMouse;   // 鼠标位置 [0, 1],从 JavaScript 传入

void main() {
    // 用鼠标位置作为直线方向向量
    // 鼠标在 (0.5, 0.5) 时,直线方向是 (0.5, 0.5) 即 45°
    vec3 line = vec3(uMouse, 0.0);
    float d = abs(cross(vec3(vUv, 0.0), normalize(line)).z);

    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
}
// JavaScript 传递鼠标坐标
// 监听鼠标移动事件,将屏幕坐标转换为 UV 坐标
 canvas.addEventListener('mousemove', (e) => {
    const rect = canvas.getBoundingClientRect();  // 获取 canvas 在页面中的位置
    gl.uniform2f(uMouseLoc,
        (e.clientX - rect.left) / rect.width,     // x: [0, 1]
        1.0 - (e.clientY - rect.top) / rect.height  // y: [0, 1],Y轴翻转
        // 注意 Y 轴翻转!WebGL 的 Y 轴向上,但屏幕坐标 Y 轴向下
    );
});

四、绘制线段:投影判断

4.1 为什么线段比直线复杂

直线是无限延伸的,但线段有起点和终点。点到线段的距离需要分情况讨论:

情况 1:投影在线段内
  C1 ──垂线──→ a━━━━━━b
  距离 = 点到直线的距离(用叉乘)

情况 2:投影在 a 的外侧
  C2 ───────→ a━━━━━━b
  距离 = C2 到 a 的距离(用 distance)

情况 3:投影在 b 的外侧
  a━━━━━━b ←─────── C3
  距离 = C3 到 b 的距离(用 distance)
类比:就像找离公路最近的距离
  - 情况1:你在公路正旁边 → 距离 = 垂直距离到公路
  - 情况2:你在公路起点前面 → 距离 = 到起点的距离
  - 情况3:你在公路终点后面 → 距离 = 到终点的距离

4.2 什么是点乘(Dot Product)

在讲解 seg_distance 之前,我们需要先理解点乘(Dot Product)

对于向量 a 和 b:
  dot(a, b) = |a| * |b| * cos(θ)

几何意义:
  - 点乘结果表示一个向量在另一个向量方向上的"投影长度"
  - 如果 b 是单位向量,dot(a, b) = |a| * cos(θ) = a 在 b 方向上的投影长度
类比:点乘就像"影子"
  - 中午 12 点,阳光直射
  - 你站在地上,你的影子长度 = 你的身高 × cos(0°) = 你的身高
  - 傍晚时分,阳光斜射
  - 你的影子长度 = 你的身高 × cos(θ) < 你的身高
  - 这个"影子长度"就是点乘的几何意义

4.3 seg_distance 函数实现

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
    vec3 ab = vec3(b - a, 0.0);       // 步骤1:计算线段向量(从 a 指向 b)
                                      // 升维到 3D 是为了后续叉乘
    vec3 p = vec3(st - a, 0.0);       // 步骤2:计算从线段起点 a 到当前点 st 的向量
    float l = length(ab);              // 步骤3:计算线段长度 |ab|

    // 步骤4:点到直线的距离(叉乘法)
    // cross(p, normalize(ab)).z = |p| * sin(θ) = 点到直线的垂直距离
    float d = abs(cross(p, normalize(ab)).z);

    // 步骤5:计算投影长度
    // dot(p, ab) / l = (|p| * |ab| * cos(θ)) / |ab| = |p| * cos(θ) = 投影长度
    float proj = dot(p, ab) / l;

    // 步骤6:判断投影是否在线段内
    // proj >= 0:投影在 a 的右侧(或重合)
    // proj <= l:投影在 b 的左侧(或重合)
    // 两个条件同时满足 → 投影在线段内
    if(proj >= 0.0 && proj <= l) return d;

    // 步骤7:投影在线段外 → 返回到最近端点的距离
    // min(distance(st, a), distance(st, b)) 取到两个端点中较近的一个
    return min(distance(st, a), distance(st, b));
}
自问自答:
Q: 为什么投影判断用 dot(p, ab) / l 而不是 dot(p, normalize(ab))?
A: dot(p, normalize(ab)) 得到的是投影长度(以单位向量为基准)。
   dot(p, ab) / l 也是投影长度,但用原始向量计算。
   两者数学等价:dot(p, ab)/l = dot(p, normalize(ab))
   但后者多了一次 normalize 运算,效率稍低。

Q: 如果 proj < 0 意味着什么?
A: 意味着当前点 st 在线段起点 a 的"反向延长线"上。
   就像你站在公路起点的前面(不是后面),最近点是起点 a。

4.4 鼠标绘制线段

uniform vec2 uMouse;    // 鼠标当前位置
uniform vec2 uOrigin;   // 线段起点(固定或另一个鼠标位置)

void main() {
    // 计算当前像素到线段的距离
    float d = seg_distance(vUv, uOrigin, uMouse);
    
    // 距离近的白色,距离远的黑色
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
}

五、绘制三角形:符号距离场

5.1 什么是有符号距离(Signed Distance)

之前我们求的都是"绝对距离"(始终为正)。有符号距离在绝对距离的基础上增加了"方向信息":

  • 正数:在直线的一侧
  • 负数:在直线的另一侧
  • 零:在直线上
类比:有符号距离就像"海拔"
  - 海平面以上:正数(+100 米)
  - 海平面以下:负数(-50 米)
  - 海平面:零
  - 绝对值表示离海平面的距离,符号表示在上方还是下方

5.2 点到三角形边的距离

float line_distance(in vec2 st, in vec2 a, in vec2 b) {
    vec3 ab = vec3(b - a, 0.0);       // 边向量
    vec3 p = vec3(st - a, 0.0);       // 从边起点指向当前点的向量
    return cross(p, normalize(ab)).z;  // 有符号距离(带方向)
                                      // 注意:这里没有 abs()!
}

为什么用有符号距离?

叉乘的 z 分量符号表示点在直线的哪一侧:
  d > 0 → 点在直线左侧
  d < 0 → 点在直线右侧
  d = 0 → 点在直线上

对于三角形的三条边,如果点在三边同侧(都在内部或都在外部),
则三个有符号距离的符号相同。
自问自答:
Q: 怎么判断"左侧"和"右侧"?
A: 这取决于边的方向。如果边从 a 指向 b:
   - 站在 a 面朝 b,你的左手边就是 "d > 0" 的一侧
   - 右手边就是 "d < 0" 的一侧
   - 这是右手坐标系的规定

Q: 为什么三角形内三个距离同号?
A: 想象一个顺时针排列的三角形 ABC:
   - 对于边 AB,内部在右侧(d < 0)
   - 对于边 BC,内部在右侧(d < 0)
   - 对于边 CA,内部在右侧(d < 0)
   三个都是负数,符号相同!
   如果点在三角形外,至少有一个距离的符号和其他的不同。

5.3 triangle_distance 函数

float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
    // 步骤1:计算当前点到三条边的有符号距离
    float d1 = line_distance(st, a, b);  // 到边 AB 的有符号距离
    float d2 = line_distance(st, b, c);  // 到边 BC 的有符号距离
    float d3 = line_distance(st, c, a);  // 到边 CA 的有符号距离

    // 步骤2:判断点是否在三角形内部
    // 条件:三个距离同号(全 >=0 或全 <=0)
    // 注意:这里用 >= 和 <= 包含边界(d=0 时在边上)
    if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 ||
       d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
        // 步骤3:点在内部 → 返回负的最小绝对距离
        // 为什么是负的?SDF 约定:内部为负,外部为正
        // min(abs(d1), min(abs(d2), abs(d3))) = 到最近边的距离
        return -min(abs(d1), min(abs(d2), abs(d3)));
    }

    // 步骤4:点在三角形外部 → 返回到三条边的最小距离
    // 这里用 seg_distance(线段距离),不是 line_distance(直线距离)
    // 因为三角形的边是线段,不是无限长的直线
    return min(seg_distance(st, a, b),
           min(seg_distance(st, b, c), seg_distance(st, c, a)));
}
自问自答:
Q: 为什么内部返回负数?
A: 这是 SDF(符号距离场)的标准约定:
   - 内部:距离 < 0
   - 边界:距离 = 0
   - 外部:距离 > 0
   这个约定让后续的着色逻辑非常统一。

Q: 为什么外部用 seg_distance 而不是 line_distance?
A: 因为三角形的边是线段,有起点和终点。
   如果点在线段的"延长线"附近,line_distance 会给出到无限长直线的距离,
   但实际最近点可能是线段的端点。
   seg_distance 正确处理了这种情况。

5.4 绘制三角形

void main() {
    // 计算当前像素到三角形的符号距离
    float d = triangle_distance(vUv,
        vec2(0.3, 0.3),    // 顶点 A:左下角
        vec2(0.5, 0.7),    // 顶点 B:顶部
        vec2(0.7, 0.3)     // 顶点 C:右下角
    );

    // 绘制三角形边界:d ≈ 0 附近为白色
    // smoothstep(0.0, 0.01, d) 在 d=0 时为 0,d=0.01 时为 1
    // 1.0 - ... 反转,使 d≈0 时为白色
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
}

六、符号距离场渲染(SDF)方法论

6.1 什么是 SDF(Signed Distance Field)

符号距离场是一种描述形状的方法:

  • 对于空间中的每一个点,存储它到最近形状边界的有符号距离
  • 内部为负,外部为正,边界为零
类比:SDF 就像一块"智能橡皮泥"
  - 你想捏一个圆:SDF 告诉你每个点到圆边界的距离
  - 你想捏一个三角形:SDF 告诉你每个点到三角形边的距离
  - 距离为 0 的地方就是形状的边界
  - 负数区域是形状内部,正数区域是形状外部

6.2 为什么 SDF 如此强大

如果不用 SDF 会怎样?

  • 你需要为每种形状写不同的绘制逻辑(圆的代码、三角形的代码、多边形的代码...)
  • 无法实现形状的融合、描边、发光等效果
  • 抗锯齿处理需要在每种形状中单独实现
  • 无法方便地实现形状的变形和动画

SDF 的优势

1. 统一性:所有形状都用"距离"描述
2. 可组合:多个 SDF 可以用 min/max 组合
3. 易变形:对距离做数学运算就能变形
4. 天然抗锯齿:距离场天然支持平滑过渡
5. 可扩展:从 2D 到 3D 原理相同

6.3 统一的两步方法论

前面绘制的图形虽然各不相同,但步骤可以统一为:

第一步:定义距离

圆:点到圆心的距离
直线:点到直线的距离
线段:点到线段的距离
三角形:点到三边的有符号距离
多边形:点到各边的有符号距离

第二步:根据距离着色

// 绘制边界(距离为 0 附近)
gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);

// 或绘制内部(距离 <= 0)
gl_FragColor.rgb = step(d, 0.0) * vec3(1.0);

// 或绘制等距线(距离 = 特定值)
gl_FragColor.rgb = (smoothstep(0.195, 0.2, d) - smoothstep(0.2, 0.205, d)) * vec3(1.0);
自问自答:
Q: 为什么 SDF 只需要"距离"就能画任何形状?
A: 因为形状的边界就是"距离 = 0"的点的集合。
   圆的边界:到圆心距离 = 半径
   三角形的边界:到某条边距离 = 0 且在另外两边同侧
   一旦你能计算距离,你就能定义边界。

Q: SDF 的"符号"(正负)有什么用?
A: 符号告诉我们点在内部还是外部。
   没有符号,我们只能画"线"(边界)。
   有了符号,我们能画"面"(填充整个形状)。

6.4 从形状到环:fract 重复

void main() {
    float d = distance(vUv, vec2(0.5));  // 到圆心的距离

    // fract(x) = x - floor(x),取小数部分
    // 例如:fract(3.7) = 0.7,fract(5.2) = 0.2
    // 将距离映射到 0~1 的重复模式
    // 20.0 * d 把距离放大 20 倍,然后取小数部分
    // 效果:每 0.05 的距离单位重复一次图案
    d = fract(20.0 * d);

    // 在 d=0.5 附近绘制白色环
    // smoothstep(0.45, 0.5, d) 在 d=0.45~0.5 时从 0 升到 1
    // smoothstep(0.5, 0.55, d) 在 d=0.5~0.55 时从 1 降到 0
    // 两者相减,只在 d≈0.5 时产生一个窄峰
    gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
}

效果:同心圆环(类似树木年轮)

类比:fract 就像"卷尺"
  - 正常的卷尺:0, 1, 2, 3, 4, 5...(无限延伸)
  - fract 卷尺:0, 1, 2, 3, 4, 5... 但只显示小数部分
  - 所以看起来永远是:0→1→0→1→0→1...
  - 把距离变成重复的 0~1 模式,就能画环了

6.5 三角环

void main() {
    // 步骤1:计算到三角形的符号距离
    float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
    
    // 步骤2:用 abs(d) 把内部距离也变成正数
    // 这样内部和外部都能产生环
    d = fract(20.0 * abs(d));

    // 步骤3:在 d=0.5 附近绘制白色环
    gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
}
自问自答:
Q: 为什么三角环要用 abs(d)?
A: 因为三角形的 SDF 内部是负数。
   如果不取 abs,fract(20.0 * 负数) 的结果和正数不同。
   取 abs 后,内部和外部的距离都变成正数,环就能穿透到三角形内部。

Q: 怎么调整环的密度?
A: 修改 20.0 这个系数:
   - 系数越大 → 环越密集
   - 系数越小 → 环越稀疏

七、实际应用

7.1 图片裁剪(三角形遮罩)

uniform sampler2D tMap;   // 纹理采样器,对应传入的图片
uniform float uTime;      // 时间,用于驱动动画

void main() {
    // texture2D 从纹理中采样颜色
    // tMap 是纹理,vUv 是采样坐标
    vec4 color = texture2D(tMap, vUv);

    // 定义三角形顶点(随时间缩放)
    // scale 从 0 逐渐增大到 1,三角形从小到大展开
    float scale = min(1.0, 0.0005 * uTime);
    vec2 a = scale * vec2(-0.577, -0.5);   // 顶点 A,随 scale 缩放
    vec2 b = scale * vec2(0.0, 1.366);     // 顶点 B,随 scale 缩放
    vec2 c = scale * vec2(0.577, -0.5);    // 顶点 C,随 scale 缩放

    // vUv - vec2(0.5) 把 UV 中心移到 (0.5, 0.5)
    // 这样三角形以屏幕中心为原点展开
    float d = triangle_distance(vUv - vec2(0.5), a, b, c);

    // 三角形内显示图片,外显示黑色
    // smoothstep(0.0, 0.01, d) 在内部(d<0)时为 0,外部(d>0.01)时为 1
    // 1.0 - ... 反转:内部为 1(显示图片),外部为 0(黑色)
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * color.rgb;
    gl_FragColor.a = 1.0;
}
类比:就像用剪刀剪照片
  - 三角形是剪刀的形状
  - 三角形内部保留照片
  - 三角形外部剪掉(变成黑色)
  - scale 控制剪刀的大小,从一个小点慢慢展开

7.2 进度条效果

uniform float uProgress;  // 进度值 0.0 ~ 1.0,从 JavaScript 传入

void main() {
    // 步骤1:将 UV 坐标中心移到 (0.5, 0.5)
    vec2 uv = vUv - vec2(0.5);
    
    // 步骤2:计算角度
    // atan(y, x) 返回 [-π, π] 的角度
    // 除以 π 再乘以 0.5 加 0.5,映射到 [0, 1]
    // 效果:从右半边开始,逆时针方向从 0 增加到 1
    float angle = atan(uv.y, uv.x) / 3.14159 * 0.5 + 0.5;

    // 步骤3:计算到圆环的距离
    // length(uv) 是到圆心的距离
    // abs(length(uv) - 0.3) 是到半径为 0.3 的圆环的距离
    float d = abs(length(uv) - 0.3);

    // 步骤4:根据进度着色
    // step(angle, uProgress):当前角度小于进度 → 1(绿色),否则 → 0(黑色)
    float progress = step(angle, uProgress);
    
    // 只在圆环附近(d 很小)显示颜色
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.02, d)) * progress * vec3(0.0, 1.0, 0.5);
    gl_FragColor.a = 1.0;
}
自问自答:
Q: 为什么用 atan 计算角度?
A: atan(y, x) 是计算点 (x,y) 相对于原点的角度。
   我们想要一个"环形进度条",需要知道每个像素在圆环上的"位置"。
   角度就是这个位置:0° 到 360° 对应进度 0% 到 100%。

Q: 为什么进度条是半圆而不是整圆?
A: atan 返回 [-π, π],映射后是 [0, 1]。
   但 step(angle, uProgress) 在 angle > uProgress 时返回 0。
   实际上这段代码画的是"整圆"进度条,但角度映射可能需要调整。

7.3 外壳纹理(3D 球体轮廓)

// 在 3D 场景中,用 SDF 给球体添加外壳效果
float sphere_distance(vec3 p, vec3 center, float radius) {
    // 3D SDF 和 2D 原理完全相同!
    // length(p - center) 是到球心的距离
    // 减去 radius:在球面上时距离 = 0,内部为负,外部为正
    return length(p - center) - radius;
}

// 结合噪声函数生成有机外壳
float shell = smoothstep(0.0, 0.1, sphere_distance(pos, center, radius));
类比:3D SDF 就像"超声波探测"
  - 2D SDF:在平面上探测到边界的距离
  - 3D SDF:在空间中探测到物体表面的距离
  - 原理完全一样,只是从 vec2 变成 vec3
  - 这也是 SDF 的强大之处:2D/3D 通用!

八、知识图谱

片元着色器几何造型
├── 颜色控制
│   ├── 纯色
│   ├── UV 渐变
│   └── 棋盘格(floor/fract/mod)
│
├── 绘制圆
│   ├── distance 求距离
│   ├── step 硬边缘
│   └── smoothstep 抗锯齿
│
├── 绘制直线
│   ├── cross 叉乘求距离
│   └── 过原点 / 过任意点
│
├── 绘制线段
│   ├── 投影判断(dot)
│   ├── 线段内 → 点到直线距离
│   └── 线段外 → 到端点距离
│
├── 绘制三角形
│   ├── line_distance 有符号距离
│   ├── 三边同号 → 内部
│   └── 推广到任意凸多边形
│
├── SDF 方法论
│   ├── 第一步:定义距离
│   ├── 第二步:根据距离着色
│   └── fract 重复 → 环状图案
│
└── 实际应用
    ├── 图片裁剪
    ├── 进度条
    └── 3D 外壳纹理

九、自检清单

检查项 说明
□ 理解 distance/step/smoothstep 三个函数的区别和用途
□ 会用叉乘求点到直线距离 cross(vec3, vec3).z
□ 理解 seg_distance 的投影判断 dot(p, ab) / l
□ 理解有符号距离的作用 判断点在线段哪一侧
□ 会写 triangle_distance 三边同号判断内部
□ 掌握 SDF 两步法 定义距离 → 根据距离着色
□ 会用 fract 创建重复图案 环、网格
□ 能应用到实际场景 裁剪、进度条、外壳

十、自问自答汇总

Q: 为什么用 distance() 就能画圆? A: 圆的定义是"到定点距离相等的点的集合"。distance() 计算每个像素到圆心的距离,根据距离决定颜色,自然形成圆形。

Q: step 和 smoothstep 有什么区别? A: step 是硬切换(0 或 1),边界锯齿明显。smoothstep 是软过渡(0~1 平滑变化),边界柔和抗锯齿。

Q: 为什么叉乘能求点到直线的距离? A: 叉乘的绝对值等于平行四边形面积。面积 = 底 × 高,如果底边(直线方向向量)归一化为 1,面积就是"高" = 点到直线的距离。

Q: 为什么线段距离比直线距离复杂? A: 直线无限长,点到直线的垂线一定在直线上。线段有端点,垂线可能落在线段外面,此时最近点是端点而不是垂足。

Q: 有符号距离和普通距离有什么区别? A: 有符号距离增加了"方向信息":内部为负,外部为正,边界为零。普通距离只有大小没有方向。

Q: 为什么 SDF 内部约定为负数? A: 这是行业约定。内部为负、外部为正、边界为零,让着色逻辑统一:step(d, 0.0) 就能填充内部。

Q: fract 为什么能创建环状图案? A: fract 取小数部分,把任意数值映射到 [0,1] 的重复模式。配合距离场,每固定距离重复一次图案,形成环。

Q: SDF 方法论的核心思想是什么? A: 所有形状都用"到边界的距离"描述。第一步定义距离函数,第二步根据距离着色。统一、简洁、可扩展。


十一、初学者常见错误汇总

错误 原因 正确做法
step(x, edge) 参数顺序写反 记错参数顺序 step(edge, x):x < edge 返回 0
smoothstep 边界太宽 过渡范围太大导致模糊 通常用 0.01 ~ 0.02 的过渡范围
忘记乘 vec3(1.0) step 返回 float,不能直接赋给 rgb 始终 step(...) * vec3(1.0)
叉乘后忘记取 abs 距离不能为负 画直线时用 abs(cross(...).z)
混淆 line_distance 和 seg_distance 直线无限长,线段有端点 三角形边界用 seg_distance
忘记归一化方向向量 叉乘结果受向量长度影响 始终 normalize(direction)
SDF 内部返回正数 违反 SDF 约定 内部返回 -min(abs(d1), ...)
distance 判断三角形内部 点到顶点距离不能判断内部 用三边有符号距离的符号判断
忘记 Y 轴翻转 WebGL Y 轴向上,屏幕坐标向下 JS 传鼠标坐标时 1.0 - y
fract 系数太小/太大 环太稀疏或太密集 根据效果调整,通常 10~50