跳转至

第 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 场景中,最典型的用法是计算当前像素到图像中心的距离:

float d = distance(UV, vec2(0.5));

这行代码的含义:对屏幕上的每一个像素,计算它的 UV 坐标到 (0.5, 0.5) 的距离。越靠近中心,d 越小(中央为 0.0);越靠近角落,d 越大(最远约 0.707)。

length(v) 是等价的写法——distance(a, b) 内部就是 length(a - b)

float d = length(UV - vec2(0.5));    // 完全等价

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) 函数完美地执行这个判断:

float circle_mask = 1.0 - step(0.3, d);

为什么是 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()

float sd_circle(vec2 uv, vec2 center, float radius) {
    return length(uv - center) - radius;
}

length(uv - center) 是像素到圆心的距离,减去 radius 后:距离小于半径 → 结果为负(在内);距离等于半径 → 结果为零(边界);距离大于半径 → 结果为正(在外)。

可视化交给一个独立的 fill() 函数:

float fill(float d, float aa) {
    return 1.0 - smoothstep(-aa, aa, d);
}

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() 可视化:

float r = fill(sd_ring(uv, CENTER, 0.3, 0.05), 0.01);

比较实心圆和圆环:

实心圆 圆环
距离计算 d = length(uv - center) - radius abs(length(uv - center) - radius) - thickness
内部判定 d < 0 d < 0
核心直觉 "离中心够近" "离圆周够近"

这个模式——带符号距离 + fill() 可视化——适用于所有图形。

画一个环


4. 矩形:距离在两条轴上的分治

4.1 从径向到轴向

圆的距离是径向的(一维):每个像素只需要知道"离中心多远"。矩形的距离是轴向的(二维):你需要分别知道像素在 X 轴上离左右边多远、在 Y 轴上离上下边多远。处理轴向问题,第一步永远是 abs(uv - center)——把坐标变换到以矩形中心为原点的"局部空间",去掉方向的复杂性:

vec2 p = uv - center;              // 平移到矩形中心
vec2 d = abs(p) - half_size;       // 到边界的轴向距离(正 = 在外,负 = 在内)

d.xd.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 把直角坐标变成角度

前面的图形都在直角坐标系中工作。正多边形需要引入极坐标系:用角度和半径描述一个点的位置。

float a = atan(uv.y - center_y, uv.x - center_x);   // 角度
float r = length(uv - vec2(0.5));                      // 半径

atan(y, x) 返回从中心到该像素的方向角(以弧度计)。

6.2 正 N 边形公式

正 N 边形的心法是一行公式:

float section = TAU / sides;
float d = cos(floor(0.5 + a / section) * section - a) * r;

这一行的内涵:把圆周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:先画两个独立的圆,分别位于画面左右两侧。

float d_a = sd_circle(uv, vec2(0.35, 0.5), 0.2);
float d_b = sd_circle(uv, vec2(0.65, 0.5), 0.2);

步骤 2:用 min() 取两者并集。

float d_union = min(d_a, d_b);
float result = fill(d_union, 0.01);

效果min() 取每个像素上两个距离中较小的那个——只要像素在任意一个圆内(距离为负),结果就为负。"离哪个更近,就算哪个"。两个圆合并为一个整体,重叠部分的边界消失,形成 8 字形。

并集: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 < 0d_outer < 0max(负, 负) 取较大的负值 → 像素被判为"里面"。
  • 在大圆外部,d_outer > 0max(..., 正值) 取正值 → "外面"。

7.3 交集:max(a, b) — 仅保留重叠部分

步骤 4:只保留两个圆同时覆盖的区域。

float d_inter = max(d_a, d_b);
float mask = fill(d_inter, 0.01);

效果:只有两个圆都覆盖到的公共区域——中间的透镜形——才显示。

直觉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 工程实践从消除魔术数字开始:

const float RADAR_RADIUS = 0.49;
const vec2  CENTER = vec2(0.5);
const float AA = 0.01;

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 第四层:旋转扫描线

扫描线是雷达的灵魂。它绕圆心旋转,扫过之处高亮。

第一件事:用 TIMEatan() 计算当前扫描角度:

const float SCAN_SPEED = 0.3;
float scan_angle = fract(TIME * SCAN_SPEED) * TAU;

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

HUD 雷达最终效果

理清两个容易混淆的问题。

问题一:为什么第 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. 本章小结

这一章你学会的不是三个函数,而是一种看待画面的新方式:

  1. 距离场思维:每个像素存的不一定是"颜色",而是"到某样东西的距离"。然后你用阈值把距离翻译成图形。
  2. 函数即形状:一个圆 = 一行 fill(sd_circle(...), aa) 调用。一个十字星 = min(sd_rect(...), sd_rect(...))。代码就是画笔。
  3. 复合 = 语言min/max 不是简单的数学函数——它们是你的"形状语法",让你用加减逻辑构建任意复杂的图案。
  4. 渐进构建:雷达不是一下子写完的——先画圆,再加环,再加线,再加动画,再加细节。每加一层,验证一层。这是 shader 编程的工程准则。

前七章,我们一直在学习"如何控制颜色";从这一章开始,我们开始学习"如何生成形状"。从"颜色"到"形状",是 shader 学习过程中的一次重要跨越。后续的程序化纹理、噪声、流体、光照等内容,都会建立在这种"距离场思维"之上——每个像素回答的不是"我是什么颜色",而是"我在不在某个区域内部"。


章末练习

  1. 改图元参数:画基本图形时,尝试修改 sd_circle()radius 参数和 sd_ring()thickness 参数,观察形状变化。尝试把矩形改到屏幕左上角 vec2(0.2, 0.2) 位置。
  2. 复合一个新形状:用 min() 把两个不同位置、不同大小的圆合并,再用 max(-小圆, 大圆) 在大圆上挖洞——最终得到一个"甜甜圈"(不是用 sd_ring() 画的)。提示:直接使用 sd_circle() 返回的带符号距离。
  3. 自定义雷达:在 radar.gdshader 中,添加第六层——在某个象限画一个小的闪烁目标点(用 sin(TIME) * 0.5 + 0.5 驱动亮度)。目标点的位置用一个 uniform vec2 target_pos 控制,target_posvec2(cos(...), sin(...)) 形式的极坐标更直观。