前言
如果你曾經在 Shadertoy 上看過那些令人驚嘆的即時渲染作品,你大概會好奇:這些人到底是怎麼在沒有任何 3D 模型的情況下,純粹用數學就「畫」出複雜的場景?答案的核心之一,就是 Signed Distance Function,簡稱 SDF。
SDF 是一種用數學函數描述幾何形狀的方法。給定空間中的任意一點,SDF 會回傳這個點到形狀表面的最短距離——如果在形狀外面,距離是正的;在裡面,距離是負的;剛好在邊界上,距離是零。這個看似簡單的概念,卻是整個 shader art 世界的基石。
今天我們從最基礎的 2D SDF 開始,一步一步建立你的幾何工具箱。
什麼是 Signed Distance Function?
用最直白的話說:SDF 就是一個函數 f(p),輸入一個座標點 p,輸出一個浮點數。
f(p) > 0:點 p 在形狀外部f(p) < 0:點 p 在形狀內部f(p) = 0:點 p 在形狀的邊界上
這個距離是有符號的(signed),所以我們可以輕鬆判斷內外。在 shader 中,我們通常用 step() 或 smoothstep() 把這個距離值轉換成顏色。
最簡單的 SDF:圓形
一個以原點為中心、半徑為 r 的圓,它的 SDF 就是:
float sdCircle(vec2 p, float r) {
return length(p) - r;
}
length(p) 計算點到原點的距離,減去半徑 r 後,外面的點距離為正、裡面為負。就這麼簡單。
在 fragment shader 裡使用它:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// 正規化座標,讓 (0,0) 在畫面中央
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
// 計算 SDF
float d = sdCircle(uv, 0.5);
// 用 step 把距離轉成黑白
// d < 0 的地方(圓內)為白色
vec3 col = vec3(1.0 - step(0.0, d));
fragColor = vec4(col, 1.0);
}
加上邊框效果
如果你想畫一個只有邊框的圓環,可以用 abs() 取絕對值:
// 邊框寬度 0.02 的圓環
float d = abs(sdCircle(uv, 0.5)) - 0.02;
vec3 col = vec3(1.0 - smoothstep(0.0, 0.005, d));
這裡 smoothstep 提供了抗鋸齒的效果,讓邊緣柔和過渡。
方形的 SDF
方形比圓形稍微複雜一點,但邏輯一樣優雅:
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);
}
這裡 b 是方形的半邊長(half-size)。拆解來看:
abs(p) - b:利用對稱性,把問題簡化到第一象限,然後計算到邊界的距離向量length(max(d, 0.0)):處理角落的情況(兩個分量都 > 0 時,距離是歐幾里得距離)min(max(d.x, d.y), 0.0):處理內部的情況(取最近的邊)
用法:
float d = sdBox(uv, vec2(0.4, 0.3)); // 寬 0.8、高 0.6 的矩形
圓角矩形
只要在最後減去一個圓角半徑 r,方形就變成圓角的了:
float sdRoundedBox(vec2 p, vec2 b, float r) {
return sdBox(p, b - r) - r;
}
這就是 SDF 的美妙之處——形狀的變形往往只需要一行數學。
三角形的 SDF(等邊三角形)
等邊三角形的 SDF 相對複雜,但 iquilezles 給出了一個經典實作:
float sdEquilateralTriangle(vec2 p, float r) {
const float k = sqrt(3.0);
p.x = abs(p.x) - r;
p.y = p.y + r / k;
if (p.x + k * p.y > 0.0) {
p = vec2(p.x - k p.y, -k p.x - p.y) / 2.0;
}
p.x -= clamp(p.x, -2.0 * r, 0.0);
return -length(p) * sign(p.y);
}
雖然看起來不那麼直覺,但核心思路是一樣的:利用對稱性(abs(p.x)),把問題簡化到更小的區域,然後用幾何關係算出距離。
布林運算:組合形狀的魔法
SDF 真正強大的地方在於——你可以用簡單的 min / max 運算來組合形狀。
Union(聯集)
把兩個形狀合在一起:
float opUnion(float d1, float d2) {
return min(d1, d2);
}
取最小值,意味著只要在任何一個形狀內部(距離為負),結果就是負的。
Subtraction(差集)
從第一個形狀中挖掉第二個:
float opSubtraction(float d1, float d2) {
return max(d1, -d2);
}
把 d2 反轉(-d2 讓內外互換),然後取 max,就只保留在 d1 內部但不在 d2 內部的區域。
Intersection(交集)
只保留兩個形狀重疊的部分:
float opIntersection(float d1, float d2) {
return max(d1, d2);
}
取最大值,只有兩個距離都是負的(都在內部)時,結果才為負。
實戰範例
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
float circle = sdCircle(uv - vec2(-0.2, 0.0), 0.4);
float box = sdBox(uv - vec2(0.2, 0.0), vec2(0.3));
// 試試不同的布林運算
float d = opUnion(circle, box); // 聯集
// float d = opSubtraction(box, circle); // 從方形挖掉圓形
// float d = opIntersection(circle, box); // 交集
vec3 col = vec3(1.0 - smoothstep(0.0, 0.005, d));
fragColor = vec4(col, 1.0);
}
Smooth Min:讓形狀融合在一起
標準的 min 會產生銳利的交界線。但如果你想讓兩個形狀像黏土一樣柔和地融合在一起呢?這就要用到 smooth minimum。
iquilezles 提出的 polynomial 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);
}
參數 k 控制融合的程度——越大越柔和。
範例:兩個融合的圓
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
// 兩個圓,一個會跟著時間移動
float c1 = sdCircle(uv - vec2(-0.3, 0.0), 0.3);
float c2 = sdCircle(uv - vec2(0.3 * sin(iTime), 0.0), 0.3);
// smooth union
float d = smin(c1, c2, 0.3);
// 用距離值來上色,增加視覺層次
vec3 col = vec3(0.0);
col = mix(vec3(0.2, 0.6, 1.0), col, smoothstep(0.0, 0.005, d));
// 加上等距線(contour lines)來視覺化 SDF
col = 0.8 + 0.2 sin(50.0 * d);
fragColor = vec4(col, 1.0);
}
當兩個圓靠近時,你會看到它們像液態金屬一樣融合在一起——這就是 smooth min 的魅力。
更多常用的 2D SDF
為了方便你日後查閱,這裡再列幾個常用的:
線段
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);
return length(pa - ba * h);
}
正六邊形
float sdHexagon(vec2 p, float r) {
const vec3 k = vec3(-0.866025404, 0.5, 0.577350269);
p = abs(p);
p -= 2.0 min(dot(k.xy, p), 0.0) k.xy;
p -= vec2(clamp(p.x, -k.z r, k.z r), r);
return length(p) * sign(p.y);
}
星形
float sdStar(vec2 p, float r, int n, float m) {
float an = 3.141593 / float(n);
float en = 3.141593 / m;
vec2 acs = vec2(cos(an), sin(an));
vec2 ecs = vec2(cos(en), sin(en));
float bn = mod(atan(p.x, p.y), 2.0 * an) - an;
p = length(p) * vec2(cos(bn), abs(sin(bn)));
p -= r * acs;
p += ecs clamp(-dot(p, ecs), 0.0, r acs.y / ecs.y);
return length(p) * sign(p.x);
}
SDF 的空間變換
除了布林運算,你還可以對輸入座標做變換來改變形狀。
平移
float d = sdCircle(p - vec2(0.5, 0.3), 0.2); // 把圓移到 (0.5, 0.3)
旋轉
mat2 rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
float d = sdBox(rot(0.785) * p, vec2(0.3)); // 旋轉 45 度的方形
重複(Repetition)
vec2 opRepeat(vec2 p, float spacing) {
return mod(p + 0.5 spacing, spacing) - 0.5 spacing;
}
// 無限重複的圓形
float d = sdCircle(opRepeat(uv, 1.0), 0.2);
這讓你用一個 SDF 就能畫出無限重複的圖案。
小結
SDF 是 shader 程式設計中最優雅的概念之一。今天我們學了:
- SDF 的基本定義:一個回傳有符號距離的函數
- 基礎形狀:圓形、方形、三角形
- 布林運算:union / subtraction / intersection
- Smooth min:讓形狀柔和融合
- 空間變換:平移、旋轉、重複
掌握了這些,你就有了在 shader 世界中「建模」的基本工具。SDF 的美在於它的一切都是數學——沒有頂點、沒有三角面、沒有貼圖,只有純粹的函數。
延伸閱讀
- iquilezles.org — 2D distance functions:收錄了幾乎所有你能想到的 2D SDF
- iquilezles.org — smooth minimum:smooth min 的各種變體
- The Book of Shaders — Shapes:互動式的 SDF 入門教學
下一篇我們會把 SDF 從 2D 帶到 3D,進入 ray marching 的世界——那才是真正讓人著迷的地方。