RAR 設計攻略
  • 線上課程
  • 專欄文章
  • AI 覺醒
  • 關於講師
客服信箱:riven@rar.design

© 2026 RAR 設計攻略 All Rights Reserved.欸嗚欸嗚欸欸嗚嗚嗚喔喔有限公司.統一編號: 90841757
使用者條款.隱私權政策
Made with ♥ by 無為教育科技股份有限公司.Teachify
Developer and agent API documentationSite content index for AI agents (llms.txt)API documentation (OpenAPI)API authentication guide
  1. 首頁
  2. 文章列表
  3. 純程式碼動畫怎麼做?用 Claude Code 拆解 12 種動態效果

純程式碼動畫怎麼做?用 Claude Code 拆解 12 種動態效果

從彈跳球、可變字體到光線步進、二十萬粒子與剛體物理,一支 30 秒影片的完整技術拆解
設計師 Riven

設計師 Riven

2026年9月28日 下午 4:12

AI 設計

AI TOOLS · MOTION DESIGN · 2026

純寫 Code 能設計哪些動畫?12 種動態效果技術拆解與 Claude Code 實作封面

▲ 一支 30 秒的直式影片,12 種動態效果,畫面和配樂都是在 Claude Code 裡用程式碼寫出來的

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

在 Claude Code 裡的工作方式:讓 AI 長出眼睛

很多人用 AI 寫程式卡關,是因為 AI 寫完就結束了——它不知道畫面長怎樣。做動畫最重要的一步,是讓 Claude Code 能「看到」自己的作品。

做法是先請它寫一個渲染小工具:輸入場景名稱和幾個時間點,就輸出那幾格的截圖,拼成一張縮圖總表。Opus 5.5 看得懂圖片,渲染完它會自己打開總表,像藝術總監一樣挑毛病——鏡頭停住了 0.1 秒、這個橘配藍變成了洋紅色、字被 IG 的按鈕蓋住——然後回頭改程式,再渲染一次。

Claude Code 終端機介面:橘色背景上的深色視窗顯示 Welcome to Claude Code 與 Simmering 思考狀態

▲ Claude Code 在終端機裡的樣子(圖片來源:Anthropic 官方)

Claude Code 編輯器面板:讀取檔案後寫入程式碼,綠色 diff 標出新增內容

▲ 它會先讀檔、再寫檔,每一步改了什麼都看得到(圖片來源:Anthropic 官方)

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

01–06:動態設計的經典原理,用程式碼重寫一次

前六種是每個動態設計師都學過的基本功:時間感、字體、形狀、立體、節奏、流動。這次它們全部由公式驅動:彈跳球的高度來自重力公式,字寬來自可變字體的軸,形狀的回彈來自阻尼彈簧。下面每段都是一小節四拍,拍位從這段開始的那個重拍算起,記作第 0 拍,就像時間軸從 0 秒開始。

EFFECT 01 · TIMING

01 時間感:先決定球落在哪一拍,再讓物理算出它跳多高

純寫 Code 動畫 TIMING 時間感:Canvas 2D 彈跳球的壓扁與拉長、洋蔥皮殘影、等時間間距點,以及即時畫出的 graph editor 曲線

▲ 球正在第三次彈跳的下墜段,沿速度方向被拉長,身後拖著橘色洋蔥皮與灰色間距點;上方 graph editor 的播放頭剛走到 f 070,地板上的關鍵格標著 028 與 056。

技術原理

動畫十二法則的第一題,永遠是彈跳球。這段我把它拆成三層一起跑:球本身的壓扁與拉長、畫面中段的洋蔥皮和間距點、上方一條即時畫出來的 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

02 動態字體:讓每個字母的寬度,各自跟著一條彈簧呼吸

純寫 Code 動畫 KINETIC TYPE 動態字體:Archivo 可變字體的 wdth 寬度軸在 Canvas 2D 中逐字變寬,橘底黑字 MOTION 搭配左右交錯捲動的描邊字列

▲ 字體工具面板退場後,六排描邊 MOTION 從上下滑入、奇偶排往相反方向捲動;中間的實心字仍跟著彈簧在改變寬度,字級也同步縮放,讓總寬維持 840 像素。

技術原理

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

03 形狀變形:256 根輻條,讓方形、三角形、星形互相流動

純寫 Code 動畫 SHAPE MORPH 形狀變形:用極座標半徑陣列讓圓形、方形、三角形、星形以阻尼彈簧互相變形,後方有 riso 錯版色影與被推開的磁吸點陣

