第 8 章:距离场
第 8 章:距离场 —— 纯代码画图形
学习目标:在第 6 章我们画出了圆环,在第 7 章我们用噪声打破了重复——本章将在这些基础上进一步扩展,把"画一个图形"这件事体系化、工具化。掌握在 shader 中纯代码绘制几何图形——圆、环、矩形、十字星、正多边形,并理解如何把简单形状组合成复杂图案。章末能独立完成一个不依赖任何贴图的 HUD 雷达扫描界面。
场景搭建:创建一个 Sprite2D 节点或ColorRect节点,为其新建 ShaderMaterial 和 canvas_item 类型的 shader 文件。本章所有图形都由 shader 代码直接生成,不需要任何外部纹理贴图。
内容要点:
distance(UV, center):shader 中唯一的"尺子"——每个像素到参考点的距离- 基本图元:圆 / 圆环 / 矩形 / 十字星 / 正多边形(每种一个
sd_xxx()函数封装,返回带符号距离) - 复合操作三件套:
min(a, b)= 并 /max(-a, b)= 差 /max(a, b)= 交 - 实战:纯代码 HUD 雷达扫描界面(圆弧 + 扫描线 + 坐标标记,无贴图),5 步渐进构建
1. 距离:shader 中唯一的"尺子"
前面几章我们学了很多工具——sin()、smoothstep()、mix()——但它们的操作对象都是"颜色"或"数值"。现在面临一个新问题:怎么在 shader 里画一个图形?
没有画笔、没有画布、没有贴图。你手上只有屏幕上的每一个像素,以及一个函数——fragment()——它对每个像素独立运行。画图形的本质,就是对每个像素回答一个二元问题:你在图形内部,还是在图形外部?
要回答这个问题,需要一件工具:距离。
1.1 distance() 和 length()
distance(a, b) 计算两个向量之间的直线距离。在 2D 场景中,最典型的用法是计算当前像素到图像中心的距离:
这行代码的含义:对屏幕上的每一个像素,计算它的 UV 坐标到 (0.5, 0.5) 的距离。越靠近中心,d 越小(中央为 0.0);越靠近角落,d 越大(最远约 0.707)。
length(v) 是等价的写法——distance(a, b) 内部就是 length(a - b):
1.2 把距离"看见":距离场的视觉化
把距离值直接输出为颜色,看看它长什么样:
shader_type canvas_item;
void fragment() {
vec2 uv = UV;
float d = distance(uv, vec2(0.5));
COLOR = vec4(vec3(d), 1.0);
}
保存。你会看到图像中央是黑色(d ≈ 0),向四角渐变为白色(d ≈ 0.707)。这就是一个距离场:每个像素存储的不是"我是什么颜色",而是"我离目标点有多远"。

