前言
遞迴(recursion)是程式設計中最優美的概念之一。一個函數呼叫自己,在每一層縮小問題的規模,直到觸及終止條件——這個簡單的機制能夠產生出令人嘆為觀止的視覺圖案。
碎形(fractal)是遞迴在幾何上的具體展現。自相似性(self-similarity)是碎形的核心特徵:放大局部,你會發現它跟整體長得一模一樣。自然界中到處都是碎形——花椰菜的花球、海岸線的輪廓、河流的分支、肺部的支氣管、閃電的路徑。
我第一次用程式畫出碎形樹的時候,盯著螢幕看了好久。明明只是幾行遞迴的程式碼,卻長出了一棵栩栩如生的樹。那一刻我深刻體會到:簡單的規則,重複施行,就能湧現出複雜的結構。
這篇文章會帶你用 p5.js 實作三個經典的碎形:碎形樹、Sierpinski 三角形、Koch 雪花曲線。
遞迴的基本結構
在進入碎形之前,先複習遞迴的基本模式:
function recursiveFunction(parameter) {
// 1. 終止條件(base case)
if (parameter <= threshold) {
return;
}
// 2. 做某件事
// 3. 呼叫自己,縮小問題規模
recursiveFunction(smallerParameter);
}
三個要素缺一不可:
- 終止條件:沒有它,遞迴會無限執行直到 stack overflow
- 本層的工作:每一層遞迴要做的事
- 縮小問題:確保朝終止條件靠近
碎形樹(Fractal Tree)
碎形樹是最直觀的遞迴圖形。規則很簡單:畫一根樹幹,然後在頂端分出兩根較短的分支,每根分支再繼續分裂。
基礎版本
function setup() {
createCanvas(800, 600);
background(30, 30, 40);
}
function draw() {
background(30, 30, 40);
stroke(200);
strokeWeight(1);
// 從底部中央開始
translate(width / 2, height);
branch(120);
}
function branch(len) {
// 終止條件
if (len < 4) {
// 在末端畫一片「葉子」
fill(100, 200, 100, 150);
noStroke();
ellipse(0, 0, 6, 6);
return;
}
// 畫樹幹
strokeWeight(map(len, 4, 120, 1, 6));
stroke(150, 120, 80);
line(0, 0, 0, -len);
// 移動到樹幹頂端
translate(0, -len);
// 右分支
push();
rotate(PI / 6); // 30 度
branch(len * 0.7);
pop();
// 左分支
push();
rotate(-PI / 6);
branch(len * 0.7);
pop();
}
push() 和 pop() 在這裡至關重要——它們保存和恢復座標系的狀態。每次分支時,我們進入一個新的座標系;分支結束後,回到父節點的座標系再畫另一個分支。
互動版本
讓樹隨滑鼠角度變化:
let angle;
function setup() {
createCanvas(800, 600);
}
function draw() {
background(30, 30, 40);
// 滑鼠 x 位置控制分支角度
angle = map(mouseX, 0, width, PI / 12, PI / 3);
translate(width / 2, height);
branch(120);
}
function branch(len) {
if (len < 4) {
let leafSize = random(4, 10);
fill(random(80, 150), random(180, 255), random(60, 120), 180);
noStroke();
ellipse(0, 0, leafSize, leafSize);
return;
}
let sw = map(len, 4, 120, 0.5, 8);
strokeWeight(sw);
stroke(100 + len 0.5, 80 + len 0.3, 50);
line(0, 0, 0, -len);
translate(0, -len);
let shrink = random(0.6, 0.75);
push();
rotate(angle + random(-0.1, 0.1)); // 加點隨機性
branch(len * shrink);
pop();
push();
rotate(-angle + random(-0.1, 0.1));
branch(len * shrink);
pop();
}
加入 random() 讓每幀的樹都略有不同,看起來像在風中搖曳。
Sierpinski 三角形
Sierpinski 三角形是另一個經典碎形。規則:取一個三角形,在中間挖掉一個倒三角,剩下三個小三角形各自重複這個過程。
function setup() {
createCanvas(800, 700);
background(30, 30, 40);
noLoop();
}
function draw() {
let p1 = createVector(width / 2, 50);
let p2 = createVector(50, height - 50);
let p3 = createVector(width - 50, height - 50);
sierpinski(p1, p2, p3, 7);
}
function sierpinski(a, b, c, depth) {
if (depth === 0) {
// 終止條件:畫出實心三角形
fill(
map(a.y, 0, height, 100, 255),
100,
map(a.x, 0, width, 100, 255),
200
);
noStroke();
triangle(a.x, a.y, b.x, b.y, c.x, c.y);
return;
}
// 三邊中點
let ab = p5.Vector.lerp(a, b, 0.5);
let bc = p5.Vector.lerp(b, c, 0.5);
let ca = p5.Vector.lerp(c, a, 0.5);
// 遞迴處理三個子三角形(跳過中間那個)
sierpinski(a, ab, ca, depth - 1);
sierpinski(ab, b, bc, depth - 1);
sierpinski(ca, bc, c, depth - 1);
}
p5.Vector.lerp(a, b, 0.5) 計算兩點的中點——這是 p5.js 的向量線性插值,非常好用。
動態版本
讓 Sierpinski 三角形逐層展開:
let maxDepth = 0;
let timer = 0;
function setup() {
createCanvas(800, 700);
}
function draw() {
background(30, 30, 40);
// 每 60 幀增加一層深度
if (frameCount % 60 === 0 && maxDepth < 8) {
maxDepth++;
}
let p1 = createVector(width / 2, 50);
let p2 = createVector(50, height - 50);
let p3 = createVector(width - 50, height - 50);
sierpinski(p1, p2, p3, maxDepth);
// 顯示資訊
fill(200);
noStroke();
textSize(16);
text('Depth: ' + maxDepth, 20, 30);
text('Triangles: ' + pow(3, maxDepth), 20, 55);
}
深度 7 會產生 $3^7 = 2187$ 個三角形,深度 8 就是 6561 個。碎形的「自相似」特性在這裡清晰可見——每多一層,整體圖案都在局部重現。
Koch 雪花曲線
Koch 雪花是碎形歷史上最早被發現的例子之一(1904 年)。它的規則:將一條線段的中間三分之一替換成一個等邊三角形的兩邊。
function setup() {
createCanvas(800, 700);
background(30, 30, 40);
noLoop();
}
function draw() {
// 初始的等邊三角形三個頂點
let size = 500;
let h = size * sqrt(3) / 2;
let cx = width / 2;
let cy = height / 2 + 50;
let p1 = createVector(cx - size / 2, cy + h / 3);
let p2 = createVector(cx + size / 2, cy + h / 3);
let p3 = createVector(cx, cy - 2 * h / 3);
stroke(150, 200, 255);
strokeWeight(1);
noFill();
// 三邊分別做 Koch 曲線
koch(p1, p2, 5);
koch(p2, p3, 5);
koch(p3, p1, 5);
}
function koch(a, b, depth) {
if (depth === 0) {
line(a.x, a.y, b.x, b.y);
return;
}
// 將線段分成三等分
let oneThird = p5.Vector.lerp(a, b, 1 / 3);
let twoThird = p5.Vector.lerp(a, b, 2 / 3);
// 在中間三分之一上建立等邊三角形的頂點
let dir = p5.Vector.sub(twoThird, oneThird);
let peak = oneThird.copy();
// 旋轉 -60 度(逆時針)
peak.add(createVector(
dir.x cos(-PI / 3) - dir.y sin(-PI / 3),
dir.x sin(-PI / 3) + dir.y cos(-PI / 3)
));
// 遞迴處理四段
koch(a, oneThird, depth - 1);
koch(oneThird, peak, depth - 1);
koch(peak, twoThird, depth - 1);
koch(twoThird, b, depth - 1);
}
Koch 曲線的迷人之處在於它的數學性質:
- 周長趨向無窮大:每次迭代,周長乘以 $4/3$
- 面積卻是有限的:收斂到原始三角形面積的 $8/5$
- 維度是非整數的:Koch 曲線的碎形維度約為 $\log 4 / \log 3 \approx 1.26$
彩色 Koch 雪花
為每一層遞迴加上不同的顏色:
function koch(a, b, depth, maxDepth) {
if (depth === 0) {
// 根據遞迴深度著色
let hue = map(maxDepth - depth, 0, maxDepth, 180, 300);
colorMode(HSB, 360, 100, 100);
stroke(hue, 80, 90);
strokeWeight(1);
line(a.x, a.y, b.x, b.y);
colorMode(RGB);
return;
}
let oneThird = p5.Vector.lerp(a, b, 1 / 3);
let twoThird = p5.Vector.lerp(a, b, 2 / 3);
let dir = p5.Vector.sub(twoThird, oneThird);
let peak = oneThird.copy();
peak.add(createVector(
dir.x cos(-PI / 3) - dir.y sin(-PI / 3),
dir.x sin(-PI / 3) + dir.y cos(-PI / 3)
));
koch(a, oneThird, depth - 1, maxDepth);
koch(oneThird, peak, depth - 1, maxDepth);
koch(peak, twoThird, depth - 1, maxDepth);
koch(twoThird, b, depth - 1, maxDepth);
}
更多碎形靈感
有了遞迴的基礎,你可以嘗試更多碎形:
碎形灌木
三分支版本的碎形樹:
function bush(len, depth) {
if (depth <= 0) return;
stroke(100, 180, 100, 150);
strokeWeight(depth * 0.8);
line(0, 0, 0, -len);
translate(0, -len);
let angles = [-PI / 5, 0, PI / 5];
let shrinks = [0.6, 0.7, 0.6];
for (let i = 0; i < 3; i++) {
push();
rotate(angles[i]);
bush(len * shrinks[i], depth - 1);
pop();
}
}
遞迴方形
在正方形的四個角落放置更小的正方形:
function recursiveSquare(x, y, size, depth) {
if (depth <= 0 || size < 2) return;
let alpha = map(depth, 0, 6, 50, 255);
fill(100, 150, 255, alpha);
stroke(200, 220, 255, alpha);
strokeWeight(0.5);
rectMode(CENTER);
rect(x, y, size, size);
let newSize = size * 0.4;
let offset = size / 2;
recursiveSquare(x - offset, y - offset, newSize, depth - 1);
recursiveSquare(x + offset, y - offset, newSize, depth - 1);
recursiveSquare(x - offset, y + offset, newSize, depth - 1);
recursiveSquare(x + offset, y + offset, newSize, depth - 1);
}
function setup() {
createCanvas(800, 800);
background(30, 30, 40);
noLoop();
recursiveSquare(width / 2, height / 2, 300, 6);
}
效能注意事項
遞迴碎形的計算量呈指數成長。每多一層:
- 碎形樹:節點數 $\times 2$
- Sierpinski:三角形數 $\times 3$
- Koch:線段數 $\times 4$
建議把最大深度控制在 8-10 層以內。如果需要更多細節,可以考慮:
- 只在滑鼠附近的區域增加細節(LOD,Level of Detail)
- 預先計算並儲存,而不是每幀重新遞迴
- 用迭代取代遞迴,避免 call stack 過深
// 用 noLoop() + redraw() 模式
function setup() {
createCanvas(800, 600);
noLoop();
}
function draw() {
background(30, 30, 40);
// 畫碎形...
}
function mouseMoved() {
redraw(); // 只在滑鼠移動時重繪
}
小結
遞迴碎形是程式與數學交匯的美麗產物。今天我們探索了三種經典碎形:
- 碎形樹:最直觀的遞迴圖形,模擬自然界的分支結構
- Sierpinski 三角形:自相似性的完美展示
- Koch 雪花:有限面積卻有無限周長的數學怪物
這些碎形看似只是數學遊戲,但它們揭示了一個深刻的道理:自然界的複雜性往往來自於簡單規則的重複。一棵大樹的形態,本質上就是一個遞迴函數。
延伸閱讀
- Benoit Mandelbrot《The Fractal Geometry of Nature》 — 碎形幾何的開山之作
- Daniel Shiffman, Coding Train: “Fractal Trees” 系列
- 《Generative Art》by Matt Pearson — 用程式碼探索碎形與生成式設計
- p5.js 官方範例中的 Recursion 類別