Remotion LabRemotion Lab
SVG × 3DSVG 形狀變形:Path Morphing
svgpathsmorphlerp

SVG 形狀變形:Path Morphing

用 per-point lerp 把一個形狀漸變成另一個。從固定點數的多邊形開始,再用 getPointAtLength 取樣處理任意 path。一次教會你 flubber 背後的基礎原理。

成品預覽

5 秒、一個段落的形狀變形。一個藍色的正方形「融化」成菱形——但仔細看會發現它不是用 CSS transform 旋轉 45°,而是真的每個頂點都在沿著直線往新位置滑過去。中間的某一幀你會看到一個八邊形似的中間態,那是四個頂點各自走到自己路徑的一半時的瞬間。

這就是 path morphing 最簡單的版本:逐點線性插值(per-point lerp)。整個概念其實極簡——只要兩個形狀的「點數量一樣」,你就可以對每個點做 a + (b - a) * t。剩下複雜的部分(兩條任意 path 怎麼變成「點數一樣」)都是這個基礎之上的工程問題。


這篇會用到

  • @remotion/paths — 主要用 getLength + getPointAtLength 做「沿任意 path 取等距點」的取樣
  • Math.min/max 之類的純數學 — morph 本身不需要任何特別 API,只是 lerp 而已

核心一句話:path morph 不是「兩條 path 之間的魔法」,是「兩組對應點之間的線性插值」。 把這個觀念建立起來,剩下都是排列組合。


前置知識


Step 1:先理解 morph 為什麼會「動」

最樸素的問題:「正方形怎麼變菱形?」一般人的直覺是「旋轉 45°」,但這個解法只在「目標形狀剛好是來源旋轉版本」時才成立。如果你想從正方形變到星星、變到貓的輪廓,旋轉就完全不夠用了。

正確的心智模型是:形狀 = 一組點 + 連接它們的方式。如果兩個形狀的「點的數量」跟「點的順序」一樣,你就可以「第 0 個點對到第 0 個點、第 1 個對第 1 個…」逐點做插值。每一幀算一組新的點、組成新的 path 字串、丟給 <path>d 屬性——畫面上看起來就會「動」。


Step 2:固定點數的多邊形 morph

最簡單的情況:兩個都是 4 個點的多邊形。寫一個 lerp 加一個「把點列轉成 path 字串」的小工具,剩下的就是 interpolate

import {AbsoluteFill, interpolate, useCurrentFrame} from 'remotion';
 
const SQUARE_POINTS: Array<[number, number]> = [
  [760, 340],
  [1160, 340],
  [1160, 740],
  [760, 740],
];
const DIAMOND_POINTS: Array<[number, number]> = [
  [960, 280],
  [1220, 540],
  [960, 800],
  [700, 540],
];
 
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
 
const buildPolygonPath = (points: Array<[number, number]>) => {
  const [first, ...rest] = points;
  const head = `M ${first[0]} ${first[1]}`;
  const tail = rest.map(([x, y]) => `L ${x} ${y}`).join(" ");
  return `${head} ${tail} Z`;
};
 
const PathMorphSection: React.FC = () => {
  const frame = useCurrentFrame();
 
  const t = interpolate(frame, [20, 100], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  // smoothstep easing
  const eased = t * t * (3 - 2 * t);
 
  const lerpedPoints = SQUARE_POINTS.map<[number, number]>((p, i) => [
    lerp(p[0], DIAMOND_POINTS[i][0], eased),
    lerp(p[1], DIAMOND_POINTS[i][1], eased),
  ]);
  const morphedPath = buildPolygonPath(lerpedPoints);
 
  return (
    <AbsoluteFill>
      <svg width="1920" height="1080" viewBox="0 0 1920 1080">
        <path
          d={morphedPath}
          fill="rgba(56,189,248,0.18)"
          stroke="#38bdf8"
          strokeWidth={6}
          strokeLinejoin="round"
        />
      </svg>
    </AbsoluteFill>
  );
};

整個流程其實就三步:

  1. 取兩組對應點SQUARE_POINTS[i] 對到 DIAMOND_POINTS[i],順序很重要
  2. 逐點插值:每個座標分量都做 a + (b - a) * t
  3. 重建 path 字串:把點列重新組回 M x,y L x,y L x,y Z 的格式

smoothstept * t * (3 - 2 * t))是一個常用的緩動函式,比純線性插值好看很多——它在開始和結束的時候會慢一點,中間快一點,所以變形看起來更自然。如果你直接用 t,動畫會有種「機器人感」,正方形以等速「滑」到菱形——不難看,但少了「彈性」。

為什麼順序重要? 試試看把 DIAMOND_POINTS 的順序改成 [左, 上, 右, 下](原本是 [上, 右, 下, 左])會怎樣——你會看到形狀在中途「打結」變成一個自我交叉的怪物,因為第 0 個點跑到對面去了。每個點都是沿直線走到對應點,所以順序錯了路徑就會穿過自己。


Step 3:用 getPointAtLength 處理任意 path

上一步只能 morph「點數一樣」的多邊形。但如果你想要 morph 兩條任意的 SVG path(比如一隻貓的輪廓和一隻狗的輪廓),它們的命令數和點數幾乎不可能一樣。

解法是:getPointAtLength 沿著兩條 path 各取 N 個等距點,這樣不管原本是什麼形狀,最後都會變成「N 個點的多邊形」,可以直接套上一步的 lerp 流程。

import {getLength, getPointAtLength} from '@remotion/paths';
 
function samplePath(path: string, n: number) {
  const total = getLength(path);
  return Array.from({length: n}, (_, i) =>
    getPointAtLength(path, (i / (n - 1)) * total),
  );
}
 