场思维:你不是在画画——你无法给 GPU 下指令说"在 (100, 200) 处画一个圆"。你只能站在每个像素的位置反问自己:"我在不在某个图形的内部?" 然后自己决定这个像素的颜色。距离场 = 信息(我离目标多远),阈值 = 决策(我算不算在图形内)。信息和决策分离,是 shader 编程中最核心的思维模型。
2. 第一个图元:画一个实心圆
2.1 从阈值到图形
现在你有了每个像素到中心的距离 d。要画一个半径 0.3 的圆,只需要判断:
如果 d < 0.3,这个像素在圆内 → 涂白;如果 d > 0.3,这个像素在圆外 → 涂黑。
step(edge, value) 函数完美地执行这个判断:
为什么是 1.0 - step()?——step(0.3, d) 在 d >= 0.3 时返回 1.0,也就是说圆外的像素得到 1.0。我们要的是圆内为白色(1.0),所以取反。
把整段代码串起来:
shader_type canvas_item;
void fragment() {
vec2 uv = UV;
float d = distance(uv, vec2(0.5));
float circle = 1.0 - step(0.3, d);
COLOR = vec4(vec3(circle), 1.0);
}
屏幕上出现了一个白色实心圆,边缘变化锐利。因为step() 是一个瞬间跳变,从 0 到 1 没有过渡。
2.2 引入 Signed Distance
在继续之前,先引入一个重要的概念升级。当前的画圆代码把"距离计算"和"是否在内部"两件事合为一体了——step(0.3, d) 直接输出 0 或 1。但后面章节要对形状做 min/max 组合操作,这需要一种更原始的表示:带符号的距离值——内部为负、外部为正、边界恰好为 0。
带符号距离的优势在于:
- 负值表示"在里面"——值越小(越负),越深入图形内部
- 正值表示"在外面"——值越大,离图形越远
- 0 代表精确的边界
有了这种表示,距离计算就和可视化彻底分开了。距离函数只管回答"每个像素离图形有多远",而 smoothstep 只负责在最后一步把距离翻译成 0→1 的遮罩。
把圆的距离提取为 sd_circle():
length(uv - center) 是像素到圆心的距离,减去 radius 后:距离小于半径 → 结果为负(在内);距离等于半径 → 结果为零(边界);距离大于半径 → 结果为正(在外)。
可视化交给一个独立的 fill() 函数:
fill() 在 d = 0 附近创建平滑过渡:d 从 -aa 到 +aa 这个宽度为 2*aa 的区间内,输出从 1.0 平滑降到 0.0。aa 是抗锯齿宽度——值越大,边缘越柔和。
完整的画圆代码:
void fragment() {
vec2 uv = UV;
const vec2 CENTER = vec2(0.5);
float d = sd_circle(uv, CENTER, 0.3);
float circle = fill(d, 0.01);
COLOR = vec4(vec3(circle), 1.0);
}
边缘柔和,最关键的是:距离计算(sd_circle)和可视化(fill)各司其职——这正是后续所有章节的共同模式。
2.3 封装与复用
现在你可以在任意位置、任意半径画一个圆——一行 sd_circle() + fill() 就是一个圆:
void fragment() {
vec2 uv = UV;
const vec2 CENTER = vec2(0.5);
float c1 = fill(sd_circle(uv, vec2(0.5, 0.5), 0.3), 0.01);
float c2 = fill(sd_circle(uv, vec2(0.7, 0.3), 0.1), 0.01);
float c3 = fill(sd_circle(uv, vec2(0.3, 0.3), 0.1), 0.01);
float result = c1 + c2 + c3;
COLOR = vec4(vec3(result), 1.0);
}
一行调用一次画圆,三个圆就组成一个小动物。

封装让代码从"数学公式"变成了"画布指令"。本章后续所有图形都遵循这个模式:带符号距离计算(sd_xxx)→ fill() 可视化。
3. 从实心圆到圆环
3.1 环的直觉
圆环是什么?——"到某个参考圆的圆周很近,但离圆心不近"。如果一个圆的半径是 0.3,圆周就是所有离中心距离恰好等于 0.3 的像素。向内外各扩展一小段厚度,就是环。数学上:取距离 d,计算它离目标半径 0.3 差多少——abs(d - 0.3)。如果这个差值很近(在厚度范围内),像素就在环上。
3.2 环的实现
float sd_ring(vec2 uv, vec2 center, float radius, float thickness) {
float d = length(uv - center);
return abs(d - radius) - thickness;
}
逐行拆解:
d= 像素到圆心的距离(和sd_circle一样)abs(d - radius)= 像素到圆周的距离。当d恰好等于radius时,结果为 0——像素正坐在圆周上- thickness:如果像素到圆周的距离小于thickness,结果为负(在环内);否则为正(在环外)
用 fill() 可视化:
比较实心圆和圆环:
| 实心圆 | 圆环 | |
|---|---|---|
| 距离计算 | d = length(uv - center) - radius |
abs(length(uv - center) - radius) - thickness |
| 内部判定 | d < 0 |
d < 0 |
| 核心直觉 | "离中心够近" | "离圆周够近" |
这个模式——带符号距离 + fill() 可视化——适用于所有图形。

4. 矩形:距离在两条轴上的分治
4.1 从径向到轴向
圆的距离是径向的(一维):每个像素只需要知道"离中心多远"。矩形的距离是轴向的(二维):你需要分别知道像素在 X 轴上离左右边多远、在 Y 轴上离上下边多远。处理轴向问题,第一步永远是 abs(uv - center)——把坐标变换到以矩形中心为原点的"局部空间",去掉方向的复杂性:
d.x 和 d.y 的含义:
- 正值 = 像素在该轴上超出了矩形边界
- 负值 = 像素在该轴上位于矩形内部
4.2 矩形距离计算
矩形边界的距离有两个分量:
- 外部距离:像素完全在矩形外面时,到最近边角的直线距离。
length(max(d, 0.0))——把正值(外部)保留、负值(内部)清零,然后取向量长度。 - 内部距离:像素在矩形内部时,到最近边的距离。
min(max(d.x, d.y), 0.0)——取两个分量中离边最近的那个负值。
两者相加得到完整的带符号距离:
float sd_rect(vec2 uv, vec2 center, vec2 half_size) {
vec2 p = uv - center;
vec2 d = abs(p) - half_size;
float outside = length(max(d, 0.0));
float inside = min(max(d.x, d.y), 0.0);
return outside + inside;
}
可视化:fill(sd_rect(uv, CENTER, vec2(0.25, 0.15)), 0.01)——得到一个宽 0.5、高 0.3 的矩形。

