前言
你有沒有看過那種影片:一堆大大小小的圓,一個接著一個地旋轉,像是齒輪組一樣互相帶動,而最末端的筆尖竟然畫出了一個完美的人臉、一隻蝴蝶、或任何你想要的圖案?
那不是魔術,那是傅立葉級數(Fourier Series)。
這個來自 19 世紀的數學工具告訴我們一件驚人的事:任何週期性的波形——不管多複雜——都可以被拆解成一組不同頻率的簡單正弦波的疊加。 反過來說,只要疊加足夠多的圓形旋轉,你就能畫出任意形狀。
這篇文章將從傅立葉的直覺開始,帶你理解離散傅立葉變換(DFT),最後用 p5.js 做出旋轉圓畫圖的動畫。
傅立葉的核心直覺
圓形旋轉就是正弦波
一個點在圓周上等速旋轉,它的 x 座標就是 cos(t),y 座標就是 sin(t)。如果我們只看 y 座標隨時間的變化,就得到一條正弦波。
現在,想像有兩個圓:大圓的圓心在原點,小圓的圓心在大圓的邊上。兩個圓以不同的速度旋轉。最終軌跡——也就是小圓邊緣那個點的路徑——會是一條由兩個頻率合成的曲線。
加更多的圓,你就能合成更複雜的曲線。這就是傅立葉的思想。
數學表達
在複數平面上,一個以角頻率 ω 旋轉的圓可以寫成:
c e^(iωt) = c (cos(ωt) + i*sin(ωt))
其中 c 是一個複數,包含了這個圓的半徑(|c|)和初始相位(arg(c))。
傅立葉級數說的是:任何週期函數 f(t) 都可以寫成:
f(t) = Σ cₙ e^(i n ω₀ t) n = -∞ 到 +∞
每個 n 對應一個「圓」——n 為正表示逆時針轉,n 為負表示順時針轉,|n| 越大轉得越快。
離散傅立葉變換(DFT)
在實際應用中,我們不會有連續的函數,而是一組離散的取樣點。假設我們有 N 個點(複數),我們可以用 DFT 來計算每個旋轉圓的係數:
Cₙ = (1/N) Σ xₖ e^(-i 2π n * k / N) k = 0 到 N-1
其中 xₖ 是第 k 個取樣點(作為複數:實部是 x 座標,虛部是 y 座標)。
每個 Cₙ 是一個複數,告訴我們:
- |Cₙ| = 這個圓的半徑
- arg(Cₙ) = 這個圓的初始角度
- n = 這個圓的旋轉頻率
用 JavaScript 實作 DFT
function dft(x) {
// x 是一個陣列,每個元素是 {re, im}
let N = x.length;
let X = [];
for (let k = 0; k < N; k++) {
let re = 0;
let im = 0;
for (let n = 0; n < N; n++) {
let phi = (TWO_PI k n) / N;
re += x[n].re cos(phi) + x[n].im sin(phi);
im += -x[n].re sin(phi) + x[n].im cos(phi);
}
re /= N;
im /= N;
let freq = k;
let amp = sqrt(re re + im im);
let phase = atan2(im, re);
X.push({ re, im, freq, amp, phase });
}
return X;
}
這個函數接收一組複數點,回傳每個頻率成分的振幅(amp)和相位(phase)。
完整的旋轉圓動畫
以下是一個完整的 p5.js 程式,讓使用者用滑鼠畫一個圖案,然後程式會用旋轉圓的方式把它重新畫出來:
let drawing = []; // 使用者畫的路徑
let fourier = []; // DFT 結果
let time = 0;
let path = []; // 旋轉圓畫出的路徑
let state = 'drawing'; // 'drawing' 或 'playing'
function setup() {
createCanvas(800, 600);
}
function draw() {
background(30);
if (state === 'drawing') {
// 使用者正在畫
stroke(255);
strokeWeight(2);
noFill();
beginShape();
for (let p of drawing) {
vertex(p.x, p.y);
}
endShape();
fill(255);
noStroke();
textSize(18);
text("用滑鼠畫一個封閉圖形,放開滑鼠開始動畫", 20, 30);
} else {
// 播放動畫
let v = epicycles(width / 2, height / 2, fourier);
path.unshift(v);
if (path.length > fourier.length + 50) path.pop();
// 畫累積路徑
noFill();
stroke(255, 80, 80);
strokeWeight(2);
beginShape();
for (let p of path) {
vertex(p.x, p.y);
}
endShape();
// 時間前進
let dt = TWO_PI / fourier.length;
time += dt;
// 畫完一圈就重置
if (time > TWO_PI) {
time = 0;
path = [];
}
}
}
function mouseDragged() {
if (state === 'drawing') {
drawing.push({ x: mouseX - width / 2, y: mouseY - height / 2 });
}
}
function mouseReleased() {
if (state === 'drawing' && drawing.length > 10) {
state = 'playing';
// 減少取樣數(太多點會太慢)
let step = max(1, floor(drawing.length / 200));
let sampled = [];
for (let i = 0; i < drawing.length; i += step) {
sampled.push({ re: drawing[i].x, im: drawing[i].y });
}
fourier = dft(sampled);
// 按振幅排序:大圓先畫
fourier.sort((a, b) => b.amp - a.amp);
time = 0;
path = [];
}
}
function epicycles(x, y, fourier) {
for (let i = 0; i < fourier.length; i++) {
let prevX = x;
let prevY = y;
let freq = fourier[i].freq;
let radius = fourier[i].amp;
let phase = fourier[i].phase;
x += radius cos(freq time + phase);
y += radius sin(freq time + phase);
// 畫圓
stroke(255, 255, 255, 40);
strokeWeight(1);
noFill();
ellipse(prevX, prevY, radius 2, radius 2);
// 畫半徑線
stroke(255, 255, 255, 100);
line(prevX, prevY, x, y);
}
return createVector(x, y);
}
function dft(x) {
let N = x.length;
let X = [];
for (let k = 0; k < N; k++) {
let re = 0, im = 0;
for (let n = 0; n < N; n++) {
let phi = (TWO_PI k n) / N;
re += x[n].re cos(phi) + x[n].im sin(phi);
im += -x[n].re sin(phi) + x[n].im cos(phi);
}
re /= N;
im /= N;
X.push({
re, im,
freq: k,
amp: sqrt(re re + im im),
phase: atan2(im, re)
});
}
return X;
}
function keyPressed() {
// 按空白鍵重新開始
if (key === ' ') {
state = 'drawing';
drawing = [];
fourier = [];
path = [];
time = 0;
}
}
程式碼解說
- 繪製階段:使用者用滑鼠畫一個圖案,路徑被記錄為一系列 (x, y) 點。
- DFT 計算:滑鼠放開時,路徑被取樣並傳入
dft()函數,得到每個頻率成分的振幅和相位。
- 播放階段:
epicycles()函數從最大的圓開始,一個接一個地疊加旋轉。每個圓的圓心是前一個圓邊緣的點,半徑是 DFT 得到的振幅,旋轉速度是對應的頻率。
- 最末端的點就是所有旋轉圓合力的結果,它的軌跡會重現原始圖形。
為什麼按振幅排序?
注意到我們把 fourier 陣列按振幅從大到小排序了。這不是必須的——無論順序如何,最終結果都一樣。但排序後,視覺上更好看:大圓決定了形狀的整體輪廓,小圓負責細節。這就像先畫草稿再加細節一樣。
頻率的意義
- k = 0(直流成分):不旋轉的圓,代表圖形的整體平移
- k = 1:旋轉一圈,代表圖形的基本形狀
- k = 2, 3, …:旋轉更快,負責越來越精細的細節
- 高頻成分:振幅通常很小,對應邊緣和尖角
這也解釋了 JPEG 壓縮的原理:去掉高頻成分(小圓),圖形看起來差不多,但資料量大幅減少。
用預設路徑:畫一顆星星
除了手繪,我們也可以用數學定義路徑。以下是一個五角星的例子:
function generateStar(cx, cy, points, outerR, innerR) {
let path = [];
let totalPoints = points * 2;
for (let i = 0; i < totalPoints; i++) {
let angle = (TWO_PI / totalPoints) * i - HALF_PI;
let r = (i % 2 === 0) ? outerR : innerR;
path.push({
re: cx + r * cos(angle),
im: cy + r * sin(angle)
});
}
return path;
}
// 在 setup 中呼叫
function setup() {
createCanvas(800, 600);
let starPath = generateStar(0, 0, 5, 200, 80);
fourier = dft(starPath);
fourier.sort((a, b) => b.amp - a.amp);
state = 'playing';
}
進階:X 和 Y 分別做 DFT
另一種常見的做法是把 x 座標和 y 座標分別做 DFT,然後用兩組旋轉圓——一組水平排列控制 x,一組垂直排列控制 y——最終在交會點畫出圖形。
let fourierX, fourierY;
let pathDrawn = [];
let time = 0;
function setup() {
createCanvas(800, 800);
// 假設 drawing 已有一組取樣點
let x_signal = drawing.map(p => ({ re: p.x, im: 0 }));
let y_signal = drawing.map(p => ({ re: p.y, im: 0 }));
fourierX = dft(x_signal);
fourierY = dft(y_signal);
fourierX.sort((a, b) => b.amp - a.amp);
fourierY.sort((a, b) => b.amp - a.amp);
}
function draw() {
background(30);
// X 方向旋轉圓(畫在上方)
let vx = epicyclesRow(width / 2, 80, fourierX, time, true);
// Y 方向旋轉圓(畫在左方)
let vy = epicyclesRow(80, height / 2, fourierY, time, false);
// 畫交叉線
stroke(255, 255, 255, 30);
line(vx.x, 0, vx.x, height);
line(0, vy.y, width, vy.y);
// 交會點
let pt = createVector(vx.x, vy.y);
pathDrawn.unshift(pt);
if (pathDrawn.length > fourierX.length) pathDrawn.pop();
// 畫路徑
noFill();
stroke(255, 80, 80);
strokeWeight(2);
beginShape();
for (let p of pathDrawn) vertex(p.x, p.y);
endShape();
let dt = TWO_PI / fourierX.length;
time += dt;
if (time > TWO_PI) {
time = 0;
pathDrawn = [];
}
}
這種視覺化方式特別直覺:你可以同時看到 x 方向和 y 方向的頻率分解,以及它們如何組合出最終的圖形。
小結
傅立葉變換是數學中最深刻的工具之一,它告訴我們:複雜是由簡單組合而成的。 任何形狀,不管多麼不規則,都可以被分解成一組圓形旋轉的疊加。這不僅是一個漂亮的數學定理,更是信號處理、圖像壓縮、音訊分析等現代科技的基石。
透過旋轉圓動畫,我們把一個抽象的數學概念變成了可以觸摸和感受的東西。我覺得這就是「程式視覺化」最大的價值——讓公式不再冰冷,讓理解變得自然。
延伸閱讀
- 3Blue1Brown:「But what is a Fourier series? From heat flow to drawing with circles」
- Daniel Shiffman Coding Challenge #130:Drawing with Fourier Transform and Epicycles
- An Interactive Introduction to Fourier Transforms(Jez Swanson 的互動式網頁)
- 嘗試用 SVG 路徑資料作為輸入,讓旋轉圓畫出 Logo 或文字