SVG 沿路徑移動物件:飛行航線 + 自動轉向
用 @remotion/paths 的 getPointAtLength + getTangentAtLength 讓飛機沿著航線飛過去,而且自動順著彎道旋轉機頭方向。
成品預覽
5 秒、一個段落的極簡地圖動畫。從 TAIPEI 到 TOKYO 的航線先被畫出來,然後一架飛機沿著這條曲線飛過去——而且飛機會自動順著彎道轉向,機頭永遠指著它前進的方向。
重點在「自動轉向」:你不用手動在每個 keyframe 指定角度,@remotion/paths 有一個 getTangentAtLength API 會直接告訴你「在 path 的某個距離上,曲線的切線方向是什麼」,把它丟進 atan2 就拿到旋轉角度。
這篇會用到
@remotion/paths—getLength、getPointAtLength、getTangentAtLength三個 APIevolvePath— 先把航線本身畫出來(如果還沒看過,強烈建議先看 前一篇:SVG 手寫簽名)Math.atan2— 把切線向量轉成角度
核心一句話:getPointAtLength 回答「物件在哪」、getTangentAtLength 回答「物件朝哪」。兩個加起來就是「沿路徑運動」的完整解。
前置知識
- SVG 手寫簽名:前一篇講
evolvePath跟 stroke-dasharray 的原理,本篇會用這招先把航線畫出來,不會再從頭解釋 - T3:YouTube 片頭動畫:如果
interpolate/useCurrentFrame還不熟,先看這篇
Step 1:設計航線 path
航線是一條從 Taipei 到 Tokyo 的 cubic bezier 曲線。我用 viewBox 0 0 1920 1080,Taipei 放在畫面左下 (560, 720),Tokyo 放在右上 (1360, 540),中間用兩個控制點拉出一個往上凸的弧度:
const ROUTE_PATH = "M 560 720 C 760 500, 1100 480, 1360 540";C x1 y1, x2 y2, x y 是 cubic bezier:從當前點畫到 (x, y),用 (x1, y1) 跟 (x2, y2) 當兩個控制點。這邊兩個控制點都在 y=480~500 這個區間,所以曲線會往上凸——看起來就像真的飛機航線,不是一條直線。
Step 2:先把航線本身畫出來
這一段跟前一篇 evolvePath 手寫動畫完全一樣,不重複解釋:
import {evolvePath} from '@remotion/paths';
import {interpolate, useCurrentFrame} from 'remotion';
const ROUTE_PATH = "M 560 720 C 760 500, 1100 480, 1360 540";
const FlightRouteSection: React.FC = () => {
const frame = useCurrentFrame();
// 航線在 frame 10-90 之間畫出來
const drawProgress = interpolate(frame, [10, 90], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const {strokeDasharray, strokeDashoffset} = evolvePath(
drawProgress,
ROUTE_PATH,
);
return (
<svg width="1920" height="1080" viewBox="0 0 1920 1080">
<path
d={ROUTE_PATH}
stroke="#facc15"
strokeWidth={6}
strokeLinecap="round"
fill="none"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
style={{filter: "drop-shadow(0 0 12px rgba(250,204,21,0.7))"}}
/>
{/* Taipei / Tokyo 端點圓圈 */}
<circle cx={560} cy={720} r={14} fill="#f87171" />
<circle cx={1360} cy={540} r={14} fill="#f87171" />
</svg>
);
};Step 3:讓飛機沿著 path 移動
航線畫完之後(frame 90),飛機才開始從 Taipei 往 Tokyo 飛。核心 API 是 getPointAtLength:給它一條 path 字串跟一段距離,它會回給你那個距離上的 {x, y} 座標。
import {getLength, getPointAtLength} from '@remotion/paths';
const FlightRouteSection: React.FC = () => {
const frame = useCurrentFrame();
// 航線畫完之後,飛機才開始飛(frame 90 -> 140)
const totalLength = getLength(ROUTE_PATH);
const planeT = interpolate(frame, [90, 140], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const planePoint = getPointAtLength(ROUTE_PATH, planeT * totalLength);
return (
<>
{/* ... 航線 svg ... */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
transform: `translate(${planePoint.x}px, ${planePoint.y}px)`,
}}
>
<div
style={{
position: "absolute",
transform: "translate(-50%, -50%)",
fontSize: 80,
}}
>
✈️
</div>
</div>
</>
);
};兩層 translate 的設計有原因:
- 外層
translate(planePoint.x, planePoint.y)把整個 div 的「左上角」移到 path 上的那個點 - 內層
translate(-50%, -50%)再把 emoji 自己的「中心」對齊到那個點
這個「雙層 translate」一起用,就能讓任何物件(emoji、圖片、圖示)的中心跟著路徑走,而不是它的左上角。這一招在做任何「沿路徑移動」的視覺都很常用。
關鍵陷阱:getPointAtLength 給你的座標是原始 SVG viewBox 座標,不是螢幕像素!如果你的 SVG 用 viewBox="0 0 1920 1080" 但實際 render 寬度只有 960px,那座標就會差兩倍。最簡單的處理方式是讓 SVG 跟外層 AbsoluteFill 同尺寸(範例就是這樣,1920×1080 對 1920×1080),這樣 1 個 viewBox 單位 = 1 個 px,完全不需要換算。
Step 4:讓飛機自動順著彎道轉向(getTangentAtLength)
到這一步為止,飛機會沿著航線走,但機頭永遠朝右邊(因為 ✈️ emoji 預設就是朝右)。在曲線下半段的時候看起來很怪:明明是往右下角飛,機頭卻還指著正右方。
補上這一步的關鍵 API 是 getTangentAtLength:給它同樣的 path 跟距離,它會回給你一個方向向量 {x, y}(不是座標,是代表「往哪個方向」的單位向量)。
import {getTangentAtLength} from '@remotion/paths';
const tangent = getTangentAtLength(ROUTE_PATH, planeT * totalLength);
// atan2 把 (y, x) 向量轉成角度(弧度),再乘 180/π 轉成度數
const planeAngle = Math.atan2(tangent.y, tangent.x) * 180 / Math.PI;為什麼用 atan2(y, x) 而不是 atan(y / x)?因為 atan2 能正確處理四個象限,而且不會在水平方向遇到除以零的問題。atan 會把 (1, 1) 跟 (-1, -1) 都算成同一個角度(45°),atan2 會給你正確的 45° 跟 -135°。做沿路徑運動幾乎一律用 atan2。
把算出來的角度塞進 transform: rotate(...):
<div
style={{
position: "absolute",
left: 0,
top: 0,
transform: `translate(${planePoint.x}px, ${planePoint.y}px) rotate(${planeAngle}deg)`,
}}
>
<div
style={{
position: "absolute",
transform: "translate(-50%, -50%)",
fontSize: 80,
}}
>
✈️
</div>
</div>Emoji 方向校正:✈️ emoji 的「預設朝向」其實不是正右方(0°),而是右上角(大約 -45°)。如果你用原始 tangent 角度,機頭會看起來永遠歪 45°。解法是在算出的角度上加 45°:
const planeAngle =
Math.atan2(tangent.y, tangent.x) * 180 / Math.PI + 45;不同 emoji / 圖示的「預設朝向」都不一樣,第一次做的時候先不要加 offset、render 一幀看看歪幾度、再補回去。
Step 5:加音效 + render
入場的時候來一個 whoosh,飛機飛的那段來一個 sweep:
<Sequence from={0} durationInFrames={30}>
<Audio src={staticFile("audio/cut-air.mp3")} volume={0.6} />
</Sequence>
<Sequence from={90} durationInFrames={30}>
<Audio src={staticFile("audio/sparkline-sweep.mp3")} volume={0.5} />
</Sequence>npx remotion render TutorialSvgFlightRouteDemo out/flight.mp4 --codec=h264原理解析:getTangentAtLength 的用途
getPointAtLength 只給你「在哪裡」,但這對沿路徑移動的物件來說只解決了一半問題。有了位置不夠,還需要知道「朝哪個方向」。
getTangentAtLength 就是補上另一半:回傳的不是座標,而是一個單位向量 {x, y},代表 path 在那個距離上的切線方向。想像你在曲線上的某個點放一隻螞蟻,螞蟻頭應該朝哪個方向才能繼續沿著曲線走——那個方向就是切線。
有了 tangent,你能做的事情包括:
- 飛機/汽車沿彎道自動轉向(本篇)
- 文字沿曲線排列(每個字單獨算 point + tangent)
- 筆尖跟著筆劃旋轉(簽名動畫的進階版)
- 箭頭跟著資料連線指向
- 黏在 SVG path 上的粒子系統(每個粒子用 tangent 決定「前進方向」)
只要你想做「物件貼著曲線」這類效果,getPointAtLength + getTangentAtLength 幾乎一定是答案。
常見問題
Q: @remotion/paths 的 getPointAtLength 比 SVGPathElement.getPointAtLength 快嗎?
是。瀏覽器原生的 SVGPathElement.getPointAtLength 需要先把 path 掛到 DOM 上,會觸發 layout、跨 JS/native 邊界,而且在 Node.js / Worker 環境根本用不了。@remotion/paths 是純 JS 實作,不依賴 DOM,所以在 Lambda render、Web Worker、SSR 都可以用,速度也快很多——對於每幀都要呼叫一次的場景特別重要。
Q: 飛機飛得一頓一頓、不平滑?
getPointAtLength 本身是平滑的(它回傳的座標對 length 是連續函數)。如果你看到卡頓,通常是:
planeT的 interpolate 範圍太短——例如[90, 95](只有 5 frames 跑完整段),每幀跳的距離太大,視覺上就會頓- path 太短——例如
M 0 0 L 100 100只有約 140 單位,配上 50 frames 的動畫,每幀移動 2.8 單位,很快 - 加了
ease但沒看清楚曲線——spring如果 damping 很低會在末段有彈跳
解法通常是拉長 planeT 的 frame 範圍,或者直接換用 spring 做比較平滑的節奏。
Q: 我想要飛機飛完之後留下一個軌跡?
最簡單的做法是疊兩層 <path>:一層是「完整的航線」(stroke-dasharray 全畫),另一層是「飛過的軌跡」(strokeDashoffset 跟 planeT 連動)。讓軌跡用比較亮的顏色,底層航線用比較淡的虛線色,就會看到飛機後面留著一條「已飛過」的實線。
本篇涵蓋的官方文件
下一步
SVG 形狀變形:Path Morphing — 前面兩篇把 path 當作「軌道」來用(手寫動畫畫軌道、飛機沿軌道跑)。下一篇要反過來:把 path 當作「目標」,從一個形狀漸變到另一個形狀。核心 API 其實只有 getPointAtLength 一個——拿來做取樣,然後 per-point 做線性插值。一次教會你手刻 flubber 背後的基礎原理。