这里的策略:把一个二维的"离矩形多远"问题,拆成两个一维轴上的分量叠加。这是 shader 编程中反复出现的模式——高维问题降维处理。
5. 十字星:组合优于重写
十字星本质上是一个横向细矩形和一个纵向细矩形的交汇处。不需要重新推导公式——用两个 sd_rect() + min() 组合即可:
float sd_cross(vec2 uv, vec2 center, float arm_length, float thickness) {
vec2 p = uv - center;
float h_arm = sd_rect(uv, center, vec2(thickness * 2.0, arm_length * 2.0));
float v_arm = sd_rect(uv, center, vec2(arm_length * 2.0, thickness * 2.0));
return min(h_arm, v_arm);
}
min(h_arm, v_arm) 的含义:min() 在带符号距离中取"较近"的那个形状——如果像素在横条内(h_arm < 0)或在竖条内(v_arm < 0),min() 返回负值,像素就是白色。如果两者都在外,返回正值,像素是黑色。效果是:两个矩形自然叠在一起,形成十字。
可视化:fill(sd_cross(uv, CENTER, 0.3, 0.03), 0.01)。

这里的策略:Cross 不是重新推导数学公式,而是横向细矩形和纵向细矩形的并集——组合优于重写。这是
min()在 SDF 中的首次亮相(它的正式名称叫"并集"),后面第 7 节会系统展开。
6. 正多边形:极坐标的威力
6.1 把直角坐标变成角度
前面的图形都在直角坐标系中工作。正多边形需要引入极坐标系:用角度和半径描述一个点的位置。
atan(y, x) 返回从中心到该像素的方向角(以弧度计)。
6.2 正 N 边形公式
正 N 边形的心法是一行公式:
这一行的内涵:把圆周TAU切成 N 等份,每个等份构成一个"楔形"。floor(0.5 + a/section) 算出当前像素落在哪个楔形中,然后在该楔形内计算到"中心线"的角度差,cos() 把它变成距离缩放因子,最后乘以 r 得到到多边形边的距离。
函数封装:
float sd_polygon(vec2 uv, vec2 center, float sides, float radius) {
vec2 delta = uv - center;
float a = atan(delta.y, delta.x);
float r = length(delta);
float section = TAU / sides;
float d = cos(floor(0.5 + a / section) * section - a) * r;
return d - radius;
}
可视化:fill(sd_polygon(uv, CENTER, 6.0, 0.28), 0.01) 画出一个六边形。

不要求完全理解这行公式的数学推导。把它当作"正 N 边形黑盒函数"调用即可。重点在于理解极坐标转换的策略——
atan()提取方向 +length()提取距离——把圆形思维直接迁移到了任意正多边形上。
到这里,我们已经学会了五种基本图元。如果现实项目中需要更复杂的图形,显然不可能每遇到一个新形状就重新推导公式。所以接下来我们学习一套更强大的工具:把图形像搭积木一样组合起来——min() 和 max() 就是你的积木榫卯。
五种基本图元完整代码参见 distance_field.gdshader文件
7. 复合操作:min/max 做形状加减
在本章前面内容中我们知道,sd_xxx() 返回带符号的距离:负值 = 在内部,正值 = 在外部,零 = 边界。有了这种统一表示,min() 和 max() 就能直接对任意形状做布尔运算——这不是巧合,而是 signed distance 体系的最强特性。
7.1 并集:min(a, b) — 两个形状叠在一起
步骤 1:先画两个独立的圆,分别位于画面左右两侧。
步骤 2:用 min() 取两者并集。
效果:min() 取每个像素上两个距离中较小的那个——只要像素在任意一个圆内(距离为负),结果就为负。"离哪个更近,就算哪个"。两个圆合并为一个整体,重叠部分的边界消失,形成 8 字形。