▲ 標註已經寫著 SHAPE 03 — TRIANGLE,但彈簧還在過衝,三角形的邊往內凹;後方的橘、藍兩層色影落後幾格,像套色沒對準的 riso 印刷,下方還有一顆拖著尾巴的衛星光點。

技術原理

做形狀變形,最麻煩的一直是「對點」:方形 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

04 黏土 3D:同一個圓,在同一格畫面裡長出體積

Claude Code 純程式碼動畫:WebGL2 raymarching 算出的橘色黏土甜甜圈,SDF 距離場搭配軟陰影、環境光遮蔽與暗部藍紫輪廓光

▲ 這段第 2 拍的甜甜圈。沒有任何 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

05 節奏網格:99 塊包浩斯磚,跟著拍子一波一波翻面

Claude Code 純程式碼動畫:Canvas 2D 繪製的包浩斯幾何方塊網格,橘、黑、米白、鈷藍四色磚跟著節拍錯位旋轉,中間一條黑色帶放著 Instrument Serif 斜體 rhythm

▲ 旋轉波正掃過網格:有些磚已經轉完、換上新配色,有些還在半路上,邊緣糊掉的就是動態模糊。中間的 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

06 流動線條:58 條山脊線,把一個字藏進會呼吸的地形

Claude Code 純程式碼動畫:Canvas 2D 繪製的 58 條 Perlin noise 山脊線,前景山峰遮住後方線條,Instrument Serif 斜體 flow 字樣插在地形中間,下半部被山峰吃掉

▲ flow 這個字被插在第 25、26 條線之間,所以後面的山在字的後方,前面的山把字的下半部吃掉。中段偏橘的就是每條線的漸層熱區。

技術原理

畫面是 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

07–12:以前要開好幾套軟體的新世代技術

後六種是另一個量級。液態玻璃要算折射和色散,光線步進要在著色器裡解 3D 場景,二十萬顆粒子要交給 GPU,反應擴散是一場化學模擬,著色器風格化要把 3D 畫面即時轉成五種印刷網屏,剛體物理要算碰撞。過去這些分別屬於 3D 軟體、粒子外掛、生成藝術工具,這次全部在同一份程式碼裡,跟著同一份節拍表動。

EFFECT 07 · LIQUID GLASS

07 液態玻璃:會融合、會分裂、還會把背後的字折出彩虹的果凍玻璃

液態玻璃動態效果:WebGL2 SDF metaballs 玻璃球放大折射背後的「玻璃」「GLASS」字體海報,邊緣帶色散彩虹與 fresnel 高光

▲ 玻璃球像放大鏡一樣掃過海報,「玻璃」被放大、GLASS 被扭曲,邊緣滲出一圈紅綠色散。上方那行 ABBE 10.1 是真的算出來的。

技術原理

這一段我最想講清楚,因為它跟 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

08 光線步進:沒有任何 3D 模型,一條公式複製出無限延伸的彩虹金屬迴廊

Raymarching 光線步進動態效果:WebGL2 GLSL 以 domain repetition 複製出無限晶格,鏡頭穿過帶薄膜干涉彩虹的鏤空方塊隧道

▲ 鏡頭鑽進鏤空方塊的孔洞,一層套一層的圓環往深處延伸,表面是肥皂泡那種紫綠薄膜色。下方 HUD 正在解碼當下的 SDF 公式。

技術原理

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

09 二十萬粒子:每一顆都知道自己在第幾秒該站在哪裡

二十萬粒子動態效果:WebGL2 GPU 粒子排成 (2,3) 環面紐結,鈷藍與橘色依表面法線打光,隨後以 curl noise 爆散成塵埃

▲ 二十萬顆粒子排成一個 (2,3) 環面紐結,受光面偏橘、背光面偏藍。底部進度條走到第 3 段,右邊的 N=200,000 就是實際畫出的粒子數。

技術原理

大部分人想到粒子,會想到物理模擬:每一幀算一次力,粒子一步一步往前推。這支片刻意反過來做。二十萬顆粒子沒有任何狀態,位置是「粒子編號 × 時間」的純函數,全部寫在 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

10 反應擴散:讓紋路從「生長」兩個字裡自己長出來

反應擴散 Gray–Scott 動畫:橘色迷宮紋路從「生長」兩個字的字形中長出,外圍藍色紋路向外蔓延,Claude Code 以 WebGL2 GLSL 寫程式生成

