前言

如果你曾經在 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)。拆解來看:

  1. abs(p) - b:利用對稱性,把問題簡化到第一象限,然後計算到邊界的距離向量
  2. length(max(d, 0.0)):處理角落的情況(兩個分量都 > 0 時,距離是歐幾里得距離)
  3. 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 程式設計中最優雅的概念之一。今天我們學了:

  1. SDF 的基本定義:一個回傳有符號距離的函數
  2. 基礎形狀:圓形、方形、三角形
  3. 布林運算:union / subtraction / intersection
  4. Smooth min:讓形狀柔和融合
  5. 空間變換:平移、旋轉、重複

掌握了這些,你就有了在 shader 世界中「建模」的基本工具。SDF 的美在於它的一切都是數學——沒有頂點、沒有三角面、沒有貼圖,只有純粹的函數。

延伸閱讀

下一篇我們會把 SDF 從 2D 帶到 3D,進入 ray marching 的世界——那才是真正讓人著迷的地方。