直觉:min() 告诉你"到较近那个形状的距离"——只要你离 A 近或离 B 近,你就是"在附近"。两个形状自然叠在一起。
7.2 差集:max(-a, b) — 挖洞
步骤 3:从一个大圆中"挖掉"一个小圆。
float d_outer = sd_circle(uv, vec2(0.5), 0.35); // 大圆
float d_hole = sd_circle(uv, vec2(0.5), 0.15); // 要挖的洞
float d_diff = max(-d_hole, d_outer); // 差集
效果:大圆中央出现了一个圆形的洞,形成甜甜圈。
max(-d_hole, d_outer) 的含义:
- 在洞的内部,
d_hole < 0,所以-d_hole > 0(正值,表示"这是洞,不要这里的像素")。max(正值, ...)取正值 → 像素被判为"外面"。 - 在洞的外部、大圆的内部,
-d_hole < 0且d_outer < 0→max(负, 负)取较大的负值 → 像素被判为"里面"。 - 在大圆外部,
d_outer > 0→max(..., 正值)取正值 → "外面"。
7.3 交集:max(a, b) — 仅保留重叠部分
步骤 4:只保留两个圆同时覆盖的区域。
效果:只有两个圆都覆盖到的公共区域——中间的透镜形——才显示。
直觉:max() 取两个距离中较大的那个。在带符号距离中,较大 = 不够负(不够深入)。要同时满足"在 A 内"且"在 B 内",像素必须经受更严格的考验——max() 挑出较保守的那个判断。
7.4 速查表
| 操作 | 代码 | 效果 | 直觉 |
|---|---|---|---|
| 并集 | min(a, b) |
两形状合在一起 | "离哪个近算哪个" |
| 差集 | max(-a, b) |
B 挖掉 A 的部分 | "不要洞里的" |
| 交集 | max(a, b) |
只要重叠的部分 | "两个都必须离得近" |
本章不展开 SDF(Signed Distance Function)术语。 但你刚刚学会的就是 SDF 的核心操作——三行
min/max做任意形状之间的布尔运算。
8. 实战:HUD 雷达扫描界面
把以上所有技术组装成一个完整的项目:一个不依赖任何贴图的纯代码雷达扫描界面。这个界面由四层叠加构成,我们逐层渐进构建——每一步增加一个新元素。
先定义所有常量——良好的 shader 工程实践从消除魔术数字开始:
8.1 第一层:外圈轮廓
用 sd_circle() 画一个圆作为雷达边界:
const float OUTER_ALPHA = 0.5;
float outer = fill(sd_circle(uv, CENTER, RADAR_RADIUS), AA);
float radar = outer * OUTER_ALPHA;
这一步增加了:一个柔和的圆形边界,定义了雷达的"屏幕"。* 0.5 把亮度减半——外圈作为装饰边框,不应该抢眼。
8.2 第二层:同心距离环
用 sd_ring() 在雷达内部画等间距的同心环,模拟距离刻度:
const int NUM_RINGS = 7;
const float RING_ALPHA = 0.9;
const float RING_THICKNESS = AA * 0.5;
for (int i = 1; i <= NUM_RINGS; i++) {
float r = RADAR_RADIUS * float(i) / float(NUM_RINGS);
float ri = fill(sd_ring(uv, CENTER, r, RING_THICKNESS), AA);
radar = max(radar, ri * RING_ALPHA);
}
这一步增加了:等间距的距离刻度环。for 循环展开到运行时是固定的迭代次数(Godot shader 不支持 break,但我们可以用条件跳过剩余迭代)。
8.3 第三层:十字坐标线
用 sd_rect() + min() 画纵横两条参考线:
const float CROSS_ALPHA = 0.9;
float cross_h = fill(sd_rect(uv, CENTER, vec2(AA, RADAR_RADIUS)), AA);
float cross_v = fill(sd_rect(uv, CENTER, vec2(RADAR_RADIUS, AA)), AA);
float cross = max(cross_h, cross_v); // fill() 输出的是 0-1 遮罩,max = 并集
cross *= step(length(uv - CENTER), RADAR_RADIUS); // 裁剪在圆内
radar = max(radar, cross * CROSS_ALPHA);
这一步增加了:纵横两条十字参考线,帮助定位目标方向。step(length(uv - CENTER), RADAR_RADIUS) 把十字线裁剪在雷达圆内部——超出边界的线段被切掉。
8.4 第四层:旋转扫描线
扫描线是雷达的灵魂。它绕圆心旋转,扫过之处高亮。
第一件事:用 TIME 和 atan() 计算当前扫描角度:
fract(TIME * ...) 确保角度在 [0, 2π) 内循环。
第二件事:计算每个像素的角度与扫描角度的差,用 angle_delta() 辅助函数归一化到 [-π, π]:
float angle_delta(float a, float b) {
return mod(a - b + PI, TAU) - PI;
}
float angle = atan(uv.y - 0.5, uv.x - 0.5);
float angle_diff = angle_delta(angle, scan_angle);
第三件事:只绘制扫描线"身后"的半圆区域(angle_diff <= 0),把角度差映射为亮度——离扫描线越近越亮:
const float TRAIL_DECAY = 9.0;
float scan_line = 0.0;
if (angle_diff <= 0.0) {
// angle_diff ∈ [-PI, 0] → trail_factor ∈ [0, 1]
// 越靠近 0 越亮,越靠近 -PI 越暗
float trail_factor = clamp(1.0 + angle_diff / PI, 0.0, 1.0);
scan_line = pow(trail_factor, TRAIL_DECAY);
}
scan_line *= step(length(uv - CENTER), RADAR_RADIUS);
radar += scan_line; // 扫描线用加法叠加(而非 max),产生高亮覆盖效果
pow(trail_factor, 9.0) 产生快速衰减——扫描线正前方最亮,向后迅速暗灭。if (angle_diff <= 0.0) 确保只有扫描线"身后"被照亮,前方不受影响。
8.5 成品:合并与着色
四层全部叠加完后,用 mix() 上色:
const vec3 BG_COLOR = vec3(0.0, 0.02, 0.05);
const vec3 RADAR_COLOR = vec3(0.0, 0.9, 0.4);
vec3 col = mix(BG_COLOR, RADAR_COLOR, clamp(radar, 0.0, 1.0));
COLOR = vec4(col, 1.0);
四层效果叠加关系:外圈 → 同心环 → 十字线 → 扫描线。前三层用 max() 合并(取最亮),扫描线用 += 加法叠加(产生高亮覆盖效果)。最后 mix() 把灰度雷达图映射为绿色雷达色。
完整的雷达 shader 代码见
radar.gdshader。

