前言

你有沒有看過那種影片:一堆大大小小的圓,一個接著一個地旋轉,像是齒輪組一樣互相帶動,而最末端的筆尖竟然畫出了一個完美的人臉、一隻蝴蝶、或任何你想要的圖案?

那不是魔術,那是傅立葉級數(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  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; } }

程式碼解說

  1. 繪製階段:使用者用滑鼠畫一個圖案,路徑被記錄為一系列 (x, y) 點。
  1. DFT 計算:滑鼠放開時,路徑被取樣並傳入 dft() 函數,得到每個頻率成分的振幅和相位。
  1. 播放階段epicycles() 函數從最大的圓開始,一個接一個地疊加旋轉。每個圓的圓心是前一個圓邊緣的點,半徑是 DFT 得到的振幅,旋轉速度是對應的頻率。
  1. 最末端的點就是所有旋轉圓合力的結果,它的軌跡會重現原始圖形。

為什麼按振幅排序?

注意到我們把 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 或文字