前言
如果你看過那些在 Shadertoy 上流動的、像是活生生的有機體一般的紋理,你可能會想:這到底是怎麼做出來的?答案很可能是 Domain Warping。
Domain Warping 的概念簡單到令人驚訝:把一個 noise 函數的輸出拿去當另一個 noise 函數的輸入座標。就這樣。但這個遞迴式的操作會產生令人難以置信的複雜、有機、近乎催眠的視覺效果。
這個技巧最早由 Inigo Quilez(iquilezles.org)推廣開來,他的經典文章 “Warping by Noise” 是每個 shader 藝術家必讀的參考資料。今天我們就來深入拆解這個技巧。
Domain Warping 是什麼?
讓我們先釐清「domain」這個詞。在數學中,函數的 domain 就是它的輸入空間。所以 domain warping 就是「扭曲輸入空間」。
正常的 fBm 呼叫:
float f = fbm(p);
最簡單的 domain warping:
float f = fbm(p + fbm(p));
我們先計算 fbm(p) 得到一個值,然後把它加到原始座標 p 上,再丟給 fbm 算一次。第一次的 fbm 扭曲了第二次的座標空間——空間本身被 noise 變形了。
基礎實作
首先需要我們的 noise 和 fbm 函數:
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f f (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
const mat2 m = mat2(0.8, 0.6, -0.6, 0.8);
float fbm(vec2 p) {
float f = 0.0;
float amp = 0.5;
for (int i = 0; i < 6; i++) {
f += amp * noise(p);
p = m p 2.0;
amp *= 0.5;
}
return f;
}
第一層 Warp
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
uv.x *= iResolution.x / iResolution.y;
vec2 p = uv * 3.0;
// 一層 domain warping
float f = fbm(p + fbm(p + iTime * 0.1));
vec3 col = vec3(f);
fragColor = vec4(col, 1.0);
}
光是這一層就已經比普通的 fbm 有趣多了——你會看到扭曲的、流動的紋理。
Iq 的經典公式
Inigo Quilez 在他的文章中展示了一個更精緻的版本:
f(p) = fbm( p + fbm( p + fbm(p) ) )
三層嵌套,每一層都進一步扭曲空間。
向量版本(更有趣)
把 fbm 的結果當作 2D 向量而非純量,可以產生更豐富的效果:
// 用不同的偏移來產生兩個獨立的 fbm 值,組成向量
vec2 fbm2(vec2 p) {
return vec2(
fbm(p + vec2(1.7, 9.2)),
fbm(p + vec2(8.3, 2.8))
);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
uv.x *= iResolution.x / iResolution.y;
vec2 p = uv * 3.0;
float t = iTime * 0.1;
// iq 經典公式
vec2 q = fbm2(p + t);
vec2 r = fbm2(p + 4.0 q + vec2(1.7, 9.2) + t 0.3);
float f = fbm(p + 4.0 * r);
vec3 col = vec3(f);
fragColor = vec4(col, 1.0);
}
加上顏色
iq 的原始範例用 q 和 r 的值來混合顏色:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
uv.x *= iResolution.x / iResolution.y;
vec2 p = uv * 3.0;
float t = iTime * 0.1;
vec2 q = vec2(
fbm(p + vec2(0.0, 0.0) + t * 0.2),
fbm(p + vec2(5.2, 1.3) + t * 0.15)
);
vec2 r = vec2(
fbm(p + 4.0 q + vec2(1.7, 9.2) + t 0.12),
fbm(p + 4.0 q + vec2(8.3, 2.8) + t 0.08)
);
float f = fbm(p + 4.0 * r);
// 用 q, r, f 來混合顏色
vec3 col = vec3(0.0);
col = mix(vec3(0.102, 0.220, 0.360), // 深藍
vec3(0.667, 0.667, 0.498), // 米黃
clamp(f f 4.0, 0.0, 1.0));
col = mix(col,
vec3(0.0, 0.0, 0.165), // 暗藍
clamp(length(q), 0.0, 1.0));
col = mix(col,
vec3(0.667, 1.000, 1.000), // 亮青
clamp(length(r.x), 0.0, 1.0));
// 最終調色
col = f f f + 0.6 f f + 0.5 f;
fragColor = vec4(col, 1.0);
}
這就是 iq 經典 domain warping demo 的完整程式碼。每次看到它產生的那些像大理石、像木紋、像星雲一般的圖案,我都覺得數學真的是一種藝術。
理解扭曲的幾何意義
為了更直觀地理解 domain warping 在做什麼,我們可以視覺化扭曲場(warp field)本身:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
uv.x *= iResolution.x / iResolution.y;
vec2 p = uv * 4.0;
float t = iTime * 0.2;
// 扭曲向量
vec2 warp = vec2(
fbm(p + t),
fbm(p + vec2(5.2, 1.3) + t)
);
// 視覺化扭曲向量(紅 = x 方向,綠 = y 方向)
vec3 col = vec3(warp.x, warp.y, 0.5);
// 或者畫出「扭曲後的格線」
vec2 warped = p + warp * 2.0;
float gridX = abs(fract(warped.x) - 0.5);
float gridY = abs(fract(warped.y) - 0.5);
float grid = min(gridX, gridY);
col = vec3(smoothstep(0.0, 0.05, grid));
fragColor = vec4(col, 1.0);
}
你會看到原本整齊的格線被 noise 扭曲成波浪狀的有機形狀——這就是 domain warping 的實質。
動態 Domain Warping
加上時間可以讓紋理緩慢流動,非常適合做背景動畫:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
uv.x *= iResolution.x / iResolution.y;
vec2 p = uv * 3.0;
float time = iTime * 0.15;
// 三層 warp,每層用不同的時間速度
vec2 q = vec2(
fbm(p + vec2(0.0) + time * 1.0),
fbm(p + vec2(5.2, 1.3) + time * 0.8)
);
vec2 r = vec2(
fbm(p + 3.0 q + vec2(1.7, 9.2) + time 0.5),
fbm(p + 3.0 q + vec2(8.3, 2.8) + time 0.3)
);
vec2 s = vec2(
fbm(p + 2.0 r + vec2(3.1, 4.7) + time 0.2),
fbm(p + 2.0 r + vec2(7.5, 6.1) + time 0.1)
);
float f = fbm(p + 3.0 * s);
// 深色有機風格
vec3 col = vec3(0.0);
col += vec3(0.2, 0.05, 0.1) * smoothstep(0.0, 0.8, f);
col += vec3(0.5, 0.2, 0.1) * smoothstep(0.3, 1.0, f);
col += vec3(0.8, 0.6, 0.3) * smoothstep(0.6, 1.0, f);
col = 0.8 + 0.2 f;
fragColor = vec4(col, 1.0);
}
用 Domain Warping 產生不同風格
木紋
float woodGrain(vec2 p) {
float warp = fbm(p 2.0) 0.5;
float grain = sin((p.x + warp) 30.0) 0.5 + 0.5;
grain = pow(grain, 0.3);
return grain;
}
// 木頭顏色
vec3 woodColor = mix(
vec3(0.4, 0.25, 0.1), // 深色紋路
vec3(0.7, 0.5, 0.25), // 淺色木頭
woodGrain(uv * 3.0)
);
大理石紋路
float marble(vec2 p) {
float warp = fbm(p * 3.0);
float veins = sin((p.x + p.y) 10.0 + warp 8.0);
veins = abs(veins);
veins = pow(veins, 0.4);
return veins;
}
生物紋路(細胞狀)
float bioPattern(vec2 p, float time) {
vec2 q = vec2(
fbm(p + time * 0.1),
fbm(p + vec2(3.7, 1.2) + time * 0.08)
);
// 用 sin 產生細胞邊界
float f = fbm(p + 3.0 * q);
float cells = sin(f 20.0) 0.5 + 0.5;
cells = smoothstep(0.4, 0.6, cells);
return cells;
}
效能與控制
控制扭曲強度
用一個乘數來控制 warp 的強度:
float warpStrength = 2.0; // 調整這個值
vec2 q = fbm2(p);
float f = fbm(p + warpStrength * q);
值越大,扭曲越劇烈。通常 1.0 到 4.0 之間效果最好。
效能考量
Domain warping 的計算量不小——每一層 warp 都需要完整計算一次 fbm(6 個 octave),三層 warp 就是 18 次 noise 呼叫。
優化建議:
- 外層 warp 可以用較少的 octave(3-4 個就夠了)
- 如果不需要極致的品質,可以用較簡單的 noise 函數
- 在效能敏感的場景中,減少 warp 的層數
// 外層用快速版本
float fbmFast(vec2 p) {
float f = 0.0;
float amp = 0.5;
for (int i = 0; i < 3; i++) { // 只有 3 層
f += amp * noise(p);
p = m p 2.0;
amp *= 0.5;
}
return f;
}
vec2 q = vec2(fbmFast(p), fbmFast(p + 5.2));
float f = fbm(p + 3.0 * q); // 最終層用完整 fbm
小結
Domain warping 是我個人最喜歡的 shader 技巧之一,因為它完美體現了「簡單規則產生複雜結果」的哲學:
- 核心概念:把 noise 的輸出當作另一個 noise 的輸入座標偏移
- Iq 公式:
fbm(p + fbm(p + fbm(p))),多層嵌套產生豐富的有機紋理 - 向量版本:用兩個獨立的 fbm 組成 2D 扭曲向量,效果更自然
- 應用:木紋、大理石、生物紋路、抽象藝術背景
- 顏色映射:用中間變數(q, r)來混合不同的顏色
當你不知道要做什麼 shader 的時候,打開 Shadertoy,隨便寫一個 domain warping,調調參數和顏色——你幾乎一定會得到某個讓你驚嘆的畫面。
延伸閱讀
- iquilezles.org — warping:iq 的原始文章,必讀
- Shadertoy — domain warp:社群的各種 domain warping 創作
- The Art of Code — Domain Warping:YouTube 上的視覺化教學
下一篇我們來聊極座標繪圖——用 atan 和 length 畫出萬花筒般的對稱圖案。