片元着色器几何造型:符号距离场渲染
对应月影课程:第 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 |