▲ 橘色迷宮就是「生」「長」兩個字的範圍,外圍的藍色紋路是從字的邊緣慢慢長出去的。左下角即時顯示 F、k 兩個參數,右下角是模擬跑到第幾步。

技術原理

反應擴散用的是 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

11 著色器風格化:同一個 3D 物件,踩著拍子換五種印刷術

著色器風格化動畫:raymarching 繪製的環面紐結被轉成藍、白、橘三色 ASCII 字元畫面,下方標籤正從 ASCII 切換成 PIXEL SORT,Claude Code 以 WebGL2 GLSL 生成

▲ 這一格停在 ASCII 字元風格,下方標籤正被亂碼換成 PIXEL SORT。四角的對位標記和上方的 PASS 2 檔名,都是在模仿印刷廠的出片單。

技術原理

這段是兩段式渲染。第一個 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

12 剛體物理模擬:讓 matter.js 準時落在拍子上

剛體物理模擬動畫:CLAUDE 字母、橘色 5.5 數字、物理方塊與彩色圓形、三角形、星星在傾斜的地板上堆疊滑動,matter.js 以 240 Hz 固定步長預先計算,Claude Code 生成

▲ 這段第 2 拍,地板像蹺蹺板往右下傾斜,HUD 顯示 θ −15.5°,整堆字母和形狀開始往下滑。中間那組橘色「5.5」是一個複合剛體,三個字永遠一起翻。

技術原理

剛體模擬用的是開源 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 毫秒以內,不然改一個參數就要等很久。

三、IG 的規格要從第一天就算進去

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

跟 After Effects、AI 影片生成比起來差在哪?

三種做法各有各的位置。程式碼動畫最強的地方是「精準又可以一直改」,最不適合的是需要實拍素材或大量手繪的案子。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.com/product/claude-code

官方文件總覽

code.claude.com/docs/en/overview

快速開始

code.claude.com/docs/en/quickstart

讓 Claude 驗證成果

code.claude.com/docs/en/best-practices

GitHub

github.com/anthropics/claude-code

AI 覺醒設計應用攻略

SUBSCRIPTION

AI 覺醒設計應用攻略

AI 工具每週都在更新,我幫你過濾雜訊,只留對設計師真正有用的應用。

了解訂閱 →

FAQ

常見問題

Q:不會寫程式,也能用 Claude Code 做動畫嗎?

A:可以,程式碼由 Claude 寫,你負責描述效果和挑毛病。真正需要的是動態設計的語彙——知道擠壓伸展、緩動曲線、色散、粒子這些詞在說什麼,你的指令就會精準很多。

Q:純程式碼動畫跟 After Effects 做的有什麼不同?

A:After Effects 用關鍵影格內插出動態,程式碼動畫把每一格寫成時間的函數直接算出來。程式碼的優勢是精準、可重現、容易改參數,也能做光線步進、粒子、物理模擬這類需要大量計算的效果;需要實拍素材或手繪的案子,還是 After Effects 比較順手。

Q:需要準備哪些軟體?

A:Claude Code、Node.js 和 Chrome 瀏覽器就夠了。其他套件(puppeteer-core 控制瀏覽器、ffmpeg-static 輸出影片、需要物理時再加 matter.js)都可以請 Claude 在專案裡自己安裝。

Q:做一支 30 秒的影片,渲染要多久?

A:這支 1,800 格的影片,在 M3 Max 筆電上平行開六個 Chrome,含啟動大約五分鐘。真正花時間的是來回修改;把場景拆給多個子代理平行製作,可以大幅縮短。

Q:做成 IG Reels 要注意什麼?

A:輸出 1080×1920;重要文字避開上方約 250px、下方約 420px 和右下的按鈕欄;響度抓在 −14 LUFS 左右;避免過度細碎的閃爍紋理,因為 IG 會重新壓縮。上傳時另外自訂一張封面,個人頁格狀縮圖會顯示中間 3:4 的範圍。

Q:程式碼做出來的動畫可以商用嗎?

A:畫面是你專案裡的程式碼算出來的,要注意的是第三方素材的授權。這支片用的字體都是 SIL OFL 開源授權,matter.js 是 MIT 授權;換成其他字體或函式庫時,發布前先確認授權條款。

Q:為什麼要讓 Claude 渲染縮圖給自己看?

A:因為程式跑得起來,不代表畫面好看。讓 Claude 渲染關鍵時間點的縮圖和連續幾格的膠卷,它就能自己發現卡頓、跑版、配色問題並修正,你只需要處理「好不好看」這一層的判斷。