Remotion LabRemotion Lab
SVG × 3DSVG 沿路徑移動物件:飛行航線 + 自動轉向
svgpathsanimationtangent

SVG 沿路徑移動物件:飛行航線 + 自動轉向

用 @remotion/paths 的 getPointAtLength + getTangentAtLength 讓飛機沿著航線飛過去,而且自動順著彎道旋轉機頭方向。

成品預覽

5 秒、一個段落的極簡地圖動畫。從 TAIPEITOKYO 的航線先被畫出來,然後一架飛機沿著這條曲線飛過去——而且飛機會自動順著彎道轉向,機頭永遠指著它前進的方向。

重點在「自動轉向」:你不用手動在每個 keyframe 指定角度,@remotion/paths 有一個 getTangentAtLength API 會直接告訴你「在 path 的某個距離上,曲線的切線方向是什麼」,把它丟進 atan2 就拿到旋轉角度。


這篇會用到

  • @remotion/pathsgetLengthgetPointAtLengthgetTangentAtLength 三個 API
  • evolvePath — 先把航線本身畫出來(如果還沒看過,強烈建議先看 前一篇: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/pathsgetPointAtLengthSVGPathElement.getPointAtLength 快嗎?

是。瀏覽器原生的 SVGPathElement.getPointAtLength 需要先把 path 掛到 DOM 上,會觸發 layout、跨 JS/native 邊界,而且在 Node.js / Worker 環境根本用不了。@remotion/paths 是純 JS 實作,不依賴 DOM,所以在 Lambda render、Web Worker、SSR 都可以用,速度也快很多——對於每幀都要呼叫一次的場景特別重要。

Q: 飛機飛得一頓一頓、不平滑?

getPointAtLength 本身是平滑的(它回傳的座標對 length 是連續函數)。如果你看到卡頓,通常是:

  1. planeT 的 interpolate 範圍太短——例如 [90, 95](只有 5 frames 跑完整段),每幀跳的距離太大,視覺上就會頓
  2. path 太短——例如 M 0 0 L 100 100 只有約 140 單位,配上 50 frames 的動畫,每幀移動 2.8 單位,很快
  3. 加了 ease 但沒看清楚曲線——spring 如果 damping 很低會在末段有彈跳

解法通常是拉長 planeT 的 frame 範圍,或者直接換用 spring 做比較平滑的節奏。

Q: 我想要飛機飛完之後留下一個軌跡?

最簡單的做法是疊兩層 <path>:一層是「完整的航線」(stroke-dasharray 全畫),另一層是「飛過的軌跡」(strokeDashoffset 跟 planeT 連動)。讓軌跡用比較亮的顏色,底層航線用比較淡的虛線色,就會看到飛機後面留著一條「已飛過」的實線。


本篇涵蓋的官方文件


下一步

SVG 形狀變形:Path Morphing — 前面兩篇把 path 當作「軌道」來用(手寫動畫畫軌道、飛機沿軌道跑)。下一篇要反過來:把 path 當作「目標」,從一個形狀漸變到另一個形狀。核心 API 其實只有 getPointAtLength 一個——拿來做取樣,然後 per-point 做線性插值。一次教會你手刻 flubber 背後的基礎原理。