理清两个容易混淆的问题。
问题一:为什么第 7 节用
min()做并集,这里却用max()?因为它们操作的是不同层级的数据: - 第 7 节的
min(a, b)操作的是带符号距离(sd_xxx()的返回值)。内部为负、外部为正。min()取更小的值 = 更深入内部的形状 = 并集。 - 本章的max(a, b)操作的是fill()输出的 0-1 遮罩。1 = 可见,0 = 不可见。max()取更大的值 = 两种可见并存 。两条规则本质相同——只是符号方向相反:带符号距离看"谁更负",0-1 遮罩看"谁更大"。
问题二:为什么大多数层用
max()合并,但扫描线用+=?
max(a, b)确保叠加结果不超过 1.0——适合"互不干扰"的图形层叠。+=允许结果超过 1.0——适合需要"高亮溢出"的效果。雷达的扫描线要压过所有底层画面、成为最亮的部分,如果也用max(),它和最亮的底层环一样亮,就不再"突出"。radar += scan_line让扫描线在底层基础上额外增亮,这正是雷达线该有的视觉特征。
9. 本章小结
这一章你学会的不是三个函数,而是一种看待画面的新方式:
- 距离场思维:每个像素存的不一定是"颜色",而是"到某样东西的距离"。然后你用阈值把距离翻译成图形。
- 函数即形状:一个圆 = 一行
fill(sd_circle(...), aa)调用。一个十字星 =min(sd_rect(...), sd_rect(...))。代码就是画笔。 - 复合 = 语言:
min/max不是简单的数学函数——它们是你的"形状语法",让你用加减逻辑构建任意复杂的图案。 - 渐进构建:雷达不是一下子写完的——先画圆,再加环,再加线,再加动画,再加细节。每加一层,验证一层。这是 shader 编程的工程准则。
前七章,我们一直在学习"如何控制颜色";从这一章开始,我们开始学习"如何生成形状"。从"颜色"到"形状",是 shader 学习过程中的一次重要跨越。后续的程序化纹理、噪声、流体、光照等内容,都会建立在这种"距离场思维"之上——每个像素回答的不是"我是什么颜色",而是"我在不在某个区域内部"。
章末练习
- 改图元参数:画基本图形时,尝试修改
sd_circle()的radius参数和sd_ring()的thickness参数,观察形状变化。尝试把矩形改到屏幕左上角vec2(0.2, 0.2)位置。 - 复合一个新形状:用
min()把两个不同位置、不同大小的圆合并,再用max(-小圆, 大圆)在大圆上挖洞——最终得到一个"甜甜圈"(不是用sd_ring()画的)。提示:直接使用sd_circle()返回的带符号距离。 - 自定义雷达:在
radar.gdshader中,添加第六层——在某个象限画一个小的闪烁目标点(用sin(TIME) * 0.5 + 0.5驱动亮度)。目标点的位置用一个uniform vec2 target_pos控制,target_pos用vec2(cos(...), sin(...))形式的极坐标更直观。