前言
大約在十年前的某個夜晚,我第一次跑出一張 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² 的計算:
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)