Rive State Machine:用 Remotion frame 驅動互動動畫
Rive 的殺手鐧是 state machine:設計師定義狀態轉換,你用 useCurrentFrame 算出數值丟進 input,畫面就會即時反應。本篇直接用 @rive-app/canvas-advanced 串到 Remotion 時間軸。
成品預覽
6 秒、單一場景的 Rive 示範。畫面中央是一張粉紅色的評分卡,上面有 5 顆星,隨著影片進度 rating 從 0 一路漲到 5 再退回 2——每次數字變動的瞬間,整組星星會用設計師在 Rive editor 裡調好的 transition 動畫切換(縮放、顏色漸變)。底部的小膠囊即時顯示當前 rating = N 的數值,確認這個值確實是從 Remotion frame 推出來的。
這就是 Rive 跟 Lottie 最大的差別:Lottie 是「按下播放就讓它跑」,Rive 是「你每幀都可以告訴它當下的狀態」。前者是錄影帶,後者是遊戲角色。
為什麼 @remotion/rive 不夠用?
Remotion 官方有一個 @remotion/rive 套件,用起來很清爽:給它一個 src 檔案路徑就能渲染。但它只支援「線性動畫模式」——也就是把 .riv 檔當成「一段從頭播到尾的動畫」處理。
它沒有暴露 state machine input 的 API。這意味著如果你想要用 Remotion frame 去驅動 Rive 的狀態切換,@remotion/rive 幫不了你——你會發現它把你的 state machine 當成普通動畫播,rating 永遠停在預設值。
解法是繞過 @remotion/rive,直接用 Rive 官方的低階套件 @rive-app/canvas-advanced。這個套件暴露了完整的 state machine API:你可以找到某個 input、設值、然後手動 advanceAndApply(dt) 推進時間。整個流程跟 Rive runtime 在網頁上跑的方式一樣,只是把「真實時間」換成 Remotion 的 frame / fps。
這篇會用到
@rive-app/canvas-advanced— Rive 官方低階 runtime,WASM-baseddelayRender/continueRender— 載入 WASM +.riv檔都是非同步的,要告訴 Remotion 等一下useEffect+useRef— 一個用來初始化 runtime(只跑一次),一個用來每幀推進canvas— Rive 直接在 HTML canvas 上繪圖,不是 SVG / React 元件
核心心法一句話:Rive runtime 在瀏覽器是「用 requestAnimationFrame 推進時間」的,你要把它改成「用 Remotion frame 推進時間」。整個技術難點就這一句。
前置知識
- Lottie 整合:如果
delayRender/continueRender的觀念還不熟,先看那一篇。本篇會同時使用兩個 handle(一個等 WASM、一個等.riv檔),機制一模一樣。 - 對 canvas API 有基本認識即可——你不需要會手刻 2D 繪圖,Rive 都幫你畫好。
Step 1:安裝套件
npm install @rive-app/canvas-advanced不裝 @remotion/rive——那個是走線性動畫路線的。我們要用的是 Rive 官方的低階 runtime。
Step 2:準備 Rive 檔案
Rive 的素材取得方式:
- rive.app 的 Community 區有大量 CC0 授權的
.riv檔可以下載 - 設計師用 Rive editor 做完之後直接匯出
.riv
重點:下載的時候要確認這個 .riv 檔有 state machine,而且知道 state machine 的名字、input 的名字、input 的型別(number 或 boolean)。這些資訊在 Rive editor 裡都看得到——如果你是自己做的就直接知道;如果是下載別人的作品,打開 Rive editor 看一下 State Machine 面板。
把檔案放到 public/ 資料夾:
public/
└── animations/
└── rating.riv
Step 3:最小可行的 Rive 整合
Rive runtime 需要兩樣東西才能跑:
- WASM 檔——Rive 的繪圖核心是用 C++ 寫的,編譯成 WASM 在瀏覽器裡跑
.riv檔——你的動畫資料
兩個都是非同步載入。先看整個流程的骨架:
import RiveCanvas, {
type Artboard,
type CanvasRenderer,
type RiveCanvas as RiveCanvasInstance,
type StateMachineInstance,
type SMIInput,
} from '@rive-app/canvas-advanced';
import {
delayRender,
continueRender,
staticFile,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import {useEffect, useRef, useState} from 'react';
// Rive WASM 從 unpkg 拿(也可以自己 host)
const RIVE_WASM_URL =
'https://unpkg.com/@rive-app/canvas-advanced@2.31.5/rive.wasm';
type RiveStateBundle = {
rive: RiveCanvasInstance;
artboard: Artboard;
smi: StateMachineInstance;
input: SMIInput;
renderer: CanvasRenderer;
};這些 import 看起來有點嚇人,但它們只是描述 Rive 底層物件的型別。你要做的是:初始化一次、拿到這些 handle、存進 ref,然後每幀重複使用。
Step 4:初始化 Rive runtime
這一段是重頭戲。在 useEffect 裡做所有的非同步載入,全部成功之後 continueRender 放行:
const RiveStateMachineCanvas: React.FC<{
src: string;
stateMachine: string;
inputName: string;
inputValueAtFrame: (frame: number) => number;
width: number;
height: number;
}> = ({src, stateMachine, inputName, inputValueAtFrame, width, height}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const canvasRef = useRef<HTMLCanvasElement>(null);
const bundleRef = useRef<RiveStateBundle | null>(null);
const lastFrameRef = useRef(0);
const [handle] = useState(() => delayRender('Loading Rive state machine'));
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
// 1. 載入 Rive WASM runtime
const rive = await RiveCanvas({locateFile: () => RIVE_WASM_URL});
// 2. 載入 .riv 檔並解析
const buf = await fetch(src).then((r) => r.arrayBuffer());
const file = await rive.load(new Uint8Array(buf));
// 3. 拿到預設 artboard
const artboard = file.defaultArtboard();
// 4. 拿到指定的 state machine
const sm = artboard.stateMachineByName(stateMachine);
const smi = new rive.StateMachineInstance(sm, artboard);
// 5. 在 state machine 的 inputs 列表裡找到我們要的那一個
let input: SMIInput | null = null;
for (let i = 0; i < smi.inputCount(); i++) {
const inp = smi.input(i);
if (inp.name === inputName) {
input = inp.asNumber();
break;
}
}
if (!input) {
throw new Error(`Rive input "${inputName}" not found`);
}
if (cancelled || !canvasRef.current) return;
// 6. 建立 renderer 綁到 canvas
const renderer = rive.makeRenderer(canvasRef.current);
// 7. 全部存進 ref,設 ready,放行 render
bundleRef.current = {rive, artboard, smi, input, renderer};
setReady(true);
continueRender(handle);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ... 下一步:每幀推進
};7 個步驟看起來很多,但每一步都很直接:載 WASM → 載 .riv → 找 artboard → 找 state machine → 找 input → 建 renderer → 存起來。只會跑一次,跑完之後 bundleRef.current 就裝滿了後續要用的 handle。
cancelled 的用途:如果元件在載入途中就被 unmount(例如 Studio 熱重載),useEffect 的 cleanup 會把 cancelled 設成 true,避免對已經不存在的 canvas 呼叫 makeRenderer。這是一個好習慣——Rive runtime 沒有 cancel token 的概念,只能靠我們自己攔。
為什麼 input.asNumber()?state machine input 可以是 number、boolean 或 trigger,Rive API 要你明確「轉型」到其中一種。這邊的 rating 是 number,所以用 asNumber()。
Step 5:每幀推進 state machine
初始化完成後,用第二個 useEffect 在 frame 改變時重繪:
useEffect(() => {
const bundle = bundleRef.current;
if (!ready || !bundle || !canvasRef.current) return;
const {rive, artboard, smi, input, renderer} = bundle;
rive.requestAnimationFrame(() => {
// 算上一幀到這一幀的時間差(秒)
const diff = Math.max(0, frame - lastFrameRef.current);
lastFrameRef.current = frame;
const dt = diff / fps;
// 更新 state machine input —— 這是整套東西的關鍵
input.value = inputValueAtFrame(frame);
// 推進 state machine 跟 artboard
smi.advanceAndApply(dt);
artboard.advance(dt);
// 重繪
renderer.clear();
renderer.save();
renderer.align(
rive.Fit.contain,
rive.Alignment.center,
{minX: 0, minY: 0, maxX: width, maxY: height},
artboard.bounds,
);
artboard.draw(renderer);
renderer.restore();
});
}, [frame, fps, ready, inputValueAtFrame, width, height]);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{width, height, display: 'block'}}
/>
);這段的關鍵是 dt = diff / fps——把「frame 差」換算成「秒差」餵給 Rive。如果 Remotion 現在是第 60 幀、上一次是第 59 幀、fps 是 30,dt = 1/30 = 0.033 秒。Rive 的 state machine 會根據這個 dt 跑一點點 transition 動畫(例如從 rating=2 轉到 rating=3 的過程)。
為什麼要自己維護 lastFrameRef? 因為 Remotion 的 frame 不一定是連續的(在 render 階段是,但在 Studio preview 可能會跳)。每次都算「跟上一幀的差」才對得起來。
為什麼要用 rive.requestAnimationFrame? Rive runtime 內部有個 micro-scheduler,畫面的最終繪製要透過它排程,才能保證 draw call 順序正確。用瀏覽器原生的 requestAnimationFrame 會跟 Rive 的內部狀態不同步。
Step 6:驅動 input 的值
上一步的 inputValueAtFrame 是一個純函式:給它當前 frame,它回傳 rating 應該是多少。範例影片用的是「離散階梯」:
const ratingAtFrame = React.useCallback((f: number) => {
if (f < 20) return 0;
if (f < 45) return 1;
if (f < 70) return 2;
if (f < 95) return 3;
if (f < 120) return 4;
if (f < 150) return 5;
if (f < 160) return 4;
if (f < 170) return 3;
return 2;
}, []);為什麼是階梯而不是 interpolate(frame, [0, 150], [0, 5]) 線性插值?因為 Rive 的 state machine 有離散狀態——rating 必須是 0、1、2、3、4、5 其中一個整數,中間值(例如 3.7)會讓 state machine 不知道該停在哪。階梯讓每個整數值停留 25 幀,剛好夠 state machine 的 transition 動畫跑完。
如果你的 state machine 是連續 input(像是 Speed: 0~100),那就可以用 interpolate 或 spring,直接算出一個連續數值丟進去,Rive 會自動做內插。
拿這個函式去呼叫 <RiveStateMachineCanvas>:
<RiveStateMachineCanvas
src={staticFile('animations/rating.riv')}
stateMachine="State Machine 1"
inputName="rating"
inputValueAtFrame={ratingAtFrame}
width={520}
height={520}
/>Step 7:渲染
npx remotion render TutorialRiveDemo out/rive.mp4 --codec=h264第一次 render 時會發現它需要下載 Rive WASM(幾百 KB),在 log 會看到「Loading Rive state machine」停留一下。下載完就正常了。如果你在斷網或 CI 環境,建議把 WASM 檔自己 host 到 public/ 下並修改 RIVE_WASM_URL。
原理解析:Lottie vs Rive state machine
兩者都是「設計師做、工程師嵌入」,但互動能力差很多:
| 面向 | Lottie | Rive state machine |
|---|---|---|
| 來源工具 | After Effects + Bodymovin | Rive editor 原生 |
| 檔案格式 | JSON(純文字) | .riv(二進位,更小) |
| 時間軸模型 | 線性:從 t=0 播到 t=end | 狀態機:根據 input 切換狀態 |
| 是否可外部驅動 | 否,按下播放就完事 | 是,每幀都能 set input |
| Remotion 寫法 | fetch JSON + <Lottie> | 低階 runtime + 手動 advance |
| 適合做什麼 | loading、裝飾、純展示動畫 | 評分、角色表情、進度綁動畫、互動式 UI |
選擇建議:
- 純展示動畫(loading、icon、插畫)→ Lottie
- 需要根據影片進度動態反應(評分從 0 漲到 5、角色根據速度變表情)→ Rive
- 兩個都用也沒問題——它們可以共存在同一支影片裡
原理解析:為什麼要直接用 advance 而不是 requestAnimationFrame
一般在瀏覽器跑 Rive,runtime 內部會自己呼叫 requestAnimationFrame——瀏覽器每次 repaint 前就推進一點。這在「真實時間」的環境下完美運作。
但 Remotion 不是真實時間。它是「我給你 frame 號碼,你告訴我這一幀畫面長什麼樣」。每一幀都是獨立計算的,沒有「上一幀是什麼時候畫的」這個概念。所以你不能讓 Rive 自己排 requestAnimationFrame——它會按照真實時間跑,跟 Remotion 的 frame 完全對不起來。
解法就是 把 Rive 的時間完全接管:每次 Remotion 呼叫我們的元件(render 第 N 幀),我們就自己算 dt = 1/fps,手動呼叫 smi.advanceAndApply(dt)。這樣不管 Remotion render 速度是多快還是多慢、不管是在 Studio 即時預覽還是 Node.js 環境下 render,Rive 的時間永遠跟 Remotion 的 frame 完美同步。
常見問題
Q: input 找不到,throw Rive input "xxx" not found?
打開 Rive editor 確認 state machine 的 input 名字。大小寫敏感、前後空白敏感。最常見的錯誤是「在 editor 裡叫 Rating、你在程式碼裡打 rating」。
Q: rating 變動時動畫沒有 transition,直接瞬間切換?
兩個可能:
- Rive editor 裡沒有為 state 之間設定 transition duration(預設是 0 秒)——回 editor 調
dt太大導致 transition 被一次跑完——檢查lastFrameRef邏輯有沒有正確維護
Q: 可以同時驅動多個 input 嗎?
可以。把 step 4 裡的 input 改成 inputs: Record<string, SMIInput>,for loop 每找到一個就存進 map。然後 step 5 的每幀更新裡同時 set 多個值。
Q: Rive 在 Studio 正常但 render 時空白?
通常是 delayRender 的 timeout 在 WASM 下載階段就觸發了。把 Composition 的 timeoutInMilliseconds 調高(例如 60000),或者把 WASM 檔 host 到 public/ 讓它走本地。
Q: WASM 載入了、.riv 也載入了,但畫面還是空的?
檢查 canvasRef.current 在 useEffect 執行的當下是不是 null。React 的 ref 在 mount 之前會是 null,而如果你用 conditional render(ready ? <canvas> : null),canvas 在初次 render 根本不存在。解法是永遠 render canvas(ready 只控制 draw call),或者把 useState 改成 mount-check。
本篇涵蓋的官方文件
下一步
T19:GSAP 動畫整合 — 連續兩篇都在講「設計師做、工程師嵌入」的動畫格式。下一篇要換到「工程師做、給工程師用」的老牌動畫函式庫 GSAP——怎麼把它的 timeline 概念接到 Remotion 的 frame 系統上,讓你可以把網頁上跑過的 GSAP 動畫直接搬進影片產線。