前言

如果你玩過 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 是程序化生成的瑞士刀,幾乎所有自然現象的模擬都離不開它:

  1. 基本 fBm:多層 noise 疊加,lacunarity 和 gain 控制頻率和振幅的變化
  2. 旋轉技巧:每一層旋轉座標,消除方向性瑕疵
  3. 應用場景:雲霧、山脈地形、火焰
  4. 變體:Ridged fBm(山脊紋路)、Turbulence(煙霧紋路)

掌握 fBm 之後,你會發現很多看起來超複雜的 shader 效果,核心其實就是 fBm 加上一些創意的顏色映射和空間變換。

延伸閱讀

下一篇我們來聊水面折射與反射——結合 noise 和光線計算來模擬水的效果。