前言

大約在十年前的某個夜晚,我第一次跑出一張 Mandelbrot Set 的圖片時,整個人被震撼到了。一個如此簡單的公式——z = z² + c——竟然能產生無窮無盡的細節,而且無論你放大多少倍,永遠有新的結構出現。那種「在有限中窺見無限」的感覺,讓我從此對碎形幾何著了迷。

這篇文章會帶你理解 Mandelbrot Set 和 Julia Set 背後的數學,然後用 GLSL shader 來即時渲染它們。Shader 的平行運算天生適合碎形——每個像素獨立計算,完美契合 GPU 的架構。

複數迭代:碎形的核心

複數的快速複習

複數由實部和虛部組成:z = a + bi,其中 i² = -1

複數的乘法:

(a + bi)(c + di) = (ac - bd) + (ad + bc)i

特別是, 的計算:

z² = (a + bi)² = (a² - b²) + (2ab)i

所以如果 z = (x, y),那麼 z² = (x² – y², 2xy)。

碎形迭代

Mandelbrot Set 和 Julia Set 的核心都是同一個迭代公式:

z_{n+1} = z_n² + c

從某個初始值 z₀ 開始,反覆套用這個公式。問題是:經過無窮多次迭代後,z 會不會飛到無窮遠?

可以證明,一旦 |z| > 2,z 就必定會飛向無窮。所以我們在迭代時設定一個上限(比如 100 次),如果在此之前 |z| 超過 2,就說這個點「逃逸」了。

Mandelbrot Set

Mandelbrot Set 的定義:

  • 對於複數平面上的每個點 c
  • 令 z₀ = 0
  • 反覆計算 z_{n+1} = z_n² + c
  • 如果 z 永遠不逃逸(|z| 始終 ≤ 2),那 c 屬於 Mandelbrot Set

在圖像上,我們把屬於集合的點塗成黑色,不屬於的點根據逃逸速度(迭代了幾次才逃逸)來著色。

GLSL 基本實作

// Fragment Shader
precision highp float;

uniform vec2 u_resolution; uniform vec2 u_center; // 視窗中心 uniform float u_zoom; // 縮放倍率 uniform int u_maxIter; // 最大迭代次數

