Remotion LabRemotion Lab
D3 資料視覺化系列D3.js × Remotion 系列(二):Force-Directed Graph 物理模擬節點圖
d3data-visualizationsimulationadvanced

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 跑完才截圖。

解法其實很優雅:

  1. 把 simulation 設成 .stop()不要讓它自己跑 RAF。
  2. useMemo 在掛載時跑一次:手動 tick N 次,每次都記錄一份 snapshot(所有節點當下的 x、y)。
  3. 渲染時 frame → snapshots[frame],直接查表畫出來。

整段 simulation 變成一個 pure function:輸入是 frame number,輸出是節點座標。Remotion 並行渲染、隨機跳幀、預覽拖動——通通沒問題,因為查表永遠回傳同樣的值。

這個 pattern 不只能套到 force simulation——任何 D3 的 simulation(包括 d3-force-3d、自訂的物理引擎)都能用同樣的方式接進 Remotion。核心思想就是:把時間軸從 wall clock 搬到 frame number,把運算前置到 useMemo。

這篇會用到

  • d3 — 主要用 forceSimulationforceLinkforceManyBodyforceCenterforceCollide
  • useMemo — 整段 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 物件會被加上 xyvxvy 四個屬性,而且整段 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 把節點的 xy 拷貝到一個全新的物件——很重要,不要直接把 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(因為 snapshotsuseMemo 裡只算一次)
  • 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? 三個理由:

  1. Debug 容易——SVG 在瀏覽器 DevTools 裡可以一個 element 一個 element 檢查,看到哪個節點位置不對直接點開看屬性。Canvas 是黑盒。
  2. 可放大——viewBox 縮放完全無損。如果你想最後 zoom in 看某個節點,SVG 直接 scale transform 就好,不會糊。
  3. 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 完整跑適合載體
< 5050ms 以內SVG 完全沒問題
50-200100~300msSVG 還行,注意 alphaDecay 調快點
200-500300~800msSVG 開始吃力,考慮 canvas
> 5001s+必須 canvas + 可能要 web worker

如果你真的要做超大規模(譬如 npm 整個依賴樹 5000 個 package),策略是:

  1. 用 web worker 跑 simulation(離開主執行緒)
  2. snapshot 改用 Float32Array 而不是 object 陣列(記憶體連續、快 5-10 倍)
  3. 渲染從 SVG 改 canvas 或 WebGL
  4. 考慮用 d3-force-3dngraph 這類更快的引擎

但本篇 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 的另一個招牌:把球面上的世界地圖投影到平面,做出旋轉地球、國家進場、軌跡路線這類「資訊圖風格」的鏡頭。