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 之間的魔法」,是「兩組對應點之間的線性插值」。 把這個觀念建立起來,剩下都是排列組合。
前置知識
- SVG 沿路徑移動物件:飛行航線:上一篇講
getPointAtLength,本篇 Step 3 會直接拿來用,不會重新解釋 - T3:YouTube 片頭動畫:如果
interpolate/useCurrentFrame/ easing 還不熟,先看這篇
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>
);
};整個流程其實就三步:
- 取兩組對應點:
SQUARE_POINTS[i]對到DIAMOND_POINTS[i],順序很重要 - 逐點插值:每個座標分量都做
a + (b - a) * t - 重建 path 字串:把點列重新組回
M x,y L x,y L x,y Z的格式
smoothstep(t * 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=h264samplePath 的所有 getPointAtLength 呼叫都是同步純 JS,所以渲染速度跟一般 React 動畫沒兩樣。你不用擔心「100 個點 × 150 幀 = 15000 次呼叫」會拖慢——@remotion/paths 對這個量級毫無壓力。
原理解析:為什麼逐點 lerp 不是萬靈丹
到這邊為止你可以 morph 任何兩條 path 了。但你會發現某些情況做出來的效果很怪:
- 形狀的「轉向」相反——一條順時針取樣、另一條逆時針,第 0 個點對到的是「對面」的第 0 個點,整個形狀會在中途翻面
- 起始點對不齊——兩條 path 的「第一個點」剛好在不同位置(一個在頂端、一個在側邊),中間態會看起來像在旋轉而不是變形
- 點分佈密度差很多——一條是規則的多邊形、另一條某段超密集某段超鬆,lerp 完中間態的「皺褶」會集中在錯的地方
這三個問題都是「對應問題」——「第 i 個點該對到第 j 個點?」這是 path morphing 真正困難的部分。flubber 的解法是:
- 把其中一條反轉看看哪個總移動距離比較短(解決方向問題)
- 旋轉陣列起點,找出讓「總移動距離最小」的對齊(解決起始點問題)
- 用更聰明的取樣演算法處理密度問題
當你的兩個形狀差異很大的時候(比如「字母 A」變到「貓」),就會需要這些優化。對於差異不大的形狀(正方形 ↔ 菱形、圓形 ↔ 橢圓),單純 per-point lerp 通常就夠了。
常見問題
Q: Path morph 做出來會跳動 / 翻轉?
兩個常見原因:
- 取樣點數量不一樣——
A.length !== B.length,lerp 出來會錯位(甚至崩潰) - 方向相反——一條是順時針取樣、另一條是逆時針,第 0 個點對到了「對面」的第 0 個點。解法是手動調整其中一條的順序,或者把一條陣列
reverse()試試看
如果還是怪,再加上「起始點對齊」:找出兩條 path 上「最接近的點」當作各自的起點,然後 rotate 陣列順序。這就是 flubber 之類的函式庫在背後做的事。
Q: 為什麼用 smoothstep 而不是 spring?
兩個都可以。smoothstep 是「進、出都平滑」的曲線,看起來很穩,適合用在圖示變形這種「想要乾淨」的場景。spring 在尾端有微微的彈跳感,比較有「機械感」或「玩具感」——如果你的素材本來就是 cartoon 風格,spring 會更搭。
範例選 smoothstep 的原因是「正方形變菱形」這個動作本身已經很簡潔,不需要再加戲。
Q: 我有兩條 path 但它們有多個 subpath(M 多次),怎麼辦?
samplePath 的方法只會沿著「第一條連續曲線」取樣,多 subpath 會被忽略。處理方法有兩種:
- 拆開單獨 morph:把每個 subpath 當成獨立的 path,分別取樣 + lerp + 重組,最後再加上
M重新接起來 - 直接用 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、想直接吃別人做好的資產時,那一篇就是你要的。