AI TOOLS · MOTION DESIGN · 2026
QUICK ANSWER
純程式碼動畫,是把每一格畫面寫成「時間的函數」,再交給瀏覽器逐格算出來。在 Claude Code 裡,你用中文描述效果,Opus 5.5 會寫 Canvas 與 WebGL 著色器、渲染縮圖回頭自己檢查,最後用 FFmpeg 合成影片。這篇拆解 12 種效果的原理、指令寫法和踩過的坑。
CHAPTER 01 / WHAT IS IT
事情的起點很隨便。我在 Claude Code 裡打了一句話:「做一支 15 秒的動態設計作品集,秀出你有多會做動態,全力以赴。」
幾輪來回之後,它變成一支 30 秒、9:16 的 IG Reels——12 種動態效果,從最經典的彈跳球,一路做到二十萬顆粒子、光線步進的 3D 和剛體物理。每一個鏡頭都踩在 128 BPM 的拍點上,連配樂都是同一份程式碼用 Web Audio 合成出來的。
整個過程我沒有打開任何剪輯軟體,也沒有拉過一條關鍵影格。
30 秒
9:16 直式 · 60 FPS
1,800
逐格計算的畫面
12
種動態技術
128
BPM,畫面與配樂共用節拍
28 萬
字元的原始碼
0
影片、圖片、音效素材
影片的結構很單純:開場一個問句「純寫 Code 能設計哪些動畫?」,接著 12 段各佔一小節(約 1.9 秒),反應擴散那段是配樂的 breakdown,給了兩小節,最後快剪回顧、落在「全部都用程式碼畫的」。
30 秒時間軸 · 每格一小節(1.875 秒)
題
01
02
03
04
05
06
07
08
09
10
11
12
尾
0s
15s
30s
■ 01–06 經典動態原理
■ 07–12 新世代圖學技術
CHAPTER 02 / HOW IT WORKS
在 After Effects 裡,動畫是關鍵影格之間的內插——你擺好兩個點,軟體幫你補中間。純程式碼動畫把這件事倒過來:沒有關鍵影格,只有一個函數 draw(t),丟進任何一個時間點,它就畫出那一格。
這個設計對動態設計師非常實際。因為每一格都只跟時間有關,所以同一格可以重畫無數次、結果一模一樣;可以開六個瀏覽器同時渲染不同格;也可以在一格裡「多拍幾次」:以這一格為中心、總長 1/120 秒的快門時間裡取五個瞬間疊在一起,就得到真正的動態模糊。
從一句指令到一支影片 · 渲染流程
01
描述效果
在 Claude Code 用中文說
02
寫成 draw(t)
Canvas 2D + WebGL2 著色器
03
逐格渲染
無頭 Chrome,六個同時開工
04
動態模糊
每格取樣 5 次再平均
05
後製
光暈、色差、底片顆粒
06
合成輸出
FFmpeg 壓成 MP4 + 配樂
還有一個很少人注意的關鍵:節拍表。我先請 Claude 寫了一份「譜」——哪一拍有大鼓、哪一拍是拍手、哪裡是 breakdown——畫面和聲音讀的是同一份。所以字母落地、形狀變形、鏡頭轉場,全部天生就對在拍子上,不用靠耳朵在時間軸上一格一格對。
核心程式碼(節錄,略為簡化)
// 128 BPM:一拍 0.46875 秒,16 小節剛好 30 秒
const BPM = 128, BEAT = 60 / BPM;
const bt = b => b * BEAT; // 拍 → 秒
// 阻尼彈簧:整支影片「會彈」的手感都來自這一行
function spring(t, f = 3, z = 0.4) {
if (t <= 0) return 0;
const w = 2 * Math.PI * f, wd = w * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + (z * w / wd) * Math.sin(wd * t));
}
// 大鼓包絡:畫面上所有「隨拍子震一下」都讀這個值
const kickEnv = (t, decay = 9) => envFrom(SCORE.kicks, t, decay);CHAPTER 03 / GETTING STARTED
很多人用 AI 寫程式卡關,是因為 AI 寫完就結束了——它不知道畫面長怎樣。做動畫最重要的一步,是讓 Claude Code 能「看到」自己的作品。
做法是先請它寫一個渲染小工具:輸入場景名稱和幾個時間點,就輸出那幾格的截圖,拼成一張縮圖總表。Opus 5.5 看得懂圖片,渲染完它會自己打開總表,像藝術總監一樣挑毛病——鏡頭停住了 0.1 秒、這個橘配藍變成了洋紅色、字被 IG 的按鈕蓋住——然後回頭改程式,再渲染一次。
Anthropic 官方最近剛好出了一支短片,講的就是這件事:把你平常手動做的檢查交給 Claude,讓它自己跑、自己截圖、自己驗證。做動畫時,這個「驗證迴圈」就是整個流程的心臟。
▲ Claude 官方影片〈Building verification loops in Claude Code〉
看得見的回饋迴圈 · 每個場景至少跑三輪
01
寫程式 Claude 依照你的描述寫出這一段的 draw(t)
02
渲染縮圖 挑 8–10 個關鍵時間點,輸出一張總表;動作細節再加一條「連續 12 格」的膠卷
03
自己看圖 檢查構圖、配色、手機尺寸讀不讀得到、有沒有卡頓或跳格
04
修改再來 改完重新渲染,直到它自己也挑不出問題
第二個關鍵是分工。30 秒、12 種技術,一個對話塞不下,也不該塞在一起。我的做法是先讓 Claude 寫好共用的「骨架」——畫布尺寸、節拍、配色、字體、轉場、後製——再寫一份場景規格書,規定每個場景要交出什麼、安全區在哪、效能上限多少。接著讓 Claude Code 同時派出多個子代理(subagent),一個代理負責一段效果,各自寫、各自渲染、各自看圖修改。
全部完成後,再派另一組代理當審查,每個人只看一個面向:IG 安全區與手機可讀性、逐格找瑕疵、聲畫是否對拍、文案與數據有沒有寫錯。前後幾輪審查挑出幾十個問題,大部分是我自己看影片根本不會發現的——例如轉場比拍點早了 5 格,或是反應擴散在某一格突然爆掉。
第一句指令可以這樣下
「幫我建立一個用程式碼做動態影片的專案:1080×1920、60 fps、30 秒、128 BPM。每一格都要是時間的純函數(不能用 Math.random),用 Canvas 2D 和 WebGL2 畫,用 puppeteer 控制本機的 Chrome 逐格渲染,每格做 5 次取樣的動態模糊,最後用 ffmpeg-static 輸出 MP4。另外寫一個預覽工具,可以指定時間點輸出截圖總表,每次改完都先渲染總表給自己看過再告訴我。」
還沒裝過 Claude Code 的話,可以先看這篇〈Claude Code 完整教學〉,十分鐘就能把環境架好。
這樣的骨架搭好之後,接下來每一種效果,都只是在上面加一個場景。下面兩章就一種一種拆開來看。
CHAPTER 04 / CLASSICS
前六種是每個動態設計師都學過的基本功:時間感、字體、形狀、立體、節奏、流動。這次它們全部由公式驅動:彈跳球的高度來自重力公式,字寬來自可變字體的軸,形狀的回彈來自阻尼彈簧。下面每段都是一小節四拍,拍位從這段開始的那個重拍算起,記作第 0 拍,就像時間軸從 0 秒開始。
EFFECT 01 · TIMING
技術原理
動畫十二法則的第一題,永遠是彈跳球。這段我把它拆成三層一起跑:球本身的壓扁與拉長、畫面中段的洋蔥皮和間距點、上方一條即時畫出來的 graph editor 曲線。三層吃的都是同一個函式 ballHeight(),曲線怎麼彎,球就怎麼跳,整段沒有一格是手 K 的。
核心技術
由落地時間反推彈跳高度(h = g·Δ²/8)、高斯脈衝壓扁、沿速度方向的等面積拉伸、洋蔥皮、等時間間距點、即時曲線編輯器
用到的工具
Canvas 2D、Instrument Serif、JetBrains Mono、Web Audio API(OfflineAudioContext)、puppeteer
難度
●●○○○
做彈跳球,大部分人會先決定球要跳多高,再去調時間。這次我倒過來:先決定球落在第幾拍。六次落地排在這段的第 1、2、2.75、3.25、3.625、3.875 拍,間隔從一拍、四分之三拍一路縮到四分之一拍,光看數字就是一段漸快的節奏。高度再用拋體公式從間隔反推:一次飛行時間為 Δ 的彈跳,頂點高度就是 g·Δ²/8,g 設成每拍平方 2240 像素。間隔越短、跳得越低,物理自然成立,而且每一下都保證踩在拍子上。音軌用同一組拍點排了六個逐漸升高的木琴質感正弦音,落在反拍的那四下再墊一顆輕的大鼓。眼睛跟耳朵拿到的,是同一張拍子表。
壓扁與拉長各有各的規則。壓扁是一個寬度 0.05 秒的高斯脈衝,只在觸地前後那幾格出現,第一下最重,後面一次比一次輕;壓扁時橢圓整個往下挪,讓球底一直貼著地板。球在空中就換成拉長:用前後極短的時間差算出速度向量,橢圓沿速度方向旋轉,長軸乘上 (1+k)、短軸除以 (1+k),面積不變,球才不會像在充氣又放氣。最後一拍前還有一個預備壓縮,接著球心炸開成一個圓形遮罩,把下一段 KINETIC TYPE 從球的位置擴出來。
洋蔥皮是往回推 6 個時間點畫的空心圓,越舊越淡;間距點則每 0.045 拍打一個點。等時間取樣的點,疏密就是速度,這是講 ease 時最好用的一張圖,以前要手畫,這次它跟著球自己長了出來。字幕「Timing / is / everything.」也各自在前三次落地時從遮罩裡升起,用的是 Instrument Serif 斜體。
在 CLAUDE CODE 裡這樣說
「用 Canvas 2D 做一段 4 拍的彈跳球(128 BPM,一拍 0.46875 秒),畫布 1080×1920,底色 #0B0B0E、球用 #F2EDE4、強調色 #FF5A1F。先定六次落地拍點 1、2、2.75、3.25、3.625、3.875,再用 h = g·Δ²/8 從落地間隔反推每次彈跳高度,不要手調。觸地前後 0.05 秒做壓扁(橢圓往下挪、底部貼地),空中沿速度方向拉長並保持面積不變。加 6 層洋蔥皮、每 0.045 拍一個間距點,上方放一條 graph editor 即時畫出 y 曲線、關鍵格和播放頭。三句字幕 Timing / is / everything. 各自在前三次落地時從遮罩升起。所有文字都要在安全區 x 72–930、y 270–1480 之內。每半拍 render 一張靜態圖排成 contact sheet,你自己先看過、確認球每次都剛好在拍點觸地再交給我。」
關鍵程式碼(classicA.js 原始碼節錄,略去繪圖細節,中文註解為說明而加)
const S1 = { floorY: 1134, R: 44, x0: 196, x1: 824, g: 2240, pop: 300 };
S1.land = [1, 2, 2.75, 3.25, 3.625, 3.875]; // 落地拍點 = 音軌上的六個音
// 先有時間,再有高度:飛行時間 Δ 的拋物線,頂點高 g·Δ²/8
S1.hts = S1.land.slice(0, -1).map((b, i) => S1.g * Math.pow(S1.land[i + 1] - b, 2) / 8);
// 壓扁:每次落地前後 0.05 秒的高斯脈衝,越後面越輕
S1.land.forEach((L, i) => {
const dt = (b - L) * BEAT;
q = Math.max(q, Math.min(0.46, 0.42 * tin[i]) * Math.exp(-Math.pow(dt / 0.05, 2)));
});
// 觸地時壓扁(底部貼地),空中沿速度方向拉長(面積不變)
if (q > 0.01) ctx.ellipse(p.x, p.y + R * q, R * (1 + q * 0.85), R * (1 - q), 0, 0, TAU);
else ctx.ellipse(p.x, p.y, R * (1 + k), R / (1 + k), Math.atan2(vy, vx), 0, TAU);
// 硬切進場:進場動畫的時鐘先走 0.15 秒,跟拍子綁定的東西不動
const PRE = 0.15, PRE_B = PRE / BEAT;踩過的坑|第一版的 TIMING 是從零開始長出來的:地板線從中間往兩側展開、graph editor 從左往右揭開、球從一個點彈成原尺寸。單獨預覽時看不出問題,可是整支片接起來,開場 hook 到 TIMING 是一刀硬切,切進來的前幾格幾乎是一張空的黑畫面,看起來像掉格。解法是 0.15 秒的 pre-roll:把「進場動畫」這一類元素的時鐘往前撥 0.15 秒(程式裡的 pb = b + PRE_B),切進來第一格就已經有地板、有曲線框、有球;落地、字幕、間距點這些跟拍子綁死的東西,繼續用真實時鐘,一格都不偏。我後來把這條寫成習慣:硬切的鏡頭,第一格就要能讀。
EFFECT 02 · KINETIC TYPE
技術原理
Archivo 是一套有 wdth 寬度軸的可變字體,從 62 的窄體一路到 125 的寬體。這段的主角是 MOTION 六個字母,每個字母都有自己的一條彈簧:第 1 拍由左到右依序撐到 125,第 2 拍再由右到左收回 86;字重則在字母從遮罩升起時,從 380 一路長到 900。寬度一變,整個字的總寬就跟著變,所以每一格都重新量一次六個字母的字寬,反推出字級,讓 MOTION 永遠剛好 840 像素寬、置中在畫面主軸上。字母撐寬時整個字反而縮小,看起來像在吸氣。
核心技術
可變字體 wdth 軸逐字動畫、FontFace stretch 描述子、逐字錯開的阻尼彈簧、固定總寬反推字級、遮罩升起、縮放穿越轉場
用到的工具
Canvas 2D、FontFace API、Archivo 可變字體、JetBrains Mono、WebGL2 後製(色差、扭曲)、puppeteer
難度
●●●○○
這段最大的技術門檻藏在看不到的地方。在 CSS 裡動可變字體很簡單,font-variation-settings 丟個數字就好;但 Canvas 2D 的 ctx.font 只吃 CSS 的 font 簡寫,塞不進這個屬性。解法有點土、但很穩:同一個 Archivo 的 woff2 檔只讀一次,註冊成 64 個 FontFace,名字從 AW62 排到 AW125,每一個都用 stretch 描述子鎖死一個寬度值,字重則宣告成 100–900 的範圍,照樣能連續調。要畫多寬,就挑對應名字的那個 family。寬度雖然只有 64 個整數階,經過動態模糊的子影格平均,肉眼看到的就是連續的伸縮。
畫面上除了主字,還擺了一整套字體設計工具:cap height 與 baseline 參考線、每個字母底下即時顯示目前的 wdth 數值、下方一組像混音台推桿的軸向面板,六條推桿各自跟著自己的彈簧滑。這些工具在第 2 拍前退場,換六排描邊版 MOTION 從上下滑進來,奇偶排往相反方向捲動、每排速度不同,而且被裁在 y 566 以下,永遠不會蓋到上方的標題區。字母升起的那一刻,音軌上有六下 tick,間隔 0.1 拍,跟字母升起的錯開節奏一模一樣。
最後一拍是整段的收尾:鏡頭對準第一個 O 左側的筆畫,放大倍率照 160 的 inCubic 次方爆衝,錨點同時從 O 滑回畫面中心。O 的黑色筆畫把整個畫面吞掉,下一拍直接接 SHAPE MORPH 的深色底,等於用字體本身當轉場。這一拍裡後製同步把色差拉高、加一點畫面扭曲,音軌是一條跟縮放一起起跳的 whoosh,落在重拍上再墊一記低頻 sub。
在 CLAUDE CODE 裡這樣說
「用 Canvas 2D 做一段 4 拍(128 BPM)的 kinetic type:橘底 #FF5A1F、黑字 #0B0B0E,單字 MOTION 用 Archivo 可變字體。Canvas 不能動 font-variation-settings,所以請把同一個 woff2 註冊成 AW62 到 AW125 共 64 個 FontFace,每個用 stretch 描述子鎖一個寬度,再寫一個 fontA(size, wdth, wght) 幫我挑字體。每個字母各自一條阻尼彈簧(f 1.7、ζ 0.55),第 1 拍由左到右撐到 wdth 125,第 2 拍由右到左收回 86,字母之間錯開 0.075 拍;每格重新量字寬、反推字級,讓整個字固定 840px 寬。第 2 拍後加六排描邊字左右交錯捲動,只能出現在 y 566 以下,避開標題區。最後一拍放大穿過 O 的筆畫當轉場,筆畫要在小節線前的最後一格才吃滿畫面。最後半拍請逐格 render 排成 contact sheet,你自己數一下黑畫面有幾格再交。」
關鍵程式碼(main.js、core.js、classicA.js 原始碼節錄,中文註解為說明而加)
// main.js:同一個 woff2,註冊成 64 個「字寬鎖死」的字體
for (let w = 62; w <= 125; w++) {
const f = new FontFace('AW' + w, buf, { stretch: w + '%', weight: '100 900' });
document.fonts.add(f); loads.push(f.load());
}
// core.js:要多寬,就挑哪一個 family
const fontA = (size, wdth = 100, wght = 900) =>
`${Math.round(clamp(wght, 100, 900) / 10) * 10} ${size.toFixed(2)}px AW${Math.round(clamp(wdth, 62, 125))}`;
// classicA.js:每個字母一條彈簧,第 1 拍左→右撐寬、第 2 拍右→左收回
let w = 62;
w = lerp(w, 125, spring((lb - 1.0 - i * 0.075) * BEAT, 1.7, 0.55));
w = lerp(w, 86, spring((lb - 2.0 - (n - 1 - i) * 0.075) * BEAT, 1.7, 0.55));
// 字母變寬,字級就縮:整個字永遠 840px 寬,並跟著大鼓微微脈動
const size = FIT_W / total * (1 + 0.025 * kickEnv(t, 12));
// 縮放穿越 O:終點刻意設在小節線之後(4.1 拍),切點時曲線還在加速
const zu = inv(3.0, 4.1, lb), z = Math.pow(160, Ease.inCubic(zu));踩過的坑|縮放穿越的第一版,時間抓的是「在小節線前完成」,結果筆畫太早吃滿畫面:下一段 SHAPE MORPH 的重拍還沒到,畫面已經黑了 6 格(60 fps 下是 0.1 秒)。單看只是一眨眼,接上音樂就很明顯,重拍像是晚了半步。修法是把縮放曲線的終點刻意推到小節線之後,inv(3.0, 4.1, lb) 裡的 4.1 永遠播不到,切點時曲線還在加速,筆畫要到小節線前不到一格才真正吞滿畫面,剩下那一點再用實色補齊。黑幕被壓到只剩最後一格,重拍就回到拍子上了。轉場的終點不一定要落在畫面裡,這是我這次學到最實用的一件事。
EFFECT 03 · SHAPE MORPH
技術原理
做形狀變形,最麻煩的一直是「對點」:方形 4 個角、星形 10 個角,錨點數量不同,硬補間就會扭成一團,用過 Illustrator 漸變工具的人都懂。這段換了一種描述形狀的方式:從圓心往外等角度射出 256 根輻條,每一根只記一件事,在這個方向上,邊緣離中心多遠。圓就是 256 個 1;方形、三角形、星形用射線去撞多邊形的邊,把交點距離存下來;花形直接用 1.02 + 0.15·cos(6θ) 算。所有形狀都是同樣長度的一條數字陣列,變形就只是把兩條陣列逐格內插。
核心技術
極座標半徑陣列(256 取樣)、射線與多邊形求交、阻尼彈簧過衝、對稱角旋轉、時間延遲的 riso 錯版色影、磁吸點陣、match cut
用到的工具
Canvas 2D、自寫 spring() 阻尼彈簧、JetBrains Mono;結尾交棒給下一段的 WebGL2 / GLSL raymarching
難度
●●●○○
五個關鍵格落在這段的第 1、2、3、3.5、3.75 拍,依序是方、三角、星、花、圓,間隔一拍、一拍、半拍、四分之一拍,越變越急,把人推向下一段。每次變形都由一條 f 2.6、ζ 0.4 的彈簧驅動,會先衝過頭再彈回來,形狀因此有了重量。spring() 寫的是阻尼振盪的公式解,丟一個時間進去,直接回傳那一刻的位置,不用一格一格往下模擬,所以 headless Chrome 跳到任何一格去渲染,算出來的畫面都一樣。旋轉也跟著同一條彈簧走,角度選得很賊:方形轉 90 度、三角形 120 度、星形 144 度,都是各自的對稱角,轉完姿態是穩的,過程裡卻多了一圈甩動的能量。音軌在每一次變形的拍點上放一聲往上滑的 zip 音。
主形狀後面疊了兩層色影,一層鈷藍 #2F3CFF、一層橘紅 #FF5A1F,形狀一樣,但分別取 0.075 秒與 0.04 秒之前的狀態,再往右下錯開 32 與 16 像素。它們是「稍早的自己」,所以彈簧過衝的那幾格,色影會跟主形狀分家,像 riso 套色沒對準;形狀一停,又乖乖疊回去。背景是 44 像素的點陣,每個點先問一句「形狀在我這個方向的半徑是多少」(radiusAt() 在 256 根輻條之間內插),再依照離邊緣的距離被往外推、被放大,形狀一變,整片點陣就像被磁鐵推開。點陣在標題區 y 560–700 之間淡出,不跟文字搶。
這段真正的任務在結尾。它最後必須停在圓心 (540, 990)、半徑 260 像素的正圓,因為下一段 CLAY 3D 的第一格,是一顆位置、輪廓都和它完全重疊的 raymarching 球體。3D 那邊的焦距是反推出來的:讓距離鏡頭 5 個單位的單位球,投影半徑剛好等於 260 像素;兩段連大鼓帶來的 5% 縮放脈衝都用同一條公式。平面的圓在切點那一格被打上光影,就這樣變成立體。
在 CLAUDE CODE 裡這樣說
「用 Canvas 2D 做一段 4 拍(128 BPM)的 shape morph:底色 #0B0B0E、主形狀 #F2EDE4,圓心固定在 (540, 990)、基準半徑 260px。所有形狀都存成 256 個等角半徑取樣的陣列(圓、方、三角、十角星、六瓣花),多邊形用射線求交點算半徑,變形就逐格內插陣列,不要做錨點對應。關鍵格放在這段的第 1、2、3、3.5、3.75 拍,用 f 2.6、ζ 0.4 的阻尼彈簧驅動,要看得到過衝;旋轉角度用各形狀的對稱角。主形狀後面疊兩層延遲 0.04 秒與 0.075 秒的色影(橘 #FF5A1F、藍 #2F3CFF),錯開 16 與 32px 做 riso 感;背景 44px 點陣要被形狀邊緣往外推,並在 y 700 以上淡出。最後 0.2 拍強制鎖回完美正圓,因為下一段要 match cut 到同位置、同半徑的 3D 球。每拍 render 一張,再把最後 3 格單獨 render 出來,你自己放大確認最後一格是乾淨的正圓、色影已經收回來。」
關鍵程式碼(簡化版,取自 classicA.js 的 SHAPES、FORM_KEYS 與 formState(),拿掉標註用欄位,程式裡的拍數從 TIMING 起算,所以第 9 拍=這段的第 1 拍)
const N = 256; // 每個形狀 = 256 根等角輻條的長度;多邊形由射線撞邊求交點(fromVerts)
const SHAPES = {
circle: new Float32Array(N).fill(1), star: fromVerts(star),
flower: Float32Array.from({ length: N }, (_, i) => 1.02 + 0.15 * Math.cos(6 * i / N * TAU)),
square: fromVerts(reg(4, 1.24, Math.PI / 4)), tri: fromVerts(reg(3, 1.36, -Math.PI / 2)),
};
const FORM_KEYS = [ // 拍點、目標形狀、對稱角旋轉
{ b: 9, s: 'square', rot: 90 }, { b: 10, s: 'tri', rot: 120 }, { b: 11, s: 'star', rot: 144 },
{ b: 11.5, s: 'flower', rot: 60 }, { b: 11.75, s: 'circle', rot: 36 },
];
for (const K of FORM_KEYS) {
if (b < K.b) break;
const p = spring((b - K.b) * BEAT, 2.6, 0.4), tgt = SHAPES[K.s]; // 會過衝的彈簧
for (let i = 0; i < N; i++) r[i] = lerp(r[i], tgt[i], p);
rot += K.rot * Math.PI / 180 * p;
}
const lock = tw(b, 11.8, 11.985, Ease.inOutCubic); // 交棒前強制鎖回正圓
if (lock > 0) for (let i = 0; i < N; i++) r[i] = lerp(r[i], 1, lock);踩過的坑|彈簧會過衝,這是它迷人的地方,也是 match cut 的天敵。最後一個圓形關鍵格在這段的第 3.75 拍才起跳(程式裡寫成 11.75,因為這支 scene 的時鐘從 TIMING 就開始算),離交棒只剩四分之一拍(約 0.12 秒);f 2.6 的彈簧走到這裡大約只完成 88%,前一個花形的起伏還殘留在邊緣上,有幾個像素的波紋。切到 3D 球的那一格,輪廓就會「跳」一下,整個 match cut 的魔法瞬間破功。解法是在最後不到 0.2 拍加一道鎖:lock = tw(b, 11.8, 11.985),把 256 根輻條強制內插回 1;riso 色影的錯位、衛星光點、標註線,也都排在交棒前收乾淨。切點那一格保證是一個乾乾淨淨的正圓,3D 球才接得住。
EFFECT 04 · CLAY 3D
技術原理
這段用的是 raymarching 加 SDF(signed distance field,有號距離場)。畫面上沒有多邊形、也沒有匯入任何模型,物件只是一條公式,例如球就是 length(p) - 1.0。每個像素從相機射出一條光線,問公式「離最近的表面還有多遠」,就放心往前走那麼遠,最多走 140 步,直到撞上表面。想像在全黑的房間裡拿一根會報距離的手杖,每一步都敢跨到剛好不撞牆的長度。
核心技術
SDF raymarching、距離場內插變形、smooth union、軟陰影、AO、Fresnel 輪廓光、ACES tone mapping
用到的工具
WebGL2 / GLSL 片段著色器、Canvas 2D(左下角規格標註)、headless Chrome(puppeteer)逐幀輸出
難度
●●●●○
這一段我最在意的是「交接」。上一段 SHAPE MORPH 收在一個米白色的平面圓,半徑 260px、圓心在 (540, 990)。這段第 0 拍一到,這個圓要在同一格畫面裡被 3D 球取代,觀眾不能察覺換了東西。做法是把相機焦距反推出來:單位球在距離 d 的地方,投影輪廓半徑是 fl ÷ √(d² − 1),相機放在距離 5,所以焦距就等於 260 ÷ 540 × √24。再把投影中心(主點)平移到圓心上,球的輪廓就跟那個平面圓分毫不差地疊在一起。
接著是打光的魔術。前 0.7 拍裡,一個叫 uShade 的參數從 0 推到 1,同時做兩件事:顏色從平面的米白混到打光後的黏土橘,光源方向從「相機正後方」滑到左上方的主光。從相機方向打過去的光,會把球照成一個沒有明暗的圓盤,所以一開始它看起來還是 2D;光源一轉開,陰影從邊緣長出來,體積就浮上來了。有點像在 Illustrator 裡把純色圓慢慢換成漸層網格,這次的明暗則由著色器依光源方向算出來。
之後每一拍換一個形狀:第 1 拍是會扭轉的圓角方塊,第 2 拍是甜甜圈,第 3 拍是五顆互相繞行、用 smin(smooth union)融在一起的 metaball。形狀之間的過渡是把兩條距離公式直接內插,進度交給 spring(f 2.8, ζ 0.42),會微微衝過頭再彈回來,看起來就像黏土被手指捏了一下。每一下 kick,表面還會疊一層極小的正弦起伏,整顆黏土跟著大鼓抖。黏土感來自三層光:寬而軟的漫射、大面積的柔和高光,加上一個很小很銳的亮點;再疊上 56 步的軟陰影和 5 次取樣的 AO,最後過 ACES tone mapping,暗部才不會死黑。
退場也是設計過的:第 3.45 拍開始,物件用 Ease.inBack 先微微膨脹再往內塌縮,bloom 同時拉高,剛好在下一小節的重拍塌成一道白光,直接切進下一段的方塊網格。另外 WebGL 的反鋸齒是關掉的,因為動態模糊的 5 張子幀各自帶一個不同的次像素偏移,平均起來反鋸齒和動態模糊一次就做完了。
在 CLAUDE CODE 裡這樣說
「用 WebGL2 寫一個 raymarching 的 SDF 場景,畫面 1080×1920、60fps。上一段結尾是半徑 260px、圓心在 (540, 990) 的米白色平面圓(#F2EDE4),請反推相機焦距,讓 3D 球在相機距離 5 時投影半徑剛好也是 260px,並把主點平移到圓心,做到無縫交接。128 BPM:第 0 到第 1 拍之間從平面色過渡到黏土打光,之後每拍換一個形狀,依序是會扭轉的圓角方塊、甜甜圈、5 顆 smooth union 的 metaball,形狀之間用 spring 內插距離場,kick 時表面輕微抖動。材質 #FF5A1F,要有軟陰影、AO、地板陰影,輪廓光用 #2F3CFF 但只准出現在暗部。上方 y 290–540 是標題區,物件不要碰到。每一拍 render 一張 still 給我檢查。」
關鍵程式碼(scenes/classicA.js 原始碼節錄,拼接多個片段,註解改寫成中文)
// JS:反推焦距,距離 5 時球的投影半徑 = 平面圓的 260px const FR = 260, HALF = 540, FL = FR / HALF * Math.sqrt(24); // GLSL:兩個形狀之間,直接內插兩條距離公式(uP 由 spring 驅動) float d = uA == uB ? shape(uA, q) : mix(shape(uA, q), shape(uB, q), uP); // GLSL:五顆 metaball 用 smooth min 黏成一團 d = smin(d, length(p-c) - 0.44, 0.48); // GLSL:uShade 0→1,光源從相機方向滑到左上主光 vec3 L = normalize(mix(normalize(uCamPos), Lkey, uShade)); float sh = mix(1., 0.2 + 0.8*shadow(p + n*0.004, L, 6.), uShade); float oc = ao(p, n); float fre = pow(clamp(1. + dot(n, rd), 0., 1.), 3.); c += lin(uRim)*fre*(1. - 0.85*dif*sh)*0.9*oc; // 輪廓光乘上「沒被照到的程度」 col = mix(uFlat, lit, uShade); // 平面色 → 打光結果
踩過的坑|第一版的輪廓光是直接用 Fresnel 疊上去的:鈷藍 #2F3CFF 加在黏土橘 #FF5A1F 上。算式沒錯,看起來卻很廉價,受光面的邊緣被藍色一混,整圈變成洋紅色,像 2010 年的漸層按鈕。修法只有一個乘數:輪廓光強度乘上 (1 - 0.85 × 漫射 × 陰影),主光照得到的地方,藍色幾乎歸零;只有背光的那一側才留下來。結果就是上圖那樣,受光面維持乾淨的橘,藍紫色只貼在甜甜圈內圈和右下的暗部,讀起來像環境反射回來的冷光。這種「補色輪廓光只放暗部」的規則,打光師早就知道,寫進 prompt 裡能省你一輪來回。
EFFECT 05 · RHYTHM
技術原理
整個網格是 9 × 11 塊、每塊 120px 的方磚,全部用 Canvas 2D 畫。每一塊都是同一套流程:先用 clip() 把畫布裁成方格,填底色,再畫一個幾何圖形。圖形只有 6 種(四分之一圓、半圓、圓點、三角形、對角雙扇形、圓環),都寫在 −1 到 1 的單位座標裡,縮放、旋轉之後再蓋上去,跟 Illustrator 裡拖一個 symbol 進來改大小是同一個概念。
核心技術
錯位延遲(stagger)、彈簧旋轉、動作中換色、固定種子亂數配色、y 軸壓扁收合
用到的工具
Canvas 2D(clip、arc、Path2D)、mulberry32 固定種子亂數、自寫 spring() 與 Ease 曲線
難度
●●○○○
動起來的祕密是「人浪」。每一塊磚其實只會做一件事:轉 90 度。可是第 1、2、3 拍各送出一道波,每道波的起跑順序不同,第一道從左上掃到右下,第二道從右上掃到左下,第三道從畫面中心往外擴散。每塊磚的延遲大約是 0.018 秒乘上它在波裡的位置,單看一塊很無聊,99 塊一起看,就是一道從畫面上滾過去的浪。旋轉用 spring(f 2.9, ζ 0.45),轉到定位會多甩一點再回正,磚塊才有重量。
換色的時機是我最喜歡的細節。每塊磚開場前就用固定種子(mulberry32,種子 4242)預先配好 4 組造型:黑底為主、米白底為主、橘底為主、鈷藍配檸檬黃。當彈簧轉過一半,也就是磚塊側到最斜的那一瞬間,它就偷偷換成下一組顏色和圖形。換裝藏在動作裡,眼睛只會覺得「整片網格變色了」,抓不到切換點。這跟剪接師把 cut 藏在動作中間是同一招。另外每一下 kick,圖形會放大 7%,網格整片跟著大鼓呼吸。
版面上,網格從 y 600 才開始往下排,讓出上方的標題區;進場時每塊磚從畫面中心附近依距離彈出來。第 1 拍前後,一條三列磚高、360px 的黑色帶從左邊擦進來,上下各壓一條 4px 的橘線,中間放 300px 的 Instrument Serif 斜體 rhythm。瑞士風格的硬網格配上一個柔軟的斜體襯線字,張力就出來了。這條帶子在第 2.9 拍往右擦出,把舞台還給網格,接著最後一拍,每一列磚沿著 y 軸被壓扁,壓到剩原本三成多高的時候疊上一條米白細線,11 列磚變成 11 條橫線。這 11 條線就是下一段 FLOW 的起點。
在 CLAUDE CODE 裡這樣說
「用 Canvas 2D 做一個包浩斯風格的方磚網格:9 × 11 格、每格 120px,從 y 600 開始往下排,避開上方 y 290–540 的標題區。每格一個底色加一個幾何圖形(四分之一圓、半圓、圓點、三角形、對角雙扇形、圓環),顏色只用 #0B0B0E、#F2EDE4、#FF5A1F、#2F3CFF、#FFD23F,用固定種子的亂數預先配好 4 組造型,圖形色不能跟底色撞色。128 BPM:第 0 拍從中心往外彈出;第 1、2、3 拍各一道旋轉波,每格用 spring 轉 90 度,波的方向依序是對角線、反對角線、由中心擴散,轉過一半時換下一組造型;每下 kick 圖形放大一點。第 1 拍擦進一條三格高的黑色帶,放 Instrument Serif 斜體的 rhythm,第 3 拍擦出。最後一拍把每一列壓扁成一條線,最後一格要在下一個 downbeat 就已經讀起來是線。每半拍 render 一張 still 給我看。」
關鍵程式碼(scenes/classicB.js 原始碼節錄,每塊磚每一幀的狀態計算,註解改寫成中文)
const d = Math.hypot(T.i - OI, T.j - OJ); // 離中心幾格
const app = spring(lt - d * 0.028, 2.6, 0.5); // 進場:由中心往外彈出
const delays = [(T.i + T.j) * 0.018, // 第 1 道波:左上 → 右下
(TC - 1 - T.i + T.j) * 0.018, // 第 2 道波:右上 → 左下
d * 0.03]; // 第 3 道波:由中心擴散
let rot = T.rot * Math.PI / 2, ci = 0;
waves.forEach((wb, k) => { // waves = [1, 2, 3](拍)
const s = spring(lt - bt(wb) - delays[k], 2.9, 0.45);
rot += s * Math.PI / 2; // 每道波轉 90°
if (s > 0.5) ci = k + 1; // 轉過一半 → 換下一組造型
});
const [bgc, fgc] = T.cols[ci % 4], type = T.types[ci % 4];
// 收合:從 downbeat 倒推的起始拍,y 軸壓扁
const c0 = collapseAt(T); // 3.35 + j * 0.036 + i * 0.012
const sy = 1 - Ease.inOutExpo(inv(c0, c0 + 0.32, lb));
ctx.translate(cx, cy); ctx.rotate(rot);
const k = hs * (1 + 0.07 * kick); ctx.scale(k, k * sy);
ctx.fillStyle = C(fgc); tileShape(ctx, type);踩過的坑|stagger 有個很容易忽略的副作用:延遲是往後加的,所以最後一塊磚一定比第一塊晚結束。如果收合的起點抓在「第 3.5 拍大家一起開始,再各自加延遲」,右下角那幾塊會拖過下一小節的重拍的 downbeat,下一段的線已經長出來,上一段的磚還半扁著,交接就糊了。這段的做法是從 downbeat 倒推:collapseAt 從第 3.35 拍起跳,每往下一列加 0.036 拍、每往右一欄加 0.012 拍,右下角最後一塊在第 3.81 拍開始折,配上 inOutExpo 前慢後快的尾巴,到下一小節的重拍已經壓到原本一成左右的高度,加上那條米白細線,讀起來就是一條線。還有一個小坑也順手擋掉了:亂數配色偶爾會讓圖形色跟底色抽到同一個,那塊磚看起來就是空的,所以配色時如果撞色,會重抽最多 9 次。
EFFECT 06 · FLOW
技術原理
畫面是 58 條水平線,每條線每隔約 8px 取一個點,點的高度由 Perlin noise 決定。noise 用的是三維版本,三個軸分別餵進 x 座標、線的編號、時間,所以相鄰的線長得像但不一樣,時間往前走,整片地形就開始流動。遮擋的做法很老派:從最後面那條(畫面最上方)畫到最前面,每畫一條線,就先用背景色把「這條線以下到畫面底部」整塊填滿,後面的線自然被蓋掉。這就是畫家演算法,跟油畫先畫遠山、再畫近景一樣。
核心技術
3D Perlin noise、畫家演算法遮擋、高斯窗、kick 行進脈衝、文字插入景深層
用到的工具
Canvas 2D(Path2D、createLinearGradient)、自寫 noise3() Perlin 雜訊、Instrument Serif 字型
難度
●●●○○
看過 Joy Division《Unknown Pleasures》封面的人,應該一眼就認得這種山脊線。讓它好看的關鍵是幾個「限制」。第一,每條線的起伏會乘上一個以畫面中心為準的高斯窗,寬度約 250px,山只在中間隆起,兩側平平地收掉,構圖才有焦點。第二,noise 的值先加 0.22 再砍掉負數,只留山峰、不要山谷,底線乾淨,眼睛才讀得出這是一片地形。第三,越後面的線振幅越小,只有前景的 55%,這讓景深自己跑出來,也讓上方的標題區保持安靜。
它跟音樂的關係藏在脈衝裡。過去 1.4 秒內的每一下 kick,都會在地形上送出一個高斯形狀的小山丘,以每秒 1400px 的速度從左往右跑,邊跑邊衰減;每條線的起跑點再錯開幾 px,脈衝經過時是斜斜地掃過整片山。大鼓一下,一道浪就從畫面上滾過去,等於把低音「畫」出來。線條本身用線性漸層上色,兩側是米白,正中間是 #FF5A1F 的熱區,後面的線透明度也比較低,所以畫面中央會有一股橘色的光往上冒。
flow 這個字是整段我最得意的地方。480px 的 Instrument Serif 斜體,在畫第 25 條線之後才畫上去,於是第 0 到 25 條在字的後面,第 26 到 57 條在字的前面,而前面那些線的填色會把字的下半部蓋掉。字就這樣「站」進了地形裡。它在第 0.7 拍從下方升起,第 3.3 拍開始退場。還有交接:上一段 RHYTHM 收合出來的 11 條橫線,在這裡被對應到第 0、6、11……57 條山脊,用彈簧從原本的列位置滑到山脊位置;其他的線再以這 11 條為中心,依距離先後從中間往兩側長出來。觀眾看到的是「網格塌成線,線再長成山」。
在 CLAUDE CODE 裡這樣說
「用 Canvas 2D 畫 58 條水平線,做成 Unknown Pleasures 那種山脊地形:從 y 750 開始、間距 15px,每條線用 3D Perlin noise(x、線的編號、時間)算起伏,只取山峰不要山谷,用高斯窗讓中間高、兩側平,後面的線振幅小一點。從最後面那條畫到最前面,每畫一條就用背景色 #0B0B0E 填滿線以下的區域,把後面的線遮掉。線是米白 #F2EDE4,中段漸層到 #FF5A1F。128 BPM:每一下 kick 送一道由左往右跑的脈衝;第 0 拍(downbeat)當格就要開始長高,不能有任何靜止的幀,並加一個 1 到 2 幀的亮度閃。把 Instrument Serif 斜體的 flow 插在第 25、26 條之間,讓前面的山吃掉字的下半部。標題區 y 290–540 保持安靜。downbeat 前後逐幀 render still 給我檢查。」
關鍵程式碼(scenes/classicB.js 的簡化版:保留遮擋、noise、kick 脈衝與插字的結構,省略漸層與交接動畫)
for (let i = 0; i < RL.N; i++) { // 58 條,由後(上)往前(下)畫
const path = new Path2D();
for (let k = 0; k <= n; k++) {
const x = xa + (xb - xa) * k / n, cx = x - RL.cx;
const win = Math.exp(-Math.pow(cx / 250, 2)); // 高斯窗:只有中間隆起
let nz = noise3(x * 0.0068 - t * 0.85, i * 0.2, t * 0.32);
nz = Math.max(0, nz + 0.22); // 只留山峰
let dy = -nz * 1.7 * win * amp;
for (const tk of recent) { // 每下 kick 一道往右跑的脈衝
const dt = t - tk, pos = -60 + dt * 1400 + (RL.N - i) * 4;
dy -= 84 * Math.exp(-Math.pow((x - pos) / 66, 2)) * Math.exp(-dt * 2.4);
}
k ? path.lineTo(x, yb + dy) : path.moveTo(x, yb + dy);
}
const fill = new Path2D(path); fill.lineTo(xb, H + 20); fill.lineTo(xa, H + 20);
ctx.fillStyle = bg; ctx.fill(fill); ctx.stroke(path); // 先用底色蓋掉後面的線,再描線
if (i === WORD_AFTER) ctx.fillText('flow', RL.cx - 30, ridgeY(i) + 40); // 第 25 條之後
}踩過的坑|第一版的山脊是在 downbeat 之後 0.15 拍才開始長,而且用 inOutCubic,前段慢慢加速。數學上很優雅,逐幀看就露餡了:這段第 0 拍大鼓落下的那一刻,畫面上的線大約有 10 幀幾乎沒動,聲音到了、畫面沒到,看起來像當機。修法有三層:長高的曲線改成從 downbeat 當格起跑的 Ease.outCubic,前快後慢,約 2 幀內就看得到山在抬升;再加一個以 exp(-42 × 經過秒數) 衰減的亮度脈衝,只活 1 到 2 幀,讓所有線同時變亮、變粗;最後在後製那層把 bloom 跟著這個脈衝往上推一下。這次學到的是:動畫曲線好不好看要在圖表上判斷,打點準不準,一定要逐幀看 downbeat 前後那幾張 still。
CHAPTER 05 / FRONTIER
後六種是另一個量級。液態玻璃要算折射和色散,光線步進要在著色器裡解 3D 場景,二十萬顆粒子要交給 GPU,反應擴散是一場化學模擬,著色器風格化要把 3D 畫面即時轉成五種印刷網屏,剛體物理要算碰撞。過去這些分別屬於 3D 軟體、粒子外掛、生成藝術工具,這次全部在同一份程式碼裡,跟著同一份節拍表動。
EFFECT 07 · LIQUID GLASS
技術原理
這一段我最想講清楚,因為它跟 Figma 裡那種「加個背景模糊就叫玻璃」差很遠。玻璃的形狀先用 2D SDF(Signed Distance Field,有號距離場)描述:畫面上每個像素都去問「我離最近的形狀邊緣有多遠」。六個膠囊形用 smooth-min 黏在一起,兩顆靠近時中間會長出一條圓滑的頸部,這就是 metaballs。接著把「離邊緣多遠」換算成一個四分之一圓的高度剖面,從高度求出表面法線,再讓光線穿過法線去折射底下那張海報。
核心技術
2D SDF metaballs、高度場 → 法線、7 波長 Cauchy 色散折射、fresnel 邊光、焦散
用到的工具
Canvas 2D(每幀重畫海報)、WebGL2、GLSL fragment shader、mipmap 貼圖
難度
●●●●○
拆開來看,它其實是兩層。底層是一張用 Canvas 2D 每一幀重畫的字體海報:LIQUID、玻璃、GLASS 三排跑馬燈,外加黃黑警示條和一排規格文字。跑馬燈的走法我很喜歡,它一邊等速漂移,一邊在每一拍用 Ease.outExpo 往前「跳一格」,所以背景本身就在打拍子。這張海報畫完後上傳成帶 mipmap 的貼圖,交給上層的 GLSL shader 當作「玻璃後面的世界」。
上層是玻璃本體。熟悉 Photoshop 的人可以把高度剖面想成「斜角與浮雕」裡的圓角斜面,公式是 h = √(1-(1-u)²),邊緣陡、中間平,所以折射集中在邊緣,中心看起來像放大鏡。真正讓它像玻璃的是色散:紅光和紫光的折射率不一樣,shader 從 700 nm 到 400 nm 取 7 個波長,每個波長用 Cauchy 公式算出自己的折射率、各自偏移去取樣海報,再按光譜權重疊回來,邊緣就自然滲出彩虹。毛玻璃感則是偷吃步:取樣時讀比較小張的 mipmap,等於免費的模糊。最後補上 fresnel(越斜看越反光)、左上與右下的細亮邊(就是 Apple 那種 Liquid Glass 的邊光),玻璃外面再畫一圈柔和陰影和帶色散的焦散光。
動態全部掛在拍點上。在完整編排裡,玻璃球第一拍用 spring 彈上來,第二拍一分為二,第三拍三顆繞圈再融回一顆,第四拍拉長成一個 UI 膠囊,裡面還浮出四個 tab bar 圖示,接著變成大放大鏡分三段掃過字,最後濺成五顆水珠。每一拍都會重新觸發一次阻尼振盪,讓玻璃橫向擠 7.5%、縱向拉長,像果凍被敲了一下。成片只剪進其中 4 拍(從三顆融合到放大鏡),不到兩秒,但每一格都有東西在彈。
在 CLAUDE CODE 裡這樣說
「用 WebGL2 fragment shader 做一個 1080×1920 的液態玻璃效果。背景是一張每幀用 Canvas 2D 重畫的字體海報(深色底 #0B0B0E、橘 #FF5A1F、鈷藍 #2F3CFF、檸檬黃 #FFD23F),三排跑馬燈每拍用 outExpo 往前跳一格,上傳成有 mipmap 的貼圖。玻璃用 2D SDF 膠囊加 smooth-min 做 metaballs,距離換成四分之一圓的高度剖面求法線,對海報做折射,用 Cauchy 公式取 7 個波長做色散,加 fresnel、左上右下的細亮邊、外圍陰影和焦散。128 BPM,第 0 拍彈入、第 1 拍分裂、第 2 拍三顆融合、第 3 拍拉成 UI 膠囊,每一拍做一次果凍擠壓。主體放在 y 580–1460,y 290–540 留給標題。畫面上印出的折射率和阿貝數要跟 shader 用同一組常數算。做完幫我輸出 lt = 0.5、1.5、2.5、3.5 秒的靜態畫面,自己看過再給我。」
關鍵程式碼(glass.js 原始碼節錄,ABBE 一行為合併改寫)
// JS:兩個數字定義整塊玻璃,HUD 與 shader 共用
const CB = 0.10 / (1 / 0.16 - 1 / 0.49); // µm²
const CA = 1.47 - CB / (0.55 * 0.55);
const nL = um => CA + CB / (um * um);
const ABBE = (nL(0.5876) - 1) / (nL(0.4861) - nL(0.6563));
// GLSL:7 個波長各折射一次,再按光譜權重合成
for (int k = 0; k < 7; k++) {
float x = float(k)/6.;
float lam = 0.700 - 0.300*x; // 700 nm 紅 → 400 nm 紫
float ior = uCA + uCB/(lam*lam); // Cauchy
vec3 r = refract(vec3(0., 0., -1.), n, 1./ior);
vec2 off = r.xy/max(-r.z, 0.25)*T*uRefr;
vec3 wt = spec3(x);
acc += poster(p + off, lod)*wt; wsum += wt;
}
vec3 col = acc/wsum;踩過的坑|海報上那行小字 n_d 1.460 · ABBE 10.1 一開始印出來的阿貝數跟 shader 實際在用的折射率對不起來,字看起來很專業,數學卻是假的。這種錯觀眾十個有九個看不出來,但剩下那一個會截圖。修法是把 Cauchy 的 A、B 改成由兩個設計目標反推:550 nm 的折射率 1.47、400 到 700 nm 的折射率差 0.10。HUD 上的 n_d、阿貝數、公式係數,和傳進 shader 的 uCA、uCB 全部從這兩個常數算出來。之後要調色散強度,只要改一個數字,畫面和字會一起變。阿貝數 10 其實比真實玻璃誇張很多(冕牌玻璃大約 60),這是刻意的,彩虹要誇張到手機小螢幕上看得見才有意義。
EFFECT 08 · RAYMARCHING
技術原理
Raymarching 我會這樣跟設計師解釋:想像每個像素派出一個人,蒙著眼往畫面深處走,手上有一根神奇的導盲杖,會告訴他「離最近的牆還有多遠」。他就大膽走那麼遠,再問一次,一路問到幾乎貼到表面為止。這根導盲杖就是 3D 版的 SDF。整個場景沒有一個多邊形,球、鏤空殼、扭轉圓環、打了三個洞的圓角方塊,全都是距離公式,所以可以用 spring 在兩個形狀的公式之間直接內插,形體變形完全不需要拓樸對應。
核心技術
Sphere tracing、SDF 布林運算與變形、16 波長薄膜干涉、程序化攝影棚 HDR、domain repetition、dolly zoom
用到的工具
WebGL2、GLSL 單一 fragment shader、Canvas 2D(HUD 文字)
難度
●●●●●
材質是我最捨不得的部分。那層彩虹色是薄膜干涉,跟肥皂泡、油膜、相機鏡頭鍍膜同一個原理:光在一層很薄的膜上下兩面各反射一次,兩道光互相加強或抵消,膜的厚度決定哪個顏色留下來。shader 在 395 到 710 nm 之間取 16 個波長,每個波長算一次反射率,用人眼的色彩配對函數換成 XYZ,再轉到 sRGB。膜厚是 170 nm 加上一層雜訊起伏,而且每一拍再往上加 70 nm、四拍一個循環,kick 落下時再多推 50 nm,所以顏色會跟著節拍整片換一輪,不需要另外調色。
打光的思路跟商品攝影一模一樣,只是燈都是公式。env() 這個函數就是一座虛擬攝影棚:頭頂一片帶擴散格柵的柔光箱、右前方主燈條、左前方補光條、後方一橘一藍兩條輪廓光,再加一條高位的檸檬黃點綴光。金屬本身不發光,它長什麼樣子完全取決於反射到哪盞燈,這也是為什麼它看起來那麼「貴」。地板另外追一次反射光線,加上軟陰影和環境遮蔽。
然後是最爽的那一刀:domain repetition。只要一行 p.xy -= c*round(p.xy/c),空間就被摺成無限多格,一個物件變成一整面晶格,成本幾乎沒變。格距 c 從 160 以對數內插收到 2.9,看起來就像無數分身從遠方飛過來排隊;深度方向只複製 z ≤ 0 那一側,避免有分身穿過鏡頭。成片只用了這個場景的後半四拍:一開場晶格就成形、鏡頭用 cubicBezier(0.25, 0, 0.4, 1) 一路 dolly 進孔洞,同時做 dolly zoom 讓視角變廣;第 1 拍整片晶格轉 90 度;第 2 拍一個 spring 把鏡頭往後拉並翻滾出來;第 3 拍所有分身同時變回球體。
在 CLAUDE CODE 裡這樣說
「用 WebGL2 寫一個 raymarching(sphere tracing)場景,輸出 1080×1920、60 fps。主體是 SDF:球、gyroid 鏤空殼、扭轉方截面圓環、挖三個孔的圓角方塊,128 BPM 每拍用 spring 換下一個形狀並轉 90 度。材質用薄膜干涉:膜折射率 1.36、厚度 170 nm 加雜訊,取 16 個波長經 CIE 配色函數轉 sRGB,每拍厚度加 70 nm 讓顏色跟拍換。環境光做成程序化攝影棚:頂部柔光箱、白色主燈條、橘 #FF5A1F 與鈷藍 #2F3CFF 輪廓光。第 4 拍開啟 domain repetition 變成無限晶格,格距從 160 對數收到 2.9,只複製 z ≤ 0;鏡頭用一條速度連續的曲線鑽進孔洞,全程每幀移動不超過 0.4 格;第 6 拍後拉翻滾,第 7 拍全部變回球。y 290–540 的標題區要壓暗保持乾淨。幫我輸出連續 10 幀的 motion strip 和幾張靜態畫面,檢查有沒有閃爍或卡頓再交給我。」
關鍵程式碼(ray.js 原始碼節錄,march() 略去包圍球判斷)
// 把空間摺成晶格:一個物件 → 無限多格
vec3 toObj(vec3 p){
if (uCell < 900.) {
p.xy -= uCell*round(p.xy/uCell);
p.z -= uCell*clamp(round(p.z/uCell), -1e4, 0.); // 只複製 z ≤ 0
}
p.xy = rot2(uSpinZ)*p.xy;
return uRot*p/uScale;
}
// sphere tracing:問距離、走那麼遠、再問一次
float t = t0;
for (int i = 0; i < 150; i++) {
float d = map(ro + rd*t);
if (abs(d) < 0.00035*t + 0.0002) { steps = float(i); return t; }
t += d*0.82; // 打 82 折,保守一點不穿牆
if (t > tmax) break;
}
return -1.;踩過的坑|第一版穿越晶格時畫面在「閃」。原因是鏡頭每一到兩幀就穿過一整格,而晶格每一格長得一模一樣,連續兩幀的畫面幾乎相同,眼睛分不出你是往前、停住還是往後,就像老電影裡倒轉的馬車輪。後來定了一條規則:鏡頭每幀最多移動 0.4 格,現在的峰值大約每幀 0.08 格,晶格才讀得出是在流動。第二個問題是鏡頭往後拉那一拍的頓點:原本鏡頭先煞車停住,後拉的 spring 再從靜止起步,中間那一瞬間的停頓在 60 fps 下非常明顯。修法是把整段鑽入改成一條速度連續(C1)的曲線,後拉的 spring 直接疊加在上面當重拍,再加一個 d*d/(d+0.6) 的緩慢漂移,它的起點斜率是 0,所以不會多出新的轉折。用 After Effects 的語言說,就是速度圖上不能出現垂直的斷崖。
EFFECT 09 · 200K PARTICLES
技術原理
大部分人想到粒子,會想到物理模擬:每一幀算一次力,粒子一步一步往前推。這支片刻意反過來做。二十萬顆粒子沒有任何狀態,位置是「粒子編號 × 時間」的純函數,全部寫在 vertex shader 裡。我喜歡把它想成一齣排練好的群舞:每個舞者手上都有自己的譜,你只要喊「現在是第 2.3 秒」,每個人就知道自己該站在哪。好處是任何一幀都能單獨算,動態模糊的子影格也不成問題。
核心技術
無狀態粒子(位置 = f(id, t))、文字遮罩轉浮點貼圖、環面紐結、curl noise、加法混色與色調映射、點大小景深
用到的工具
WebGL2 gl.POINTS、GLSL vertex shader、RGBA32F 資料貼圖、RGBA16F 累積緩衝、Canvas 2D(取字形)
難度
●●●●○
實際畫的時候,JS 只呼叫一次 gl.drawArrays(gl.POINTS, 0, 200000),連頂點資料都不傳,shader 用 gl_VertexID 當身分證,再用 PCG hash 從編號生出每顆粒子自己的亂數。隊形有四種:三條對數螺旋臂的星系、「5.5」字形、(2,3) 環面紐結、爆炸後的塵埃。字形那一段很像在 Illustrator 裡做「文字轉外框再撒點」:先在一張看不見的 Canvas 上寫出「5.5」,把所有不透明的像素收集起來,隨機抽 20 萬個當目標點,存進一張 512 寬的 RGBA32F 浮點貼圖,shader 再用 texelFetch 依編號讀出來。
我覺得最聰明的一步是「排序配對」。如果隨便把粒子分給目標點,二十萬顆會在空中亂交叉,看起來像雜訊。這裡先預測每顆星系粒子在變形那一刻的螢幕 x 座標,再把目標點也按 x 排序,一對一配起來,左邊的粒子就去字的左邊,整片流動看起來有方向。每顆粒子還有自己的 spring 延遲,照 x、y 位置錯開,像由左往右的 stagger 動畫;飛行途中再被 curl noise 吹出一道弧線,落地時剛好收乾淨。紐結段則用紐結管子的表面法線替每顆粒子打光,受光面熱、背光面冷,顏色從鈷藍過渡到橘。
成片剪進的 4 拍從「5.5」開始:第 1 拍字形散開、繞成紐結,第 3 拍前整團先吸氣內縮 10%,然後在拍點上炸開,約四分之一變成高溫火花往外噴,其餘沿著紐結的形狀膨脹,被四層 curl noise 扭成煙,最後慢慢沉成塵埃。算圖時所有粒子用加法混色(blendFunc ONE, ONE)疊進 RGBA16F 緩衝,亮度可以超過 1,最後用 1 - exp(-a) 做色調映射,像底片曝光一樣讓亮部柔柔地收掉,不會一片死白。景深則靠點的大小:離焦平面越遠點越大,亮度按面積反比調低,能量守恆,所以散景不會突然變亮。
在 CLAUDE CODE 裡這樣說
「用 WebGL2 畫 200,000 顆 gl.POINTS 粒子,1080×1920、60 fps。不要做逐幀物理模擬,每顆粒子的位置寫成 vertex shader 裡 f(gl_VertexID, t) 的純函數,亂數用 hash。隊形照 128 BPM 走:第 2 拍從螺旋星系變成「5.5」字形(先在 Canvas 上寫字、取不透明像素存成浮點貼圖,粒子和目標點都按 x 排序配對),第 4 拍變成 (2,3) 環面紐結並用管子法線打光,第 6 拍用 curl noise 炸開成塵埃。顏色只用鈷藍 #2F3CFF 到橘 #FF5A1F。用加法混色疊進 RGBA16F,再用 1 - exp(-x) 色調映射。主體在 y 580–1460,y 290–540 的標題區要壓暗,爆炸不能飄進去。塵埃要考慮 Instagram 重新壓縮:顏色要成片、不要每顆隨機,大部分塵埃做成柔邊大光點。輸出爆炸前後 5 張靜態畫面和一段連續幀讓我看。」
關鍵程式碼(particles.js 簡化版,保留原本的結構與參數)
// JS:一次畫 20 萬個點,不傳任何頂點資料 gl.blendFunc(gl.ONE, gl.ONE); // 加法混色,疊進 RGBA16F gl.drawArrays(gl.POINTS, 0, N); // vertex shader:位置 = f(id, t) uint id = uint(gl_VertexID); vec4 tg = texelFetch(uTxt, ivec2(int(id % 512u), int(id / 512u)), 0); vec3 txt = tg.xyz * uRT; // 「5.5」上屬於我的目標點 vec3 kp = knot(id, t, uRK, hk); // 紐結上屬於我的位置 float s2 = spr(t - T2 + 0.03 - d2, 1.9, 0.5); // 每顆有自己的 spring 延遲 p = mix(txt, kp, s2); float arc = sin(3.14159*clamp(s2, 0., 1.)); // 飛到一半時最亂 p += curl(p*0.0032 + vec3(3., 0., t*0.4) + h0*0.3)*70.*arc; // 合成:色調映射,亮部柔和收邊 vec3 T = 1. - exp(-a*uExp);
踩過的坑|爆炸後的塵埃在本機算圖時美到不行,滿天閃閃的小亮點,每顆顏色都隨機。可是一模擬 Instagram 的重新壓縮,那片閃點就撐不住,碎成一塊塊的色斑。原因是那種畫面對影片壓縮來說「資訊量太高」:二十萬個亮點每幀都在動、每顆顏色還不一樣,編碼器的位元率根本不夠分,只好整片抹掉。修法分三步。第一,塵埃的顏色改成跟空間有關,由粒子原本在紐結上的位置決定,變成藍底裡幾條寬寬的橘色紋路,不再是椒鹽雜點。第二,90% 的塵埃放大成最小 14 px 的柔邊高斯光點,很多顆疊在同一個像素上,看起來是一層平滑的霧;只留 10% 小而清楚,讓人還讀得出「這是粒子」。第三,塵埃出現時把後製的動態顆粒從 0.016 降到 0.004,因為塵埃本身就是質感,再疊一層會動的 grain 就是在跟編碼器搶位元率。設計師做短影音常常只看本機輸出的檔案,這次讓我學到要把平台的壓縮也當成畫面的一部分來設計。
EFFECT 10 · REACTION–DIFFUSION
技術原理
反應擴散用的是 Gray–Scott 模型。畫面上每個格子都裝著兩種虛擬化學物 U 和 V:U 以速度 F 不斷補進來,V 以 F+k 的速度被淘汰,兩者相遇時 V 會吃掉 U 來複製自己。只要調 F 和 k 兩個數字,同一條公式就會長出斑點、迷宮或珊瑚。我把 512×910 個格子放進兩張 32 位元浮點紋理,在 GPU 上來回互寫(ping-pong),一秒跑 2600 步起跳。
核心技術
Gray–Scott 反應擴散、RGBA32F ping-pong、F/k 參數地圖、倒角距離場、高度場浮雕打光、快照回溯
用到的工具
WebGL2、GLSL、Canvas 2D(畫字形遮罩)
難度
●●●●○
用設計師的語言講,它像宣紙上的兩種墨:一種暈得快(U 的擴散係數是 1),一種暈得慢(V 只有 0.5),速度的落差讓圖樣自己決定形狀。做這段時我最大的體悟是:要設計的是「生長條件」,條件定好,圖案會自己長出來。所以我先用 Noto Sans TC 900 把「生」「長」畫進一張跟模擬同尺寸的畫布當種子,再做一張「參數地圖」紋理:R、G 兩個通道用 Perlin 雜訊微調 F 和 k,讓字外的反應不均勻;B 是字形遮罩,決定這格用字內還是字外的化學;A 是每一格到字形的距離,用兩趟掃描的倒角距離轉換(chamfer distance)算出來。標題帶那一段我在 k 上多加了 0.011,等於劃成禁生區,紋路長不進去,標題才讀得清楚。
這段剛好落在音樂的 breakdown(第 40–48 拍),整整 8 拍沒有大鼓,畫面得自己當節拍器。每一拍,主光源用彈簧轉一格、鏡頭往前推一小步,再從中心打出一圈光環,掃過的紋路稜線會亮起來。化學參數則照時間關鍵影格變:0.95 秒字外開始冒出會分裂的斑點,2.15 秒全場變成珊瑚;第 6 拍拍手聲從八分音符加密成十六分音符,參數切到「沸騰」,一道波前從字裡往外推,一路湧到下一段的 drop。
模擬算出來的只是一張灰階高度圖。質感全靠著色 pass:用 bicubic 取樣把 512×910 放大到 1080×1920 避免鋸齒,拿高度的梯度當法線,從 mipmap 取模糊版本算環境遮蔽,再疊高光、環境反射和一片虛擬柔光箱,才有那種上了釉的浮雕墨感。字內走 clay 橘的色階,字外走 cobalt 藍,交界用雜訊扭過,看起來像手染的暈邊。
最燒腦的是確定性。動態模糊每一格要渲染 5 個子影格,片尾 montage 又會跳回任意時間點取樣,如果模擬「跑到哪算到哪」,同一個時間點每次都會長得不一樣。解法是把步數寫成時間的純函式 stepsAt(lt),F、k 也用步數查表;每 2048 步存一張快照,時間往回跳就從最近的快照重算。同樣的步數,得到的永遠是每個位元都一樣的畫面。
在 CLAUDE CODE 裡這樣說
「用 WebGL2 做一段 Gray–Scott 反應擴散,模擬網格 512×910,兩張 RGBA32F 紋理 ping-pong。用 Noto Sans TC 900 把『生長』兩個字畫成遮罩當種子:字裡用迷宮參數(F 0.0545、k 0.062),字外先是不長東西的死區,再依序解凍成斑點、珊瑚。步數寫成時間的純函式,每 2048 步存一張快照,動態模糊的 5 個子影格和往回跳轉都要得到一模一樣的結果。畫面用高度場做浮雕打光,字內用 clay 橘 #FF5A1F、字外用 cobalt 藍 #2F3CFF。128 BPM、共 8 拍:每一拍主光轉一格、鏡頭推一點、打一圈光環;第 6 拍開始沸騰,參數要在 0.28 秒內漸進,不能一次切。紋路限制在 y 580–1460 的安全區,y 290–540 的標題帶要保持安靜。每改一次就 render 0.2、1.0、2.0、3.0、3.6 秒的靜態圖給我看。」
關鍵程式碼(rd.js 原始碼節錄,中文註解為補充說明)
// JS:步數是時間的純函式,後段加速(沸騰時跑更快)
const stepsAt = lt => {
const x = Math.max(0, lt - LT0), s = Math.max(0, lt - LTS);
return Math.floor(R0 * x + R1 * s * s);
};
// GLSL:Gray–Scott 的一步(9 點拉普拉斯)
vec4 c = T(0,0);
vec4 l = 0.2*(T(1,0) + T(-1,0) + T(0,1) + T(0,-1))
+ 0.05*(T(1,1) + T(-1,1) + T(1,-1) + T(-1,-1)) - c;
vec4 m = texelFetch(uM, p, 0); // 參數地圖
vec2 fk = mix(uFK.xy, uFK.zw, m.b); // 字內/字外兩組 F、k
float ba = uBoil.w*smoothstep(uBoil.x, uBoil.x - 60., m.a);
fk = mix(fk, uBoil.yz, vec2(ba, ba*ba)) + m.rg; // k 比 F 慢一步進入沸騰
float u = c.r, v = c.g, r = u*v*v;
o = vec4(clamp(u + l.r - r + fk.x*(1. - u), 0., 1.),
clamp(v + 0.5*l.g + r - (fk.x + fk.y)*v, 0., 1.), c.b + 0.004*l.b, 1.);踩過的坑|「沸騰」一開始是在第 6 拍直接把 F、k 換成沸騰值(F 0.030、k 0.055),結果整片迷宮在一幀之內糊成一灘爛泥,連看清楚變化的機會都沒有。反應擴散對參數非常敏感,瞬間跳到另一個區域,系統來不及長出結構就先崩掉了。後來改成三件事一起做:強度用 smoothstep 在 0.28 秒內(BOIL_RAMP)慢慢加上去;波前半徑沿著距離場從字的內部往外擴,先沸的是字,外圈跟著被捲進去;k 的混合比例用強度的平方,讓 k 比 F 慢半步,參數路徑一直貼著「會長紋路」的帶狀區,不會掉進整片淹沒的區域。同一個效果,從爆炸變成設計過的湧動。
EFFECT 11 · SHADER STYLES
技術原理
這段是兩段式渲染。第一個 pass 用 raymarching 畫一個 (2,3) 環面紐結和三顆繞著它轉的小球,但它不輸出顏色,只把亮度、材質 ID、高光、覆蓋率四個數字寫進一張 540×960 的半解析度 G-buffer。第二個 pass 用全解析度 1080×1920 讀這張「資料圖」,再把它翻譯成五種印刷語言:Bayer 遞色、三色網點、ASCII 字元、像素排序、孔版印刷。
核心技術
SDF raymarching、G-buffer 延遲風格化、Bayer 8×8 有序遞色、旋轉網屏半色調、字元圖集、像素排序、雙色錯版
用到的工具
WebGL2、GLSL、Canvas 2D(生成字元圖集)
難度
●●●●●
拆成兩個 pass 的好處,用 Photoshop 的圖層來想就懂了:3D 那層只管把形體算對,風格那層像一個可以隨時換掉的濾鏡。換風格時 3D 完全不用動;而 raymarching 最吃效能,用半解析度算剛好,風格層用全解析度輸出,網點和字元的邊緣照樣銳利。G-buffer 存的是 16 位元浮點,覆蓋率另外放在 alpha,風格層讀的時候先除回去,物體邊緣才不會多出一圈暗邊。
五種風格各有各的手藝。遞色用 Bayer 8×8 門檻矩陣,每 6 px 一格,把亮度跟矩陣裡的門檻比大小,只輸出紙色或墨色,是最老派的 1-bit 美學。網點用三張各自旋轉的網屏疊印:cobalt 15°、clay 75°、墨色 45°,點的半徑取墨量的平方根,面積才會跟墨量成正比,這是印刷廠師傅的常識,寫進 shader 一樣成立。ASCII 先用 JetBrains Mono 把 69 個字元各畫一次,量每個字的白色像素總量,挑出墨量平均分布的 16 個字做成圖集,越亮的地方用越「滿」的字。像素排序每 3 px 一欄,找出亮度超過門檻的片段,沿片段取 16 個顏色樣本,在 shader 裡用兩層迴圈數出每個樣本的名次,每個像素再拿它那個名次的顏色,像把書架上的書按深淺重排。孔版印刷只用 cobalt 和 clay 兩版,兩版各自偏移,大鼓一打錯位就更大,再用雜訊做出油墨不足的斑駁。
在成片裡,這段是全片的第二個 drop。第 0 拍像素過場進來時畫面已經是遞色,第 1 拍切網點、第 2 拍切 ASCII、第 2.5 拍切像素排序、第 3 拍切孔版,每一次切換都配一聲合成器 chirp。切換用四種形狀輪流的硬邊 wipe(放射、由上往下、分帶、斜切),0.24 拍內切完;紐結也會在每次切換時被彈簧甩一下,像被印刷機的滾筒推了一把。我自己最滿意的是節奏感:切換間隔從一拍縮成半拍,視覺上的「加速」跟音樂往下一段推進是同一件事。
在 CLAUDE CODE 裡這樣說
「用 WebGL2 做兩個 pass 的著色器風格化。第一個 pass 用 raymarching 畫一個 (2,3) 環面紐結加三顆繞行的小球,輸出到 540×960 的半解析度 G-buffer(亮度、材質 ID、高光、覆蓋率)。第二個 pass 用全解析度把它轉成五種印刷風格:Bayer 8×8 遞色、三色旋轉網點(15°、45°、75°)、JetBrains Mono 的 ASCII(依墨量排序挑 16 個字)、依亮度的像素排序、cobalt 加 clay 的雙色孔版(兩版故意錯位)。128 BPM,在第 1、2、2.5、3 拍各切一次風格,每次用不同形狀的硬邊 wipe,0.24 拍內切完,每次切換紐結要被彈簧甩一下。紙面風格要自動把 bloom 調小。wipe 位置每個輸出影格只取樣一次,5 個動態模糊子影格共用。畫面限制在 y 572 以下,風格標籤放在 y 1282–1436。每種風格各 render 一張靜態圖,再把切換前後的連續影格排成一張 sheet 給我檢查。」
關鍵程式碼(shaders.js 節錄,stBayer 為簡化版,中文註解為補充說明)
// GLSL:Bayer 8×8 門檻矩陣,由 2×2 遞迴組出來
float bayer2(vec2 a){ a = mod(floor(a), 2.); return fract(dot(a, vec2(0.5, a.y*0.75))); }
float bayer4(vec2 a){ return bayer2(0.5*a)*0.25 + bayer2(a); }
float bayer8(vec2 a){ return bayer4(0.5*a)*0.25 + bayer2(a); }
vec3 stBayer(vec2 q){
vec2 cell = floor(q/uBayerPx), cq = (cell + 0.5)*uBayerPx;
Src s = getS(cq); // 讀 G-buffer
float dk = mix(bgD, 1. - pow(s.L, 0.8), s.m); // 需要多少墨
float ink = step(bayer8(mod(cell, 8.)) + 0.001, dk);
return mix(uPP, uPK, ink); // 只有紙色或墨色
}
// JS:wipe 位置每個輸出影格只取一次,並對齊半格相位
const fph = ((((bt(PLACE.shaders[0]) - bt(PLACE.shaders[2])) * FPS) % 1) + 1) % 1;
const sw = state((Math.round(lt * FPS + fph) - fph) / FPS);踩過的坑|遞色、網點、孔版三種風格印在紙色 #F2EDE4 上,亮度接近白。後製 bloom 的門檻原本是 0.62,整張紙都超標,一切到紙面風格就整片發光、閃白。一開始我在切換那一瞬間直接換 bloom 參數,可是 wipe 還切到一半,新舊兩種畫面同時在螢幕上,照樣閃。最後改成讓 fx() 往前後各看一小段時間:紙面出現前約 0.1 秒就開始把 bloom 從 0.34 降到 0.05、門檻拉到 0.99,紙面完全離開後再用約 0.18 秒退回來,中間用 smoothstep 抹平,閃白就消失了。
第二個坑藏在時間軸裡。這段在成片中是從場景自己的第 4 拍開始播,4 拍等於 1.875 秒、也就是 112.5 格,多出來的那半格,讓每個輸出影格都落在場景時間 n+0.5 格的位置。為了讓 wipe 邊緣是乾淨的印刷硬邊,我規定 5 個動態模糊子影格共用同一個 wipe 位置,用 Math.round() 對齊到影格;偏偏子影格散在 ±0.2 格的範圍,四捨五入剛好把它們拆成兩組,邊緣又變回兩道疊在一起的灰階殘影。修法是先算出這半格的相位 fph,在對齊相位的網格上取整,五個子影格才真正落在同一格。
EFFECT 12 · RIGID BODIES
技術原理
剛體模擬用的是開源 2D 物理引擎 matter.js:每個物體有密度、摩擦、彈性,碰撞和堆疊交給引擎算。麻煩在於物理引擎天生是即時的,影片卻要求每一格都能重現。所以整段模擬在 init() 裡用固定 1/240 秒的步長一口氣算完(−0.3 到 4.0 秒,共 1033 步),每一步把每個物體的 x、y、角度存成軌跡;渲染時只查表內插,一次都不模擬。
核心技術
固定步長預計算、軌跡內插、複合剛體、落地時間校準迴圈、碰撞事件驅動的衝擊線
用到的工具
matter.js、Canvas 2D
難度
●●●○○
我喜歡把這種做法比喻成「先拍定格動畫,再用放映機播」:init() 把整場戲拍完存成底片,動態模糊的子影格和片尾 montage 的回跳,都只是在底片上找位置。240 Hz 比 60 fps 密四倍,兩步之間做線性內插就很順。
物件的設計藏了不少小心機。CLAUDE 六個字母用 Archivo 900 排,字寬算到剛好塞滿地板;它們在引擎裡的碰撞形狀是圓角矩形,字形畫在上面,這樣堆疊起來才穩。「5.5」用 Body.create({ parts }) 把三個圓角矩形組成一個複合剛體,落地、翻滾、飛走都是同一個整體,不會散架。星星在引擎裡是五邊形,畫的時候才補上內凹的角。陰影畫在四分之一解析度的畫布上再模糊,便宜又柔。
在成片裡這段是第 52–56 拍,四拍四個事件:52 拍一顆重 clay 球砸進來(密度大約是其他物體的 20 倍),鏡頭跟著震;53 拍撒下一整波圓形、星星、三角形,後半拍再補一波;54 拍地板像蹺蹺板傾斜、往上頂一下,同時把地板摩擦力調低,整堆開始滑;55 拍重力反轉成 −0.36 G,天花板打開,每個物體依離中心的距離依序被往上踢,像一圈漣漪。碰撞事件會記下相對速度超過 700 px/s 的撞擊點,畫成放射狀的衝擊線;音軌那邊,每一拍都有一聲合成的木頭敲擊。
盒子的兩道牆刻意收在 x 60 和 x 905。IG Reels 右下角有一整排愛心、留言、分享按鈕(大約 x 940 以右),物體滾到那裡就會被按鈕蓋掉,所以牆往內收,讓整場物理都在按鈕欄左邊發生。天花板在 y 572 開一條縫,物體從縫裡掉進來、最後也從縫裡飛出去,上方的標題帶完全不受干擾。
在 CLAUDE CODE 裡這樣說
「用 matter.js 做一段剛體物理:CLAUDE 六個字母(Archivo 900、墨色 #0B0B0E)是剛體,『5.5』做成一個複合剛體讓三個字一起翻滾,再加『物』『理』兩個方塊和一堆 clay、cobalt、lemon 色的圓、三角、星星、膠囊。整段在 init() 用固定 1/240 秒的步長先算完,把每一步的 x、y、角度存成軌跡,draw() 只做內插,不准即時模擬。128 BPM:第 0 拍一顆大 clay 球砸下、第 1 拍撒一波小形狀、第 2 拍地板像蹺蹺板傾斜、第 3 拍重力反轉全部往上飛。每個物體第一次落地都要對準拍點:寫一個校準迴圈重跑模擬、微調生成時間,誤差壓到 10 ms 以內,最後把每個物體的誤差印出來。左右牆放在 x 60 和 905,避開 IG 右側按鈕欄;物體從 y 572 的天花板縫掉進來,不能碰到上方標題帶。render 每個落地前後的連續影格,對著拍點檢查。」
關鍵程式碼(physics.js 節錄並簡化,省略生成、碰撞事件與空值判斷)
// init():一次算完,固定 1/240 秒一步,逐步錄下軌跡
for (let i = 0; i < NS; i++) {
const t = T0 + i * DT;
if (t >= LB(7)) engine.gravity.y = -GUP * G / 1000; // 第 55 拍:重力反轉
defs.forEach((d, k) => {
const b = live[k]; if (!b) return;
tracks[k][i * 3] = b.position.x; tracks[k][i * 3 + 1] = b.position.y; tracks[k][i * 3 + 2] = b.angle;
});
Engine.update(engine, 1000 / HZ);
}
// draw():只查表 + 線性內插,從不模擬
function pose(k, lt) {
const f = (clamp(lt, T0, T1 - 1e-6) - T0) * HZ, i = Math.floor(f), u = f - i;
const tr = DEFS[k].track, j = Math.min(NS - 1, i + 1);
return { x: lerp(tr[i * 3], tr[j * 3], u), y: lerp(tr[i * 3 + 1], tr[j * 3 + 1], u),
a: lerp(tr[i * 3 + 2], tr[j * 3 + 2], u) };
}踩過的坑|讓物體「準時落地」比我想像的難。用拋物線公式反推生成時間,理論上會剛好落在拍點,實際上空氣阻力、撞到別的物體、240 Hz 的離散步長,都會讓落地提早或延後,跟音效一對就露餡。解法是一個校準迴圈:物體依拍點分組,每組最多重跑 16 次模擬,用 matter.js 的 collisionStart 事件抓第一次碰撞的時間,差多少就回頭調生成時間、起始高度或初速;已經對準的組別直接鎖住,後面的組才不會把前面的擾亂。另一個細節是「落地」的定義:只算撞到地板,或撞到比自己更早落地的物體,兩個形狀在空中擦撞不算數,否則校準會把錯的時間點當成答案。最後每個落地都壓在拍點 ±6 ms 以內,連 60 fps 的半格都不到。
CHAPTER 06 / LIMITS
程式碼動畫最常見的崩壞,是同一格畫兩次長得不一樣。只要用了 Math.random() 或現在時間,動態模糊就會糊成一團、平行渲染也會前後對不起來。隨機要改用固定種子的亂數;反應擴散、物理這種「有狀態」的模擬,要先整段算好存起來,或定期存快照,往回跳時從快照重算。
這支 30 秒、1,800 格的影片,在我的 M3 Max 筆電上開六個 Chrome 同時算,含啟動大約五分鐘。寫場景時就要訂好效能預算,這次的規格是單次 draw 約 60 毫秒、含 5 次取樣與後製的一格約 400 毫秒以內,不然改一個參數就要等很久。
Reels 的標準輸出尺寸是 1080×1920;畫面上方約 250px、下方約 420px 會被介面蓋住,右下還有一排按讚、留言的按鈕(大約 x 940 以右)。重要的字要放在安全區裡,響度抓在 −14 LUFS 左右。另外,IG 會重新壓縮影片,太細碎的閃爍顆粒會被壓成色塊,這支片的粒子塵埃就為此重做過一次。
畫面雖然是程式畫的,字體檔和物理引擎還是別人寫的。這支片用的 Archivo、Noto Sans TC、JetBrains Mono、Instrument Serif 都是開源授權(SIL OFL),matter.js 是 MIT 授權,商用前還是自己再確認一次。
Claude 看得出卡頓、跑版、字太小,但「這個橘配這個藍好不好看」「這一拍該不該更狠」,標準還是得由你給。整個過程裡,我做最多的事就是看、挑、再說一次要什麼。怎麼把自己的品味講清楚給 AI 聽,我在〈Claude Code 怎麼做出沒有 AI 感的網站?〉有一套三步驟的做法。
CHAPTER 07 / COMPARED TO
三種做法各有各的位置。程式碼動畫最強的地方是「精準又可以一直改」,最不適合的是需要實拍素材或大量手繪的案子。AI 影片生成那條路,我之前在〈Seedance 2.5 能做 motion graphics 嗎?〉修了十一版;如果你要的是能放進網站、App 裡互動的動效,Rive 會是另一條更適合的路。
比較項目
After Effects
AI 影片生成
Claude Code 寫程式
動態怎麼來
關鍵影格+外掛
提示詞生成
時間的函數
控制精度
高
低,每次結果不同
高,精確到每一格
怎麼修改
手動調曲線
重新生成
說一句話、改一個參數
對拍
靠耳朵手動對
很難指定
節拍表自動對齊
3D、粒子、模擬
要外掛或其他軟體
看模型能力
同一份程式碼裡寫
最適合
商業案、實拍合成
快速概念、氛圍片
生成藝術、技術型動態、品牌系統
CHAPTER 08 / TAKEAWAYS
1993 年 After Effects 出現,動態設計師才開始習慣在時間軸上拖關鍵影格。三十幾年後,這支影片沒有時間軸——「時間」只是函數裡的一個變數,代進去,畫面就算出來了。
做完這 12 種效果,我最大的感想是:懂原理的人會越來越吃香。你知道彈簧的阻尼是什麼手感、知道色散為什麼會出現彩虹邊、知道轉場的切點為什麼要壓在拍點前兩三格,你就能用一句話讓 Claude 做對;你不知道,就只能一直說「再好看一點」。
“
程式碼負責算出每一格,哪一格值得留下來,還是設計師說了算。
如果你也想試,從最簡單的彈跳球開始:請 Claude Code 搭好渲染骨架,做一顆會擠壓伸展的球,然後要求它每改一次都渲染縮圖給自己看。等你習慣了「描述、看、挑」這個節奏,後面十一種都只是換一個題目。
RELATED READS · 延伸閱讀
Claude Code 設計師完整指南
用設計師的角度看懂 agentic loop、CLAUDE.md,還有 Skills、Hooks、MCP、Subagents 怎麼搭。
Claude Opus 5.5 為什麼更強還更便宜?
這次在 Claude Code 背後做事的主力模型:價格、跑分與設計師用法。
Figma Motion 怎麼用?
時間軸、關鍵影格、緩動曲線,拿來對照程式碼動畫的另一種思路。
Figma Shaders 是什麼?
一句話生成全息箔、噪點質感,著色器在設計工具裡的樣子。
3D 動作 RPG 怎麼用 AI 做出來?
Three.js 加上 AI 從第一段提示詞做到能交付,另一個用程式碼做視覺的實戰。
設計師為什麼該學 Claude Skills?
把這次這種工作流程封裝成 AI 的永久能力,下次一句話就能叫出來。
EXTRA · LINKS 官方資源
Claude Code 產品頁
讓 Claude 驗證成果
FAQ
A:可以,程式碼由 Claude 寫,你負責描述效果和挑毛病。真正需要的是動態設計的語彙——知道擠壓伸展、緩動曲線、色散、粒子這些詞在說什麼,你的指令就會精準很多。
A:After Effects 用關鍵影格內插出動態,程式碼動畫把每一格寫成時間的函數直接算出來。程式碼的優勢是精準、可重現、容易改參數,也能做光線步進、粒子、物理模擬這類需要大量計算的效果;需要實拍素材或手繪的案子,還是 After Effects 比較順手。
A:Claude Code、Node.js 和 Chrome 瀏覽器就夠了。其他套件(puppeteer-core 控制瀏覽器、ffmpeg-static 輸出影片、需要物理時再加 matter.js)都可以請 Claude 在專案裡自己安裝。
A:這支 1,800 格的影片,在 M3 Max 筆電上平行開六個 Chrome,含啟動大約五分鐘。真正花時間的是來回修改;把場景拆給多個子代理平行製作,可以大幅縮短。
A:輸出 1080×1920;重要文字避開上方約 250px、下方約 420px 和右下的按鈕欄;響度抓在 −14 LUFS 左右;避免過度細碎的閃爍紋理,因為 IG 會重新壓縮。上傳時另外自訂一張封面,個人頁格狀縮圖會顯示中間 3:4 的範圍。
A:畫面是你專案裡的程式碼算出來的,要注意的是第三方素材的授權。這支片用的字體都是 SIL OFL 開源授權,matter.js 是 MIT 授權;換成其他字體或函式庫時,發布前先確認授權條款。
A:因為程式跑得起來,不代表畫面好看。讓 Claude 渲染關鍵時間點的縮圖和連續幾格的膠卷,它就能自己發現卡頓、跑版、配色問題並修正,你只需要處理「好不好看」這一層的判斷。