Particle System:200 顆 seeded 粒子做出爆炸 → 飛行 → 成字的動畫
用純 React 刻一個 200 顆粒子的爆炸動畫,完成 4 個 phase:中心爆炸、自由飛行、聚合成字、淡出。核心是 seeded random + 解析式物理,確保每幀完全可重現。
成品預覽
10 秒、200 顆粒子、4 個階段:從中心一口氣爆開、帶重力自由飛行、再被拉回來排成 "PARTICLES" 字形、最後整片放大淡出。全程純 React + CSS transform,沒有 <canvas>、沒有 WebGL、沒有任何第三方動畫庫。
這篇會做出什麼
一支 10 秒(300 frame)的粒子動畫,畫面上總共 200 顆粒子,由四個 phase 組成:
- Phase 1 · Explosion(0~60 frame):所有粒子從畫面中心
(960, 540)爆開,速度由 seeded random 決定,角度均勻分布於 360°。 - Phase 2 · Free Flight(60~180 frame):粒子帶
gravity = 0.02的重力往下拋物線飛行,同時慢慢降透明度。 - Phase 3 · Reform(180~270 frame):每顆粒子各自插值回自己的「字形目標座標」,200 顆粒子會精準排成
PARTICLES字樣。 - Phase 4 · Fade Out(270~300 frame):粒子 scale 從 1 放大到 1.6 並 fade 到 0。
整個 composition 只用原生 <div> + transform,沒有 canvas、沒有 WebGL、沒有 pixi.js、沒有 three.js。200 顆 React 元素在 1920×1080 下跑得非常順,渲染時也不會跟 Remotion 的 per-frame 模型打架。這篇的重點不是「多花俏」,而是把粒子系統在 Remotion 裡的三個關鍵設計決策講清楚。
前置知識
- T01 Hello Remotion — 熟悉
useCurrentFrame和interpolate - T04 轉場與緩動 — 熟悉
Easing.inOut - T22 Kinetic Typography — 這篇會沿用 T22 介紹過的 seeded random 概念,把它從「每個字母 stagger 隨機」擴張到「每顆粒子都有獨立穩定的初始狀態」
💡 如果你還沒看過 T22,至少先掃一眼它的
mulberry32段落——這篇會直接用同一個 PRNG 函式。
原理解析:Remotion 粒子系統的三個關鍵決策
這個段落是整篇教學的靈魂。在貼第一行 code 之前,你必須先理解這三件事——否則一定會踩到坑,而且是那種「跑起來好像對但每次渲染都不一樣」的詭異坑。
決策 1:為什麼不用 <canvas> 而用 React 元素?
直覺上,200 顆粒子應該用 <canvas> 畫。但在 Remotion 的世界裡這是反模式。原因很簡單:Remotion 的渲染模型是 「每一幀都是一個 pure function of frame number」,瀏覽器的 <canvas> API 則是命令式 (imperative) 的 ctx.fillRect()——你必須自己管 render loop、requestAnimationFrame、state diff。這套機制跟 Remotion 的 per-frame 快照會互相打架:Remotion 要求「給我 frame 150 的畫面」,canvas 內部的 loop 不知道該怎麼回應。
解法是:每顆粒子就是一個 <div>。200 顆 <div> + transform: translate(...) 在現代瀏覽器上有 GPU composite 加速,實測跑到 500 顆簡單圓形都還很順。真正的效能天花板大約在 1000~1500 顆(取決於 box-shadow 的複雜度),到了那個量級才需要考慮 <canvas> + useDelayRender + ImageBitmap 的進階方案。
// ❌ Remotion 裡不要這樣寫
useEffect(() => {
const ctx = canvasRef.current.getContext("2d");
requestAnimationFrame(draw); // ← Remotion 根本不會執行這個 loop
}, []);
// ✅ 這樣寫就好
return particles.map((p, i) => (
<div key={i} style={{ transform: `translate(${x}px, ${y}px)` }} />
));決策 2:Seeded random 為什麼是粒子系統的生死題
再講一次 Remotion 的核心契約:每一幀的畫面 = f(frame) 是一個純函式。如果你在 render 函式裡呼叫 Math.random(),那每一次 React 重新渲染(例如播放、拖動 timeline、匯出時的 parallel render)都會拿到不同的值。結果就是——粒子每一幀都在隨機跳位置,完全不是動畫,是雜訊。
解法是兩層:
- 初始狀態用 seeded PRNG 產生:例如
mulberry32(1337),給同一個 seed 永遠產出同一個序列。 - 初始狀態用
useMemo包起來:讓每次 re-render 都拿到同一組初始速度、大小、顏色,而不是重跑一次 PRNG。 - 每幀位置由 deterministic 公式計算:完全不碰
Math.random(),只靠 frame 推導。
function mulberry32(seed: number) {
return function () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const particles = useMemo(() => makeParticles(), []); // ← 關鍵💡 為什麼不用
Math.random()+useState快取?因為 Remotion 的多執行緒 render 會在不同 worker 裡建立新的 React tree,useState的初始值也會重跑——唯一穩定的方法是useMemo+ 固定 seed。
決策 3:解析式物理 vs 迭代式物理
這是整篇最關鍵的一個設計決定,也是絕大多數從 Processing / p5.js 轉過來的人會踩到的坑。
迭代式物理(Iterative) 長這樣:
// ❌ 在 Remotion 裡這樣寫會爛掉
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += gravity;這套寫法預設「幀是依序執行的」,frame 1 → 2 → 3 → ... 每次把前一幀的結果 mutation 上去。但 Remotion 支援隨機存取(random access):使用者拉 timeline 到 frame 200,Remotion 不會回去把 0~199 全部跑一次,而是直接問「frame 200 長什麼樣?」。迭代寫法這時就崩了。
解析式物理(Analytic / closed-form) 才是正解:
// ✅ 對 Remotion 友善的寫法
const x = initialX + vx * frame;
const y = initialY + vy * frame + 0.5 * gravity * frame * frame;這是國高中物理的等加速度運動公式 s = v₀t + ½at²,但它有個超棒的副作用:任何 frame 都能 O(1) 算出來,而且往回拖 timeline 完全免費。這就是為什麼 Remotion 的動畫幾乎都用 interpolate 而不是手動累加——思維要從「時間序列」轉成「時間的函式」。
Step 1:建立 composition 與 mulberry32 helper
Claude Code:
新增 Composition "TutorialParticlesDemo":
- 1920x1080
- fps 30
- durationInFrames 300(10 秒)
- 新建 src/scenes/TutorialParticlesDemo.tsx
- 在 src/Root.tsx 註冊
檔案頂部先放常數與 mulberry32:
- PARTICLE_COUNT = 200
- CENTER_X = 960, CENTER_Y = 540
- P1_END = 60, P2_END = 180, P3_END = 270, P4_END = 300
- mulberry32(seed) 回傳 () => number,0~1 之間
Phase 邊界常數集中定義,後面 phase 判斷每次都是 if (frame < P1_END),改動畫節奏只要改這四個數字。mulberry32 是目前最流行的 32-bit seeded PRNG,品質夠好、程式碼短,粒子系統的隨機性要求遠低於密碼學等級,這個就夠了。
💡 為什麼 seed 選
1337?純粹因為好記。你可以改成任何整數,動畫形狀會完全不同但同樣穩定。
Step 2:<Particle> 元件與 useMemo 快取初始狀態
新建 ParticleField 元件:
- 用 useCurrentFrame 拿 frame
- const particles = useMemo(() => makeParticles(), [])
- makeParticles() 回傳 ParticleInit[],每顆包含:
- vx, vy: 初始速度(由 mulberry32 決定角度和 speed)
- size: 6~14 px
- hue: 180~280(青到紫)
- rotSpeed: -4~4
- targetX, targetY: phase 3 的目標字形位置
- 回傳 <AbsoluteFill>,裡面 particles.map 渲染 <div>
這是整個粒子系統的「靜態藍圖」。useMemo 空依賴陣列確保 makeParticles() 只跑一次,200 顆粒子的 vx, vy, hue, size 在整個 10 秒內都是同一組固定值。
function makeParticles(): ParticleInit[] {
const rand = mulberry32(1337);
const particles: ParticleInit[] = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
const angle = rand() * Math.PI * 2;
const speed = 3 + rand() * 3; // 3~6 px/frame
particles.push({
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 6 + rand() * 8,
hue: 180 + rand() * 100,
rotSpeed: (rand() - 0.5) * 8,
targetX: TARGETS[i].x,
targetY: TARGETS[i].y,
});
}
return particles;
}💡 Why 極座標(angle + speed)而不是直接
vx = rand() * 10 - 5? 因為極座標保證粒子 speed 分布是均勻的圓環,直接撒vx, vy會得到方形偏差——四個角落的粒子會比上下左右更快。
Step 3:Phase 1 爆炸動畫(0~60 frame)
在 ParticleField 的 map 迴圈裡,計算每顆粒子的 x, y:
- flightX = CENTER_X + p.vx * Math.min(frame, P2_END)
- flightY = CENTER_Y + p.vy * Math.min(frame, P2_END)
+ 0.5 * 0.02 * flightFrame²
- 若 frame < P1_END:opacity 從 0 interpolate 到 1(前 8 幀)
爆炸的重點是 「瞬間從中心射出」。因為我們用的是解析式公式 x = CENTER_X + vx * frame,frame = 0 時所有粒子都精準位於 (960, 540),隨著 frame 遞增各自散開。不需要「爆炸動畫」的特殊處理,公式本身就是爆炸。
const flightFrame = Math.min(frame, P2_END);
const flightX = CENTER_X + p.vx * flightFrame;
const flightY =
CENTER_Y +
p.vy * flightFrame +
0.5 * 0.02 * flightFrame * flightFrame;Math.min(frame, P2_END) 是小 trick:phase 3 之後要把 flight 位置「凍結」在 frame 180 的狀態,這樣插值回字形時才有穩定的起點。如果不加這個 clamp,phase 3 還會繼續往下掉。
Step 4:Phase 2 自由飛行 + 重力(60~180 frame)
Phase 2 不需要額外寫邏輯——flightX, flightY 公式已經涵蓋了。
只要加 opacity 的 phase 2 分支:
else if (frame < P2_END) {
opacity = interpolate(frame, [P1_END, P2_END], [1, 0.7]);
}
這裡是踩過坑才選出來的魔術數字組合:speed 3~6 px/frame、gravity 0.02 px/frame²。為什麼?因為我們需要 phase 2 結束(frame 180)時,粒子還要留在 1920×1080 畫面裡,phase 3 才能把它們拉回中心成字。
簡單算一下最壞情況:速度 6 px/frame、飛 120 frame = 720 px 位移,加上起點是中心 960/540,粒子最遠會飛到 (1680, 1260)——x 還在畫面內,y 已經跑出去了。這就是為什麼 gravity 不能太大:0.5 × 0.02 × 120² = 144 px,額外往下 144 px 剛好是戲劇性抛物線但還沒掉出螢幕。如果 gravity 改成 0.05,就會變成 0.5 × 0.05 × 14400 = 360 px,粒子瞬間全掉光。
💡 調這類參數的 workflow:先寫死 gravity = 0,跑一遍確認速度沒問題;再慢慢加 gravity,每次 × 2 直到 phase 2 末尾畫面看起來戲劇性剛好。
Step 5:Phase 3 聚合成 "PARTICLES" 字形(180~270 frame)
建一個 5x7 的 pixel font:
- 每個字母是一個 [col, row][] 陣列(像素座標)
- PARTICLES 9 個字母,字寬 5 cell、高 7 cell、間距 1.4 cell
- buildTargets() 把所有亮點座標放進一個陣列
- 若粒子數量 > 亮點數量,用 % 循環補齊
Phase 3 的位置計算:
else if (frame < P3_END) {
const t = interpolate(frame, [P2_END, P3_END], [0, 1], {
easing: Easing.inOut(Easing.ease),
});
x = flightX + (p.targetX - flightX) * t;
y = flightY + (p.targetY - flightY) * t;
}
Pixel font grid 是個偷吃步的做法——不用載任何 font 檔案,不用解析 SVG path,直接用陣列手刻 5×7 的 bitmap。P = [[0,0],[1,0],[2,0],[3,0],[0,1],[4,1],...] 這樣列出亮點座標就好。每個 cell 22 px,整個 PARTICLES 字樣大約 1200 px 寬、154 px 高,置中放在 1920×1080 中間。
const FONT: Record<string, [number, number][]> = {
P: [[0,0],[1,0],[2,0],[3,0],[0,1],[4,1],[0,2],[4,2],
[0,3],[1,3],[2,3],[3,3],[0,4],[0,5],[0,6]],
A: [[1,0],[2,0],[3,0],[0,1],[4,1],[0,2],[4,2],
[0,3],[1,3],[2,3],[3,3],[4,3],[0,4],[4,4],
[0,5],[4,5],[0,6],[4,6]],
// R, T, I, C, L, E, S 同樣手刻 ...
};插值用 Easing.inOut(Easing.ease) 很重要——linear 會讓聚合看起來像被拖拉的,inOut 則有「先慢 → 加速 → 緩停」的自然感。每顆粒子從自己當下的 (flightX, flightY) 出發,各自趕到自己的 (targetX, targetY),視覺上就是「散開的粒子被磁鐵吸回來排字」。
Step 6:Phase 4 淡出 + 音效 + UI
最後 30 幀的 phase 4:
- x, y 停在 targetX, targetY
- opacity 從 1 → 0
- scale 從 1 → 1.6(放大淡出的經典手法)
- transform: `rotate(${frame * p.rotSpeed}deg) scale(${scale})`
另外加上:
- <TopLabel>:固定在頂端的 "Particle System · 200 seeded particles"
- <PhaseIndicator>:左下角顯示 "Phase X / 4 — Explosion/Flight/Reform/Fade"
- <ProgressBar>:底部進度條,interpolate frame → width
- 音效 4 層:frame 0 爆炸聲、60 riser、180 swipe、270 outro bell
音效分層是用 <Sequence from={X} durationInFrames={Y}> 把每個 <Audio> 定位到對應 phase 的開頭,是 T11 音效設計那套的延伸應用。
<Sequence from={0} durationInFrames={30}>
<Audio src={staticFile("audio/t11/06-drop-boom.mp3")} volume={0.8} />
</Sequence>
<Sequence from={180} durationInFrames={30}>
<Audio src={staticFile("audio/t15/01-title-swipe.mp3")} volume={0.7} />
</Sequence>放大 + 淡出是「粒子煙火結尾」的通用收法,比單純 fade 更戲劇。rotate 是累積值 frame * rotSpeed,每顆粒子轉速不同,整體看起來像星塵在閃爍。
效能考量
實測在 M1 Mac + Chrome 的數據:200 顆粒子 渲染 300 frame 大約 8 秒(含音訊),順得像水。500 顆 時 box-shadow 開始變成瓶頸,渲染時間大約 18 秒。1000 顆以上 就開始有單幀 > 20ms 的停頓,這時候就該認真考慮把 boxShadow 拔掉(或改用低頻率 radial gradient 背景 fake 光暈),甚至轉去 <canvas> + useDelayRender。
三個提速小技巧:
useMemo初始狀態是必須的——如果省掉,200 顆粒子每幀都會重跑makeParticles(),渲染時間直接 × 3。- 用
transform: translate()而不是left / top——後者觸發 layout,前者只觸發 composite,GPU 直接處理。本篇為了程式碼簡潔用了left/top,真要追速度換成transform: translate3d(${x}px, ${y}px, 0)會再快一截。 willChange: "transform, opacity"——提示瀏覽器這個 element 會動,先提到 composite layer。別濫用,有 500 個就會吃掉幾百 MB 記憶體。
延伸:其他粒子類型
同一套「seeded init + 解析式位置」的架構可以換湯不換藥做出各種粒子效果。
1. 煙霧(Smoke):初速度向上 + 向上 drift + opacity 從 1 fade 到 0 + size 從小放大。關鍵是 y velocity 要帶 decay(空氣阻力),用 vy * (1 - frame * 0.003) 模擬。
const y = startY - p.vy * frame * (1 - frame * 0.003);
const opacity = interpolate(frame, [0, 90], [1, 0]);
const scale = interpolate(frame, [0, 90], [0.5, 2.5]);2. 星空 Twinkle:粒子位置不動,只改透明度。每顆用不同 phase 的 sin(frame * speed + phase) * 0.5 + 0.5,整片星星就會隨機閃爍——但仍然是 deterministic。
const twinkle = Math.sin(frame * p.speed + p.phase) * 0.5 + 0.5;
// opacity = 0.2 + twinkle * 0.83. 落葉(Falling Leaves):慢速往下 + 水平 sin 搖擺模擬空氣流動。x = startX + Math.sin(frame * 0.05 + p.phase) * 40;、y = startY + frame * 1.5;,外加 rotate 累積,就有風中葉子的感覺。
這三種效果的共通點都是:初始狀態穩定、每幀位置是 f(frame) 的純函式。只要遵守這兩條,粒子想變什麼都可以。
FAQ
Q:要 1000 顆粒子會爆嗎?
A:不會爆但會變慢。實測 1000 顆簡單圓形 + 無 box-shadow 還能跑,但若每顆都有 box-shadow 就會從 30 fps 的 real-time preview 掉到 5~10 fps。超過 1500 顆就建議改 <canvas> + useDelayRender(參考 /docs/delay-render)。
Q:為什麼 useMemo 裡不用 seed + frame?
A:因為 useMemo 的 deps 如果放 [frame],每一幀都會重新執行 makeParticles(),不但效能爆炸,而且會讓「初始速度」每幀都被重新分配一次——雖然 seeded PRNG 會產生一樣的值,但邏輯上完全沒意義,還會拖慢速度。初始狀態用 [],動態值(位置)直接在 JSX 裡算,這才是 Remotion 的標準模式。
Q:粒子尾跡(trail)怎麼做?
A:兩種做法。簡單版:每顆粒子多渲染 5~10 個「歷史位置」,用前幾幀的解析公式算出來(例如 vx * (frame - i)),opacity 遞減。進階版:用 SVG <path> 把整條軌跡畫成曲線,Remotion 裡非常好用。兩種都受惠於解析式物理——歷史位置可以直接反推,不用記 state。
Q:粒子可以互相碰撞嗎?
A:可以,但非常麻煩,而且碰撞本質上是迭代式的——frame N 的碰撞結果影響 frame N+1,沒辦法寫成 closed-form。如果真的需要碰撞,解法是先離線用物理引擎(例如 matter.js)在 Node 腳本裡跑一次全部 300 frame,把每顆粒子每幀的 (x, y) 匯出成 JSON,然後 Remotion 渲染時直接查表。這樣還是 f(frame)——只是函式變成陣列索引。
本篇涵蓋的官方文件
- /docs/use-current-frame —
useCurrentFrame用法 - /docs/interpolate —
interpolate三段式插值 - /docs/easing —
Easing.inOut和 curve 函式 - /docs/reusability — 元件重用與 memo 策略
- /docs/the-fundamentals — Remotion 的「每幀純函式」核心哲學
下一步
- T22 Kinetic Typography — 把 seeded random 應用在文字 stagger 動畫
- T24 Glitch Effect — 用 seeded noise 做故障風特效
- T19 GSAP 整合 — 什麼時候該把粒子動畫交給 GSAP、什麼時候不該
有問題歡迎到 FB 社群 討論!