const A = samplePath(catSvgPath, 100);
const B = samplePath(dogSvgPath, 100);
 
// 之後就跟 Step 2 一樣
const lerped = A.map((p, i) => ({
  x: lerp(p.x, B[i].x, eased),
  y: lerp(p.y, B[i].y, eased),
}));

100 個點對於大部分形狀已經夠細了。如果是非常複雜的輪廓(比如有很多細節的 logo),可以拉到 200~300。注意取樣點越多,每幀都要重算 getPointAtLength 越多次——但因為 @remotion/paths 是純 JS、不碰 DOM,所以即使在 Node render 環境也不會卡,每幀數百次呼叫完全在預算內。

samplePath 的取樣公式 (i / (n - 1)) * total 是「等距取樣」:第一個點在 length=0、最後一個點在 length=total,中間平均分佈。如果你想要「曲率高的地方取多一點、直線段取少一點」這種「自適應」取樣,就要自己寫一個依曲率分配密度的版本,但對 90% 的場景來說等距已經夠了。

這種「先取樣再 lerp」的做法,是 path morphing 函式庫(像 flubber)背後最基本的原理之一。flubber 在這之上做了很多優化(自動找對齊起點、處理多個 subpath、最小化點移動距離),但底層邏輯就是這套。你現在自己手刻一遍就懂為什麼它存在了。


Step 4:渲染

npx remotion render TutorialSvgPathMorphDemo out/morph.mp4 --codec=h264

samplePath 的所有 getPointAtLength 呼叫都是同步純 JS,所以渲染速度跟一般 React 動畫沒兩樣。你不用擔心「100 個點 × 150 幀 = 15000 次呼叫」會拖慢——@remotion/paths 對這個量級毫無壓力。


原理解析:為什麼逐點 lerp 不是萬靈丹

到這邊為止你可以 morph 任何兩條 path 了。但你會發現某些情況做出來的效果很怪:

  1. 形狀的「轉向」相反——一條順時針取樣、另一條逆時針,第 0 個點對到的是「對面」的第 0 個點,整個形狀會在中途翻面
  2. 起始點對不齊——兩條 path 的「第一個點」剛好在不同位置(一個在頂端、一個在側邊),中間態會看起來像在旋轉而不是變形
  3. 點分佈密度差很多——一條是規則的多邊形、另一條某段超密集某段超鬆,lerp 完中間態的「皺褶」會集中在錯的地方

這三個問題都是「對應問題」——「第 i 個點該對到第 j 個點?」這是 path morphing 真正困難的部分。flubber 的解法是:

  • 把其中一條反轉看看哪個總移動距離比較短(解決方向問題)
  • 旋轉陣列起點,找出讓「總移動距離最小」的對齊(解決起始點問題)
  • 用更聰明的取樣演算法處理密度問題

當你的兩個形狀差異很大的時候(比如「字母 A」變到「貓」),就會需要這些優化。對於差異不大的形狀(正方形 ↔ 菱形、圓形 ↔ 橢圓),單純 per-point lerp 通常就夠了。


常見問題

Q: Path morph 做出來會跳動 / 翻轉?

兩個常見原因:

  1. 取樣點數量不一樣——A.length !== B.length,lerp 出來會錯位(甚至崩潰)
  2. 方向相反——一條是順時針取樣、另一條是逆時針,第 0 個點對到了「對面」的第 0 個點。解法是手動調整其中一條的順序,或者把一條陣列 reverse() 試試看

如果還是怪,再加上「起始點對齊」:找出兩條 path 上「最接近的點」當作各自的起點,然後 rotate 陣列順序。這就是 flubber 之類的函式庫在背後做的事。

Q: 為什麼用 smoothstep 而不是 spring?

兩個都可以。smoothstep 是「進、出都平滑」的曲線,看起來很穩,適合用在圖示變形這種「想要乾淨」的場景。spring 在尾端有微微的彈跳感,比較有「機械感」或「玩具感」——如果你的素材本來就是 cartoon 風格,spring 會更搭。

範例選 smoothstep 的原因是「正方形變菱形」這個動作本身已經很簡潔,不需要再加戲。

Q: 我有兩條 path 但它們有多個 subpath(M 多次),怎麼辦?

samplePath 的方法只會沿著「第一條連續曲線」取樣,多 subpath 會被忽略。處理方法有兩種:

  1. 拆開單獨 morph:把每個 subpath 當成獨立的 path,分別取樣 + lerp + 重組,最後再加上 M 重新接起來
  2. 直接用 flubber:它原生支援多 subpath,會自動分配「哪個 subpath 對到哪個 subpath」

如果你只是想要 morph 一個 logo,多花 30 行寫拆分邏輯就好。如果你要做成通用工具,引入 flubber 可能比較划算。

Q: 為什麼這個 demo 中間有一幀看起來像八邊形?

因為 4 個頂點各自走到「自己路徑的 50%」的時候,剛好構成一個既不是正方形也不是菱形的中間態。它有 4 個角,但每個角的位置都是「正方形對應角 + 菱形對應角」的中點——拼起來就形成一個八角形似的東西。這正是逐點 lerp 的特性,也是它跟「旋轉動畫」最大的差別。


本篇涵蓋的官方文件


下一步

Lottie 整合:把 After Effects 動畫直接搬進 Remotion — 連續三篇手刻 SVG path 動畫之後,下一篇要進到「外部動畫格式」的世界:怎麼把設計師在 After Effects 做好的 Lottie JSON,無痛塞進 Remotion 的時間軸裡。當你不想自己寫 path、想直接吃別人做好的資產時,那一篇就是你要的。