前言
如果你玩過 Perlin noise 或 simplex noise,你會發現單層 noise 產生的圖案太過「光滑」,看起來像被拉伸的地形圖。真實世界的自然紋理——雲朵、山脈、海岸線、火焰——都有一個共同特徵:自相似性(self-similarity)。無論你用什麼尺度去觀察,細節的結構都和大尺度類似。
Fractional Brownian Motion(fBm) 就是用來模擬這種多尺度結構的技巧。概念非常簡單:把多層不同頻率和振幅的 noise 疊加起來。每一層稱為一個 octave(八度音,借用音樂術語),每往上一層,頻率加倍、振幅減半。
這篇文章將帶你從原理到實作,再到幾種經典的 fBm 應用。
基本概念
單層 Noise
假設我們已經有一個 2D noise 函數(例如 value noise 或 Perlin noise):
// 簡單的 value noise
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);
// 四個角的隨機值
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
// 平滑插值
vec2 u = f f (3.0 - 2.0 * f); // smoothstep
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
這個 noise 在頻率 1 的時候會產生大塊的、平滑的隨機區域。
多層疊加 = fBm
fBm 的公式:
fBm(p) = noise(p) * 1.0
+ noise(p 2) 0.5
+ noise(p 4) 0.25
+ noise(p 8) 0.125
+ ...
每一層的頻率是前一層的倍數,振幅是前一層的某個比例。
標準 fBm 實作
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 6; i++) {
value += amplitude noise(p frequency);
frequency *= 2.0; // lacunarity:頻率倍數
amplitude *= 0.5; // gain:振幅衰減
}
return value;
}
兩個關鍵參數
- Lacunarity(頻率倍數):通常設為 2.0。增大它會讓細節更碎裂,減小它會讓層次更模糊。
- Gain(振幅衰減):通常設為 0.5。增大它會讓高頻細節更突出(粗糙感),減小它會更平滑。
float fbm(vec2 p, float lacunarity, float gain, int octaves) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < octaves; i++) {
value += amplitude noise(p frequency);
frequency *= lacunarity;
amplitude *= gain;
}
return value;
}
試試不同的參數組合:
// 標準雲霧
float cloud = fbm(uv * 3.0, 2.0, 0.5, 6);
// 粗糙地形
float terrain = fbm(uv * 3.0, 2.0, 0.7, 8);
// 平滑起伏
float smooth_hill = fbm(uv * 3.0, 2.0, 0.3, 4);
旋轉每一層:避免方向性瑕疵
一個常見的改進是在每一層疊加之前,對座標做一個旋轉。這可以打破 noise 格點可能產生的軸向對齊感:
// 旋轉矩陣(約 37 度)
const mat2 m = mat2(0.8, 0.6, -0.6, 0.8);
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 6; i++) {
value += amplitude * noise(p);
p = m p 2.0; // 旋轉 + 倍頻
amplitude *= 0.5;
}
return value;
}
這個小技巧會讓 fBm 的結果看起來更自然、更有機。
雲霧效果
雲霧是 fBm 最經典的應用。
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
uv.x *= iResolution.x / iResolution.y; // 修正比例
// 加上時間讓雲飄動
vec2 p = uv 4.0 + vec2(iTime 0.1, iTime * 0.05);
float f = fbm(p);
// 雲霧顏色
vec3 skyColor = vec3(0.3, 0.5, 0.85);
vec3 cloudColor = vec3(1.0, 0.98, 0.95);
// 用 smoothstep 控制雲的密度
float cloud = smoothstep(0.3, 0.7, f);
vec3 col = mix(skyColor, cloudColor, cloud);
// 加上一點太陽光的漸變
float sun = 1.0 - length(uv - vec2(0.7, 0.8));
col += vec3(1.0, 0.8, 0.5) max(sun, 0.0) 0.3;
fragColor = vec4(col, 1.0);
}
立體感的雲
要讓雲有立體感,可以用 fBm 的「假法線」來模擬光照:
// 用 fBm 的梯度當作假法線
float eps = 0.01;
float fx = fbm(p + vec2(eps, 0.0)) - fbm(p - vec2(eps, 0.0));
float fy = fbm(p + vec2(0.0, eps)) - fbm(p - vec2(0.0, eps));
vec3 normal = normalize(vec3(-fx, -fy, 0.05));
vec3 lightDir = normalize(vec3(0.5, 0.8, 1.0));
float lighting = max(dot(normal, lightDir), 0.0);
vec3 col = mix(skyColor, cloudColor (0.7 + 0.3 lighting), cloud);
山脈地形
把 fBm 的值當作高度,就可以生成地形:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
uv.x *= iResolution.x / iResolution.y;
// 地形高度
float height = fbm(uv * 5.0, 2.0, 0.5, 8);
// 根據高度分配顏色
vec3 col;
if (height < 0.3) {
col = vec3(0.2, 0.4, 0.8); // 水
} else if (height < 0.45) {
col = vec3(0.76, 0.7, 0.5); // 沙灘
} else if (height < 0.65) {
col = vec3(0.2, 0.6, 0.15); // 草地
} else if (height < 0.8) {
col = vec3(0.5, 0.4, 0.3); // 山岩
} else {
col = vec3(0.95); // 雪
}
// 簡單的光照(用高度差近似法線)
float eps = 0.005;
float hx = fbm((uv + vec2(eps, 0.0)) * 5.0, 2.0, 0.5, 8);
float hy = fbm((uv + vec2(0.0, eps)) * 5.0, 2.0, 0.5, 8);
vec3 n = normalize(vec3(height - hx, height - hy, eps * 2.0));
float light = max(dot(n, normalize(vec3(1.0, 1.0, 0.5))), 0.0);
col = 0.5 + 0.5 light;
fragColor = vec4(col, 1.0);
}
火焰效果
火焰需要一些技巧:noise 要往上飄動,而且底部要比頂部密度高。
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
// 讓 noise 往上飄
vec2 p = uv * 3.0;
p.y -= iTime * 2.0; // 往上移動
// fBm
float f = fbm(p, 2.0, 0.5, 6);
// 火焰形狀:底部寬、頂部窄
float shape = 1.0 - smoothstep(-0.5, 1.5, uv.y); // 高度漸變
shape *= 1.0 - smoothstep(0.0, 0.5, abs(uv.x)); // 寬度漸變
float fire = f * shape;
fire = smoothstep(0.1, 0.9, fire);
// 火焰顏色(從白到黃到橙到紅到黑)
vec3 col = vec3(0.0);
col = mix(col, vec3(1.0, 0.0, 0.0), smoothstep(0.0, 0.3, fire));
col = mix(col, vec3(1.0, 0.5, 0.0), smoothstep(0.3, 0.6, fire));
col = mix(col, vec3(1.0, 1.0, 0.5), smoothstep(0.6, 0.8, fire));
col = mix(col, vec3(1.0, 1.0, 1.0), smoothstep(0.8, 1.0, fire));
fragColor = vec4(col, 1.0);
}
fBm 的變體
Ridged fBm(脊狀 fBm)
把每一層的 noise 取絕對值再反轉,會產生像山脊一樣的銳利邊緣:
float fbmRidged(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float prev = 1.0;
for (int i = 0; i < 6; i++) {
float n = 1.0 - abs(noise(p)); // 取絕對值再反轉
n = n * n; // 平方讓脊更銳利
value += n amplitude prev; // 用前一層的值調制
prev = n;
p = m p 2.0;
amplitude *= 0.5;
}
return value;
}
Ridged fBm 非常適合模擬山脈的稜線、閃電或是血管網路。
Turbulence
Turbulence 是 fBm 的另一個變體,每一層都取絕對值:
float turbulence(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 6; i++) {
value += amplitude abs(noise(p) 2.0 - 1.0);
p = m p 2.0;
amplitude *= 0.5;
}
return value;
}
Turbulence 產生的紋理更像煙霧或大理石紋路。
效能考量
fBm 的計算成本隨 octave 數線性增長。6-8 層通常夠用了,因為更高頻的細節在螢幕上可能只有次像素大小。
一些優化技巧:
// 根據距離減少 octave(在 ray marching 場景中)
int octaves = int(mix(8.0, 2.0, clamp(distance / 50.0, 0.0, 1.0)));
// 或者用 LOD(level of detail)
float fbmLOD(vec2 p, float lod) {
int oct = max(1, 8 - int(lod));
return fbm(p, 2.0, 0.5, oct);
}
小結
fBm 是程序化生成的瑞士刀,幾乎所有自然現象的模擬都離不開它:
- 基本 fBm:多層 noise 疊加,lacunarity 和 gain 控制頻率和振幅的變化
- 旋轉技巧:每一層旋轉座標,消除方向性瑕疵
- 應用場景:雲霧、山脈地形、火焰
- 變體:Ridged fBm(山脊紋路)、Turbulence(煙霧紋路)
掌握 fBm 之後,你會發現很多看起來超複雜的 shader 效果,核心其實就是 fBm 加上一些創意的顏色映射和空間變換。
延伸閱讀
- The Book of Shaders — Fractal Brownian Motion:互動式的 fBm 教學
- iquilezles.org — fBm:iq 對 fBm 的深入分析
- iquilezles.org — warp:domain warping,下一篇的主題
下一篇我們來聊水面折射與反射——結合 noise 和光線計算來模擬水的效果。