---
title: "純程式碼動畫怎麼做？用 Claude Code 拆解 12 種動態效果"
slug: pure-code-animation-claude-code-12-effects
url: https://rar.design/posts/pure-code-animation-claude-code-12-effects
published_at: 2026-09-28
author: "設計師 Riven"
category: "AI 設計"
access: public_access
---

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

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

By **設計師 Riven** - 資深數位產品設計師｜知名線上課程講師 | 2026-09-28

AI TOOLS · MOTION DESIGN · 2026▲ 一支 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 FPS1,800逐格計算的畫面12種動態技術128BPM，畫面與配樂共用節拍28 萬字元的原始碼0影片、圖片、音效素材影片的結構很單純：開場一個問句「純寫 Code 能設計哪些動畫？」，接著 12 段各佔一小節（約 1.9 秒），反應擴散那段是配樂的 breakdown，給了兩小節，最後快剪回顧、落在「全部都用程式碼畫的」。30 秒時間軸 · 每格一小節（1.875 秒）題010203040506070809101112尾0s15s30s■ 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 在終端機裡的樣子（圖片來源：Anthropic 官方）▲ 它會先讀檔、再寫檔，每一步改了什麼都看得到（圖片來源：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 / CLASSICS01–06：動態設計的經典原理，用程式碼重寫一次前六種是每個動態設計師都學過的基本功：時間感、字體、形狀、立體、節奏、流動。這次它們全部由公式驅動：彈跳球的高度來自重力公式，字寬來自可變字體的軸，形狀的回彈來自阻尼彈簧。下面每段都是一小節四拍，拍位從這段開始的那個重拍算起，記作第 0 拍，就像時間軸從 0 秒開始。EFFECT 01 · TIMING01 時間感：先決定球落在哪一拍，再讓物理算出它跳多高▲ 球正在第三次彈跳的下墜段，沿速度方向被拉長，身後拖著橘色洋蔥皮與灰色間距點；上方 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 TYPE02 動態字體：讓每個字母的寬度，各自跟著一條彈簧呼吸▲ 字體工具面板退場後，六排描邊 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 MORPH03 形狀變形：256 根輻條，讓方形、三角形、星形互相流動▲ 標註已經寫著 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 3D04 黏土 3D：同一個圓，在同一格畫面裡長出體積▲ 這段第 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 · RHYTHM05 節奏網格：99 塊包浩斯磚，跟著拍子一波一波翻面▲ 旋轉波正掃過網格：有些磚已經轉完、換上新配色，有些還在半路上，邊緣糊掉的就是動態模糊。中間的 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 · FLOW06 流動線條：58 條山脊線，把一個字藏進會呼吸的地形▲ 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 / FRONTIER07–12：以前要開好幾套軟體的新世代技術後六種是另一個量級。液態玻璃要算折射和色散，光線步進要在著色器裡解 3D 場景，二十萬顆粒子要交給 GPU，反應擴散是一場化學模擬，著色器風格化要把 3D 畫面即時轉成五種印刷網屏，剛體物理要算碰撞。過去這些分別屬於 3D 軟體、粒子外掛、生成藝術工具，這次全部在同一份程式碼裡，跟著同一份節拍表動。EFFECT 07 · LIQUID GLASS07 液態玻璃：會融合、會分裂、還會把背後的字折出彩虹的果凍玻璃▲ 玻璃球像放大鏡一樣掃過海報，「玻璃」被放大、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 · RAYMARCHING08 光線步進：沒有任何 3D 模型，一條公式複製出無限延伸的彩虹金屬迴廊▲ 鏡頭鑽進鏤空方塊的孔洞，一層套一層的圓環往深處延伸，表面是肥皂泡那種紫綠薄膜色。下方 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 PARTICLES09 二十萬粒子：每一顆都知道自己在第幾秒該站在哪裡▲ 二十萬顆粒子排成一個 (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–DIFFUSION10 反應擴散：讓紋路從「生長」兩個字裡自己長出來▲ 橘色迷宮就是「生」「長」兩個字的範圍，外圍的藍色紋路是從字的邊緣慢慢長出去的。左下角即時顯示 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 STYLES11 著色器風格化：同一個 3D 物件，踩著拍子換五種印刷術▲ 這一格停在 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 BODIES12 剛體物理模擬：讓 matter.js 準時落在拍子上▲ 這段第 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 EffectsAI 影片生成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-practicesGitHubgithub.com/anthropics/claude-codeSUBSCRIPTIONAI 覺醒設計應用攻略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 渲染關鍵時間點的縮圖和連續幾格的膠卷，它就能自己發現卡頓、跑版、配色問題並修正，你只需要處理「好不好看」這一層的判斷。