void main() { // 將像素座標映射到複數平面 vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y; vec2 c = uv / u_zoom + u_center;

// 迭代 vec2 z = vec2(0.0); int iter = 0;

for (int i = 0; i < 1000; i++) { if (i >= u_maxIter) break;

// z = z² + c float x = z.x z.x - z.y z.y + c.x; float y = 2.0 z.x z.y + c.y; z = vec2(x, y);

// 逃逸判定 if (dot(z, z) > 4.0) break; iter++; }

// 著色 if (iter == u_maxIter) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); // 集合內部:黑色 } else { float t = float(iter) / float(u_maxIter); vec3 color = 0.5 + 0.5 cos(6.28318 (t + vec3(0.0, 0.33, 0.67))); gl_FragColor = vec4(color, 1.0); } }

著色技巧:平滑迭代次數

上面的著色方式有一個問題:由於迭代次數是整數,顏色之間會有明顯的「色帶」。解決方法是用平滑迭代次數:

// 在迭代結束後(逃逸時)
float smoothIter = float(iter) - log2(log2(dot(z, z))) + 4.0;
float t = smoothIter / float(u_maxIter);

原理是利用對數來估算「差多少就恰好逃逸」,得到一個連續的迭代值。效果立竿見影——色帶消失,取而代之的是絲綢般的漸層。

更豐富的調色盤

vec3 palette(float t) {
    // 使用 cosine color palette(iq 的經典方法)
    vec3 a = vec3(0.5, 0.5, 0.5);
    vec3 b = vec3(0.5, 0.5, 0.5);
    vec3 c = vec3(1.0, 1.0, 1.0);
    vec3 d = vec3(0.00, 0.33, 0.67);
    return a + b  cos(6.28318  (c * t + d));
}

// 在 main 中使用 float t = smoothIter / float(u_maxIter); vec3 color = palette(t);

調整 a、b、c、d 四個向量的值,你就能得到完全不同風格的配色方案。iq(Inigo Quilez)的部落格上有很多漂亮的配色範例。

Julia Set

Julia Set 和 Mandelbrot Set 使用完全相同的迭代公式 z = z² + c,但規則不同:

  • Mandelbrot:c 變化(對應每個像素),z₀ = 0
  • Julia:z₀ 變化(對應每個像素),c 固定

換句話說,每個 c 值定義了一個不同的 Julia Set。而 Mandelbrot Set 可以被理解為「所有 Julia Set 的索引」——c 在 Mandelbrot Set 邊界上的 Julia Set 特別有趣,c 在內部的 Julia Set 是連通的,c 在外部的 Julia Set 是不連通的(Cantor dust)。

Julia Set 的 GLSL 實作

precision highp float;

uniform vec2 u_resolution; uniform vec2 u_center; uniform float u_zoom; uniform vec2 u_juliaC; // Julia Set 的 c 參數 uniform int u_maxIter;

void main() { vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y; vec2 z = uv / u_zoom + u_center;

// c 是固定的 vec2 c = u_juliaC;

int iter = 0; for (int i = 0; i < 1000; i++) { if (i >= u_maxIter) break;

float x = z.x z.x - z.y z.y + c.x; float y = 2.0 z.x z.y + c.y; z = vec2(x, y);

if (dot(z, z) > 4.0) break; iter++; }

// 平滑著色 float smoothIter = float(iter) - log2(log2(dot(z, z))) + 4.0; float t = smoothIter / float(u_maxIter);

vec3 color; if (iter == u_maxIter) { color = vec3(0.0); } else { color = 0.5 + 0.5 cos(6.28318 (t * 1.5 + vec3(0.0, 0.15, 0.3))); }

gl_FragColor = vec4(color, 1.0); }

有趣的 Julia Set 參數

不同的 c 值會產生截然不同的 Julia Set:

c = (-0.7, 0.27015)    — 經典的螺旋形
c = (-0.8, 0.156)      — 像樹枝一樣的圖案
c = (0.285, 0.01)      — 像海螺的旋渦
c = (-0.4, 0.6)        — 兔子形狀(Douady's rabbit)
c = (0.355, 0.355)     — 對稱的雪花
c = (-0.54, 0.54)      — 細碎的塵埃

用滑鼠互動:在 Mandelbrot 上選 Julia 參數

一個很酷的做法是:左半邊畫 Mandelbrot Set,滑鼠指到的位置作為 c,右半邊即時顯示對應的 Julia Set。

在 p5.js 中,你可以使用 createGraphics 搭配 shader 來實現:

let mandelbrotShader, juliaShader;
let mandelbrotCanvas, juliaCanvas;

function preload() { mandelbrotShader = loadShader('base.vert', 'mandelbrot.frag'); juliaShader = loadShader('base.vert', 'julia.frag'); }

function setup() { createCanvas(1000, 500); mandelbrotCanvas = createGraphics(500, 500, WEBGL); juliaCanvas = createGraphics(500, 500, WEBGL); }

function draw() { // Mandelbrot mandelbrotCanvas.shader(mandelbrotShader); mandelbrotShader.setUniform('u_resolution', [500, 500]); mandelbrotShader.setUniform('u_center', [-0.5, 0.0]); mandelbrotShader.setUniform('u_zoom', 0.3); mandelbrotShader.setUniform('u_maxIter', 200); mandelbrotCanvas.rect(0, 0, 500, 500);

// 滑鼠位置轉成複數平面座標 let mx = map(mouseX, 0, 500, -2.5, 1.0); let my = map(mouseY, 0, 500, -1.2, 1.2);

// Julia Set(用滑鼠位置作為 c) juliaCanvas.shader(juliaShader); juliaShader.setUniform('u_resolution', [500, 500]); juliaShader.setUniform('u_center', [0.0, 0.0]); juliaShader.setUniform('u_zoom', 0.4); juliaShader.setUniform('u_juliaC', [mx, my]); juliaShader.setUniform('u_maxIter', 200); juliaCanvas.rect(0, 0, 500, 500);

image(mandelbrotCanvas, 0, 0); image(juliaCanvas, 500, 0);

// 十字準線 stroke(255, 80, 80); noFill(); if (mouseX < 500) { line(mouseX, 0, mouseX, 500); line(0, mouseY, 500, mouseY); } }

移動滑鼠時,你會看到右邊的 Julia Set 不斷變化。當滑鼠在 Mandelbrot Set 邊界附近時,Julia Set 最精彩——充滿了複雜的碎形結構。

進階:Burning Ship Fractal

除了經典的 z² + c,還有很多變體。Burning Ship 碎形只改了一行:

// 原本:z = z² + c
// Burning Ship:對 z 的實部和虛部取絕對值後再平方
z = vec2(abs(z.x), abs(z.y));
float x = z.x  z.x - z.y  z.y + c.x;
float y = 2.0  z.x  z.y + c.y;
z = vec2(x, y);

這會產生一個像燃燒的船一樣的圖案,非常壯觀。

效能優化

在 shader 中渲染碎形時,有幾個實用的優化技巧:

提前跳出

// Mandelbrot 主心形和週期2泡泡的快速判定
// 如果 c 在主心形或週期2泡泡內,直接判定為集合內部
float q = (c.x - 0.25)  (c.x - 0.25) + c.y  c.y;
if (q  (q + c.x - 0.25) < 0.25  c.y * c.y) {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    return;
}
if ((c.x + 1.0)  (c.x + 1.0) + c.y  c.y < 0.0625) {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    return;
}

距離估計

使用距離估計(Distance Estimation)可以讓渲染結果更加精細,邊界更銳利:

// 同時追蹤導數 dz/dc
vec2 dz = vec2(1.0, 0.0);
for (...) {
    // dz = 2  z  dz + 1
    dz = vec2(2.0  (z.x  dz.x - z.y * dz.y) + 1.0,
              2.0  (z.x  dz.y + z.y * dz.x));
    // z = z² + c
    z = vec2(z.xz.x - z.yz.y + c.x, 2.0z.xz.y + c.y);
}

float r = length(z); float dr = length(dz); float dist = 2.0 r log(r) / dr;

距離估計值可以用來做更精確的抗鋸齒,或者用於 3D 碎形(Mandelbulb)的光線行進。

小結

碎形是數學中最令人驚嘆的主題之一。一個只有五行的公式,就能產生出無窮的複雜性。而 GLSL shader 讓我們可以即時探索這個無窮,用滑鼠拖動就能深入到你從未見過的奇異角落。

我建議你真的動手跑一次這些 shader,然後開始放大——放大到你覺得夠了的 10 倍以上。你會發現,無論你多深入,永遠有新的驚喜在等著你。

延伸閱讀

  • Inigo Quilez 的部落格:iquilezles.org(碎形著色與距離估計的經典教學)
  • Shadertoy 上搜尋 “mandelbrot” 或 “julia”,有大量即時可玩的範例
  • Numberphile 的 Mandelbrot Set 影片
  • Ben Sparks 在 GeoGebra 上的 Mandelbrot/Julia 互動工具
  • 進階挑戰:嘗試渲染 3D 碎形(Mandelbulb 或 Mandelbox)