D3.js × Remotion 系列(二):Force-Directed Graph 物理模擬節點圖
用 D3 的 forceSimulation 算節點互斥、連線拉扯、collision 避讓,用 useMemo 預跑整段模擬,每一幀讀取對應 snapshot——25 個節點從中心一團爆散開來的科技棧依賴圖。
成品預覽
12 秒的示範影片。一開始 25 顆節點全部擠在畫面中央——直徑大概只有 16px 的一團黑點,幾乎看不出有東西。接著 fade-in 完成、力導引引擎啟動,整團節點瞬間像爆炸一樣往四面八方彈開:藍色的 frontend(React、Remotion、Next.js、D3、Three.js……)往一邊聚集、綠色的 backend(Node、Postgres、Prisma……)往另一邊靠攏、橘色的 tooling(TypeScript、Vite、ESLint……)夾在中間,靠 TypeScript 與 React、Node 的 cross-cluster 連結維持著三方的拉扯平衡。
大約 200 個 simulation tick 之後,整張圖完全穩定下來。最後 30 幀畫面 zoom out 到 0.92 倍、底下浮現一張小卡寫著「25 nodes · 40 edges · ~200 simulation ticks」,給觀眾一個全景的收尾感。整段影片沒有任何 React state、沒有任何 useEffect、沒有任何 requestAnimationFrame——所有節點的位置都是 useMemo 預先算好、按 frame 查表渲染出來的。
為什麼 Force Simulation 在 Remotion 裡很特別
如果你直接照著 D3 官方文件寫 force-directed graph,大概會長這樣:
const sim = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links))
.force("charge", d3.forceManyBody())
.on("tick", () => {
// 每個 tick 更新 SVG 的 cx / cy
selection.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
});這段在瀏覽器裡跑得很開心——forceSimulation 預設會自己掛 requestAnimationFrame,每 16ms tick 一次、迭代計算節點位置,等到 alpha 衰減到接近 0 就停下來。整個過程是「跑在 wall clock 上的物理引擎」。
但這跟 Remotion 的渲染模型完全衝突:
- Remotion 的每一幀是 deterministic——同一個 frame 不管渲染幾次都必須產生同一張畫面。RAF 不存在。
- Remotion 會 並行渲染多個 frame——render worker 可能同時掛載 4 個 component instance、每個算不同的 frame。每個 instance 各自跑 RAF 的話,第 100 frame 的 simulation 狀態和第 50 frame 完全不相關,畫面會跳來跳去。
- 即使你想用
useEffect慢慢累積狀態,也沒用——Remotion render 進程不會等 effect 跑完才截圖。
解法其實很優雅:
- 把 simulation 設成
.stop(),不要讓它自己跑 RAF。 - 用
useMemo在掛載時跑一次:手動 tick N 次,每次都記錄一份 snapshot(所有節點當下的 x、y)。 - 渲染時
frame → snapshots[frame],直接查表畫出來。
整段 simulation 變成一個 pure function:輸入是 frame number,輸出是節點座標。Remotion 並行渲染、隨機跳幀、預覽拖動——通通沒問題,因為查表永遠回傳同樣的值。
這個 pattern 不只能套到 force simulation——任何 D3 的 simulation(包括 d3-force-3d、自訂的物理引擎)都能用同樣的方式接進 Remotion。核心思想就是:把時間軸從 wall clock 搬到 frame number,把運算前置到 useMemo。
這篇會用到
d3— 主要用forceSimulation、forceLink、forceManyBody、forceCenter、forceCollideuseMemo— 整段 simulation 只跑一次,結果存起來interpolate— 一些輔助動畫(fade-in、最後的 zoom out)
整個 component 裡 沒有任何 useState、沒有任何 useEffect、沒有任何 ref。整個 simulation 是一個 pure function——這也是為什麼這個 pattern 能這麼穩。
前置知識
建議先看過 T32:D3.js Bar Chart Race,對「D3 提供算數工具、Remotion 提供 frame 與渲染」這個分工有基本概念。本篇會把分工推向極致:D3 不只算 scale 與軸線,還會跑完整的物理模擬。
Step 1:定義 nodes 與 links
先把資料準備好。我們做一個「科技棧依賴圖」:25 個節點分成 frontend(藍)、backend(綠)、tooling(橘)三類,40 條邊代表它們之間的依賴關係。
type Category = "frontend" | "backend" | "tooling";
type RawNode = {
id: string;
label: string;
category: Category;
};
type RawLink = {
source: string;
target: string;
};
const CATEGORY_COLORS: Record<Category, string> = {
frontend: "#3b82f6", // blue
backend: "#10b981", // green
tooling: "#f59e0b", // orange
};
const RAW_NODES: RawNode[] = [
{ id: "react", label: "React", category: "frontend" },
{ id: "remotion", label: "Remotion", category: "frontend" },
{ id: "nextjs", label: "Next.js", category: "frontend" },
{ id: "d3", label: "D3.js", category: "frontend" },
// ... 共 25 個
{ id: "node", label: "Node", category: "backend" },
{ id: "prisma", label: "Prisma", category: "backend" },
{ id: "postgres", label: "Postgres", category: "backend" },
// ...
{ id: "typescript", label: "TypeScript", category: "tooling" },
{ id: "vite", label: "Vite", category: "tooling" },
// ...
];
const RAW_LINKS: RawLink[] = [
{ source: "react", target: "remotion" },
{ source: "react", target: "nextjs" },
{ source: "react", target: "d3" },
// ... cluster 內的連結
{ source: "react", target: "typescript" }, // cross-cluster bridge
{ source: "node", target: "typescript" },
// ...
];關於「為什麼要 deep copy」這件事必須先說清楚:D3 的 forceSimulation 會 直接 mutate 你傳進去的 nodes 陣列——每個 node 物件會被加上 x、y、vx、vy 四個屬性,而且整段 simulation 過程都會持續更新它們。如果你直接把 RAW_NODES 餵進去,你的常數就被偷偷污染了,下一次 hot reload 或 component re-mount 拿到的就不是「初始狀態」而是「上次 simulation 結束的狀態」。
正確做法是 RAW_NODES.map(...) 建一個全新的陣列,讓 simulation 在這個 copy 上面爬,不動到原始定義。RAW_LINKS 同理。
Step 2:在 useMemo 裡跑 simulation
這是整篇最關鍵的一個 function。我們建立 simulation、.stop() 阻止它自動跑、然後手動 tick N 次,每次截一份 snapshot:
const FADE_IN_FRAMES = 30;
const TOTAL_FRAMES = 360; // 12 秒 × 30 fps
const computeSnapshots = (): FrameSnapshot[] => {
type SimNode = d3.SimulationNodeDatum & {
id: string;
label: string;
category: Category;
};
type SimLink = d3.SimulationLinkDatum<SimNode> & {
source: string | SimNode;
target: string | SimNode;
};
// Seed all nodes 在中央一個非常小的圈裡——這樣 simulation 一啟動,
// 你會看到節點像爆炸一樣往外彈開,視覺很爽。
const nodes: SimNode[] = RAW_NODES.map((n, i) => {
const angle = (i / RAW_NODES.length) * Math.PI * 2;
const radius = 8; // very tight cluster
return {
id: n.id,
label: n.label,
category: n.category,
x: WIDTH / 2 + Math.cos(angle) * radius,
y: HEIGHT / 2 + Math.sin(angle) * radius,
vx: 0,
vy: 0,
};
});
const links: SimLink[] = RAW_LINKS.map((l) => ({
source: l.source,
target: l.target,
}));
const simulation = d3
.forceSimulation<SimNode>(nodes)
.force(
"link",
d3
.forceLink<SimNode, SimLink>(links)
.id((d) => d.id)
.distance(120)
.strength(0.7),
)
.force("charge", d3.forceManyBody<SimNode>().strength(-400))
.force("center", d3.forceCenter(WIDTH / 2, HEIGHT / 2))
.force("collide", d3.forceCollide<SimNode>().radius(50))
.alpha(1)
.alphaDecay(0.012)
.stop();
// ... 接下來:每幀 tick 一次,截 snapshot
};逐個 force 解讀,每一個都對應到一種物理直覺:
forceLink— 把有連結的節點 拉近。distance(120)是「理想距離」,strength(0.7)是這個拉力的強度。你可以想像每條邊都是一條彈簧:拉太遠它會把節點拉回來,太近它會推開。forceManyBody— 節點之間的 互斥力。strength(-400)是負值,代表斥力(正值反而會吸引)。這個力是 N² 的,每個節點都會推開所有其他節點,視覺上會讓圖「均勻散開」。forceCenter— 把整體節點的重心 拉回畫面中央。沒有這個力,整張圖會慢慢漂出畫面外,就像沒有引力錨點的星系。forceCollide— 把節點當成有半徑的圓 互不重疊。radius(50)是每個節點的「碰撞半徑」,比實際畫面上的圓圈稍大,給文字 label 留呼吸空間。alphaDecay(0.012)— alpha 是 simulation 的「能量」,從 1 衰減到接近 0 表示收斂。decay 越小,simulation 跑越久才穩定;越大,越快定下來但可能還沒到最佳解就停了。0.012是經驗值,大概 200~250 個 tick 收斂。
最關鍵的是 .stop()。沒加這行,D3 會立刻用 RAF 自己跑——而我們要的是「現在不要動,等我手動叫你 tick」。
接下來每幀 tick 一次、截 snapshot:
const snapshotFrame = (): FrameSnapshot => {
const nodeSnaps: NodeSnapshot[] = nodes.map((n) => ({
id: n.id,
label: n.label,
category: n.category,
x: n.x ?? WIDTH / 2,
y: n.y ?? HEIGHT / 2,
}));
const lookup = new Map(nodeSnaps.map((n) => [n.id, n]));
const linkSnaps: LinkSnapshot[] = links.map((l) => {
const sourceId =
typeof l.source === "string" ? l.source : (l.source as SimNode).id;
const targetId =
typeof l.target === "string" ? l.target : (l.target as SimNode).id;
const s = lookup.get(sourceId)!;
const t = lookup.get(targetId)!;
return { sourceId, targetId, x1: s.x, y1: s.y, x2: t.x, y2: t.y };
});
return { nodes: nodeSnaps, links: linkSnaps };
};
const snapshots: FrameSnapshot[] = [];
// Frames 0..29:fade-in,simulation 還沒跑,定格在初始 cluster
const initial = snapshotFrame();
for (let f = 0; f < FADE_IN_FRAMES; f++) {
snapshots.push(initial);
}
// Frames 30..359:每幀 tick 一次、截一份
for (let f = FADE_IN_FRAMES; f < TOTAL_FRAMES; f++) {
simulation.tick();
snapshots.push(snapshotFrame());
}
return snapshots;
};注意 forceLink 在第一次 tick 時,會把你傳進去的 source: "react" 字串 替換成實際的 SimNode 物件——這是 D3 的 in-place mutation。所以 snapshotFrame 裡判斷 typeof l.source === "string" 是處理還沒被替換的情況(不過實務上跑過一次 tick 之後就永遠是物件了)。
snapshotFrame 把節點的 x、y 拷貝到一個全新的物件——很重要,不要直接把 simulation 內部的 nodes 陣列存起來,因為下一次 tick 它會被 mutate,你存的 reference 會跟著變。每幀都拷貝一份,snapshots 才會是時間軸上的真實切片。
Step 3:snapshot 對應 frame
整段預先算好之後,主 component 變得超簡單:
export const TutorialD3ForceGraphDemo: React.FC = () => {
const frame = useCurrentFrame();
// 整段 simulation 只跑一次,結果存起來
const snapshots = useMemo(() => computeSnapshots(), []);
const safeFrame = Math.max(0, Math.min(snapshots.length - 1, frame));
const snap = snapshots[safeFrame];
// ...接下來用 snap.nodes 和 snap.links 渲染 SVG
};safeFrame 那行是防呆——萬一 Remotion 給你的 frame 超過 snapshots 長度(例如手滑改了 composition 的 durationInFrames 但沒重算),用 clamp 不會 crash。
這就是「pure function of time」的精神:
- 同一個
frame,永遠回傳同一個snap(因為snapshots在useMemo裡只算一次) - Remotion 並行渲染多個 frame?沒問題,每個 frame 都查同一張表
- 預覽時拖動 timeline?沒問題,跳到哪幀就讀那幀
- Hot reload?
useMemo重新跑一次computeSnapshots,因為 simulation 用的種子位置是固定的(圍著中心的小圈),結果完全 deterministic
注意 useMemo 的 dependency 是空陣列 []——這跟 T28 GSAP timeline 一樣,是「掛載時跑一次、之後永遠不重算」。每幀重算是大忌:360 幀每一幀都跑一次完整的 simulation,render 會慢得像在跑馬拉松。
Step 4:渲染節點與連線
剩下就是普通的 React + SVG 渲染:
<svg
width={WIDTH}
height={HEIGHT}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
style={{ position: "absolute", inset: 0 }}
>
{/* Edges */}
<g opacity={nodeAlpha}>
{snap.links.map((l, i) => (
<line
key={`link-${i}`}
x1={l.x1}
y1={l.y1}
x2={l.x2}
y2={l.y2}
stroke="#64748b"
strokeOpacity={0.45}
strokeWidth={1.5}
/>
))}
</g>
{/* Nodes */}
<g opacity={nodeAlpha}>
{snap.nodes.map((n) => {
const color = CATEGORY_COLORS[n.category];
return (
<g key={n.id} transform={`translate(${n.x}, ${n.y})`}>
<circle
r={34}
fill={color}
fillOpacity={0.18}
stroke={color}
strokeOpacity={0.4}
strokeWidth={1}
/>
<circle
r={26}
fill={color}
stroke="#0a0e1a"
strokeWidth={2}
style={{ filter: `drop-shadow(0 0 12px ${color}aa)` }}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fontSize={13}
fontWeight={700}
fill="#ffffff"
style={{
paintOrder: "stroke",
stroke: "#0a0e1a",
strokeWidth: 3,
strokeLinejoin: "round",
}}
>
{n.label}
</text>
</g>
);
})}
</g>
</svg>幾個值得特別講的細節:
為什麼用 SVG 不用 canvas? 三個理由:
- Debug 容易——SVG 在瀏覽器 DevTools 裡可以一個 element 一個 element 檢查,看到哪個節點位置不對直接點開看屬性。Canvas 是黑盒。
- 可放大——viewBox 縮放完全無損。如果你想最後 zoom in 看某個節點,SVG 直接 scale transform 就好,不會糊。
- Remotion 的 server-side render 對 SVG 友善——Chromium headless 在 server 上渲染 SVG 是原生支援,不需要額外的 canvas runtime polyfill。Canvas 在 Node 上要嘛用
node-canvas、要嘛在 Chromium 裡跑,多一層複雜度。
當節點數超過 500 之後 SVG 會吃 layout 與 reflow,那時就要切 canvas 了。但 25 個節點?SVG 完全勝任。
每個節點兩個 circle 疊起來 — 外層 r=34 半透明的圈是 glow,內層 r=26 實心圈是節點本體,加上 drop-shadow 做出發光感。Cluster 顏色(藍 / 綠 / 橘)一眼就分得出來。
paintOrder: "stroke" + stroke + strokeWidth: 3 — 這是 SVG 的小技巧,文字會先畫一圈深色 outline 再蓋上白色 fill,效果像加了 text-shadow。在亮色節點上的小白字才不會看不清楚。
Step 5:zoom out 收尾
最後 30 幀,整個 graph 微微縮小 + 浮現一張 overlay 小卡:
// Slight zoom-out at the end so the stabilized graph "settles".
const zoom = interpolate(frame, [330, 360], [1, 0.92], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Outro overlay text fade-in.
const overlayOpacity = interpolate(frame, [325, 345], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<AbsoluteFill
style={{
transform: `scale(${zoom})`,
transformOrigin: "center center",
}}
>
{/* SVG 力導引圖在這裡 */}
</AbsoluteFill>
{/* Outro overlay */}
<div style={{ opacity: overlayOpacity, /* ... */ }}>
25 nodes · 40 edges · ~200 simulation ticks
</div>
</AbsoluteFill>
);這個 zoom out 從 1.0 縮到 0.92,幅度其實不大——但配合最後浮上來的文字,給觀眾一種「鏡頭往後拉、給你看全景」的暗示。這是影片設計的小心機:當觀眾正盯著一個複雜畫面看的時候,輕微的 scale down 會釋放他的視覺焦點,讓他「鬆一口氣」並準備進入下個鏡頭。
不要做太大——超過 0.85 觀眾會覺得是 bug 或縮圖不對勁。微微收一點點就好。
效能討論
整個 simulation 有 25 個節點、40 條邊、330 個 tick,每次 tick 是 O(N²)(forceManyBody)→ 大概 25² × 330 ≈ 200,000 次距離計算。聽起來很多,但 useMemo 裡 只跑一次——大概 30~80ms 之間,掛載一次完成。
之後 360 幀的渲染,每幀只是「查表 + 畫 SVG」,沒有任何重算。Remotion 的 render worker 基本上能跑滿 60fps(甚至更高,被 SVG 寫入瓶頸限制住)。
實測規模對照:
| 節點數 | 一次 simulation 完整跑 | 適合載體 |
|---|---|---|
| < 50 | 50ms 以內 | SVG 完全沒問題 |
| 50-200 | 100~300ms | SVG 還行,注意 alphaDecay 調快點 |
| 200-500 | 300~800ms | SVG 開始吃力,考慮 canvas |
| > 500 | 1s+ | 必須 canvas + 可能要 web worker |
如果你真的要做超大規模(譬如 npm 整個依賴樹 5000 個 package),策略是:
- 用 web worker 跑 simulation(離開主執行緒)
- snapshot 改用
Float32Array而不是 object 陣列(記憶體連續、快 5-10 倍) - 渲染從 SVG 改 canvas 或 WebGL
- 考慮用
d3-force-3d或ngraph這類更快的引擎
但本篇 25 個節點完全用不到這些優化。記住:過早優化是萬惡之源——先用最簡單的 SVG + useMemo 跑起來,跑不動再說。
可以擴充的方向
這個 pattern 一旦理解了,你能套到很多場景:
- GitHub 社群圖:把 nodes 換成某個 repo 的 contributors,links 換成「commit 過同一個檔案的人」。畫出 PR 與協作關係。
- 互動 highlight:如果你想做「某幀開始把 React 那個節點放大、其他變灰」,只要在渲染時根據 frame 判斷
n.id === "react"改變 fill 與 scale 即可。simulation 不用重跑。 - 3D 力導引:把
d3-force-3d換上去,simulation 多算一個 z 軸,渲染端用 React Three Fiber 或 SVG 投影成 2D。3D 球體效果很適合做開場 hero shot。 - Simulation + GSAP 進場:用 simulation 算完最終位置之後,把這些位置當作 GSAP 的目標座標,做一段 stagger 飛入動畫。等於是 D3 算佈局、GSAP 做進場、Remotion 串時間軸——三個工具各司其職。
- npm registry 自動依賴樹:寫個 build 階段的腳本,呼叫 npm registry API 取得某 package 的 deps tree,把 nodes / links 自動產生出來,做成「scan & visualize」的工具影片。
每一個都是同一個 pattern 的延伸:先用 useMemo 把所有能算的東西算掉,渲染時純查表。
小結
D3 的 force simulation 是一個非常成熟、設計優雅的物理引擎,但它預設是「跑在瀏覽器 RAF 上」的——這個前提跟 Remotion 的 deterministic frame model 完全相反。
但只要你理解 .stop() + 手動 tick + snapshot 的三步驟,這個衝突就不存在了:simulation 變成一段「給定種子、產生時間序列」的 pure function,而 useMemo 是它最自然的容器。整個過程沒有 React state、沒有 effect、沒有 ref——你寫的不是「動畫」,你寫的是「一段被預先計算好的時間序列查詢」。
D3 的腦 + Remotion 的手 + useMemo 的計算暫存 = 三位一體。一旦掌握這個模型,你就會發現幾乎所有「以 wall clock 為前提」的視覺化函式庫,都可以用同樣的方式接到 Remotion——只要它的核心運算是同步、可重現、可預先跑完的。
下一篇 T23:D3.js 地理投影(Geo Projection) 我們看 D3 的另一個招牌:把球面上的世界地圖投影到平面,做出旋轉地球、國家進場、軌跡路線這類「資訊圖風格」的鏡頭。