電影感片頭:Motion Blur + Noise 做出懷舊膠捲質感
用 @remotion/motion-blur 的 Trail 元件和 @remotion/noise 的 noise2D 函式,加上 SVG feTurbulence,做一個會閃爍的電影放映機片頭,logo 帶殘影滑入,全程有膠捲顆粒感。
成品預覽
這是一個 10 秒的電影放映機片頭,分成 4 個明確階段:
- 暖機閃爍(0~2 秒):放映機還沒對到焦,畫面忽明忽暗
- Logo 殘影滑入(2~5 秒):品牌 logo 從右側帶著 motion blur 殘影掃進來
- 靜止微抖(5~8 秒):logo 定住,但整個畫面有手持膠捲那種微妙晃動
- 最後閃白(8~10 秒):高潮收尾,畫面爆白像底片燒掉
全程膠捲顆粒、掃描線、暗角 vignette 都活著(不是靜態圖層),完全用 React 程式碼產生,沒有任何外部影像素材。
這篇會用到
@remotion/motion-blur的<Trail>元件 — 把 children 多次堆疊出殘影@remotion/noise的noise2D(seed, x, y)— 確定性偽隨機,做閃爍與抖動- SVG
<feTurbulence>— 真實底片顆粒(不是貼圖,是 GPU 即時算的) - CSS
mix-blend-mode— 把顆粒層自然融入畫面 <Sequence>控制四階段時間軸
前置知識
- T3 YouTube 片頭動畫 — 熟悉
<Sequence>與interpolate - T8 Podcast 音波視覺化 — 熟悉如何在 React 裡組視覺層
如果你還沒做過上面兩篇,建議先去看一下,這篇會把那些基礎當作已知。
Step 1:安裝套件
npm install @remotion/motion-blur @remotion/noise兩個都是 Remotion 官方套件,版本號必須跟你專案裡的 remotion 對齊。如果你升級主套件,記得連這兩個一起升,否則會出現 type mismatch。
Step 2:用 noise2D 做放映機閃爍
老式 8mm 放映機的燈泡會不規則抖動,看起來有種「會呼吸」的暖光。要模擬這個效果,第一個直覺是 Math.random(),但這在 Remotion 是大忌——稍後會解釋原因。
正確做法是 @remotion/noise 的 noise2D:
import {noise2D} from '@remotion/noise';
import {useCurrentFrame, interpolate, AbsoluteFill} from 'remotion';
export const FlickerLayer = () => {
const frame = useCurrentFrame();
// noise2D 的回傳值範圍是 [-1, 1]
// 第一個參數是 seed(字串或數字),決定 noise 的「形狀」
// 後兩個參數是座標,這裡用 frame * 0.12 當時間軸
const flickerNoise = noise2D('projector', frame * 0.12, 0);
// 把 [-1, 1] 對應到 [0.78, 1] 當作 opacity
const flickerOpacity = interpolate(flickerNoise, [-1, 1], [0.78, 1]);
return (
<AbsoluteFill
style={{
background:
'radial-gradient(ellipse at center, rgba(255,180,90,0.18) 0%, transparent 65%)',
opacity: flickerOpacity,
}}
/>
);
};為什麼乘 0.12? 這是噪音的「速度」。乘越大,相鄰 frame 的差距越大,閃爍越激烈;乘越小,閃爍越溫吞。0.12 大概是「電影放映機」的舒服值。
Step 3:用 Trail 做殘影 Motion Blur
<Trail> 是 @remotion/motion-blur 最常用的元件。它會把 children 渲染好幾層,每一層用稍微早一點的 frame,疊起來就有「殘影 / motion blur」效果。
import {Trail} from '@remotion/motion-blur';
import {Img, staticFile, useCurrentFrame, interpolate, AbsoluteFill} from 'remotion';
export const SlidingLogo = () => {
const frame = useCurrentFrame();
// 0~40 frame 之間從 900px 滑到 0
const slideProgress = interpolate(frame, [0, 40], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// ease-out cubic
const eased = 1 - Math.pow(1 - slideProgress, 3);
const translateX = interpolate(eased, [0, 1], [900, 0]);
return (
<AbsoluteFill style={{justifyContent: 'center', alignItems: 'center'}}>
<Trail layers={12} lagInFrames={1} trailOpacity={0.6}>
<div
style={{
transform: `translateX(${translateX}px)`,
filter:
'drop-shadow(0 0 40px rgba(255,180,80,0.35)) drop-shadow(0 8px 30px rgba(0,0,0,0.6))',
}}
>
<Img
src={staticFile('images/debug-tuboshu-logo.png')}
style={{width: 560, height: 560, objectFit: 'contain'}}
/>
</div>
</Trail>
</AbsoluteFill>
);
};三個重要 props:
| Prop | 作用 | 建議值 |
|---|---|---|
layers | 殘影要堆幾層 | 6~12,越多越滑順但 render 越慢 |
lagInFrames | 每層往前偏移幾 frame | 1~2,配合動畫速度 |
trailOpacity | 殘影尾巴的衰減強度 | 0.4~0.7,越高尾巴越長 |
只有當 children 的位置 / 大小會隨 frame 改變時,Trail 才看得出效果。靜止的東西套 Trail 不會有任何視覺差別(但 render 還是會慢 12 倍——別這樣做)。
Step 4:Camera Shake with noise2D
Logo 滑進來之後是「靜止微抖」階段。手持膠捲機的 jitter 不是規律振動,是有點隨機但又有連續性——剛好就是 noise 的特性。
const frame = useCurrentFrame();
// 只在 frame 150~240 之間啟動 shake
const shakeActive = frame >= 150 && frame < 240;
// 注意 X 跟 Y 用不同 seed(42 vs 42 但第三參數不同),
// 否則兩軸會同步、變成斜線往返而不是抖動
const shakeX = shakeActive ? noise2D(42, frame * 0.15, 0) * 5 : 0;
const shakeY = shakeActive ? noise2D(42, frame * 0.15, 100) * 5 : 0;
return (
<AbsoluteFill
style={{
transform: `translate(${shakeX}px, ${shakeY}px)`,
}}
>
{/* 整個場景內容 */}
</AbsoluteFill>
);幾個小細節:
* 5是震幅。5 像素的 1080p 畫面差不多就是「微抖」,再大就變地震。- 用
frame * 0.15控制抖動頻率,比閃爍稍快。 - 第三個參數(noise 的另一軸座標)用來區分 X 與 Y——同一個 seed 用不同位置取樣,視覺上才會「不同步」。
Step 5:膠捲顆粒(SVG feTurbulence)
膠捲顆粒(film grain)是這支片頭的靈魂。最常見的做法是疊一張 grain.png,但那是靜態的——播放時你會看到顆粒「凍結」在畫面上,超假。
正解是用 SVG <feTurbulence> 即時算出每一幀的雜訊:
const GrainOverlay: React.FC<{baseFrequency: number; opacity: number}> = ({
baseFrequency,
opacity,
}) => {
return (
<AbsoluteFill
style={{
opacity,
mixBlendMode: 'screen',
pointerEvents: 'none',
}}
>
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<filter id="grain-filter">
<feTurbulence
type="fractalNoise"
baseFrequency={baseFrequency}
numOctaves={2}
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter="url(#grain-filter)" />
</svg>
</AbsoluteFill>
);
};怎麼讓顆粒每幀變化? 把 baseFrequency 隨 frame 變化就能讓圖形跑起來。也可以用 seed={frame} prop 直接重新洗牌,每幀都產生完全不同的圖樣:
<feTurbulence baseFrequency="0.9" numOctaves="2" seed={frame} />參數速記:
baseFrequency:0.5~2.0 之間。值越大顆粒越細。numOctaves:層數。2~4 會疊出比較有層次的顆粒。feColorMatrix saturate=0:把顆粒去飽和變成黑白雜訊(彩色雜訊看起來像 RGB 壞點)。
Step 6:掃描線 + 暗角
兩個經典 CSS 效果,組合起來瞬間電影感 +50:
const Scanlines: React.FC<{opacity: number}> = ({opacity}) => (
<AbsoluteFill
style={{
opacity,
background:
'repeating-linear-gradient(0deg, rgba(0,0,0,0.5) 0px, rgba(0,0,0,0.5) 1px, transparent 2px, transparent 4px)',
pointerEvents: 'none',
mixBlendMode: 'multiply',
}}
/>
);
const Vignette: React.FC = () => (
<AbsoluteFill
style={{
background:
'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.85) 100%)',
pointerEvents: 'none',
}}
/>
);掃描線用 repeating-linear-gradient 一行黑一行透明做出來,再用 mix-blend-mode: multiply 壓暗。Vignette 就是個 radial-gradient,中間透明、四角壓黑。
兩個都要記得 pointerEvents: 'none',不然會擋住下層的互動(雖然 render 不影響,但 Studio preview 點不到)。
Step 7:渲染
把整個 Composition 註冊到 Root.tsx,然後:
npx remotion render TutorialMotionBlurNoiseDemo out/t16.mp4 --codec=h264如果想加速渲染,可以暫時把 <Trail> 的 layers 從 12 降到 6 預覽,正式輸出再開回去。
原理解析:為什麼 noise 比 random 好
這是這篇最重要的觀念,務必看完。
Math.random() 每次呼叫都會回不同結果。在傳統 web 應用沒問題,但 Remotion 的 render pipeline 是這樣的:
- Studio preview:你拉時間軸到 frame 100,元件 re-render,
Math.random()給你 0.34 - Render time:worker 渲到 frame 100,元件 re-render,
Math.random()給你 0.81 - 結果:preview 跟最終影片長得不一樣,甚至同一次 render 兩個 worker 渲到同一幀都不同
更慘的情況是 Remotion 的並行渲染:每個 frame 是獨立 worker,狀態完全不共享。隨機數就會在 frame 100 跟 frame 101 之間出現尖銳跳動,看起來像閃爍 bug。
noise2D 是純函式:
noise2D('projector', 5.0, 0) // 永遠回 0.234...
noise2D('projector', 5.1, 0) // 永遠回 0.241... (跟前一幀只差一點)好處:
- debug 時停在同一幀,畫面永遠一樣——你才能改 CSS 看效果
- 渲染中斷 resume 後接續畫面連貫——不會接縫處跳一格
- 連續性:相鄰座標的 noise 值很接近,視覺上是平滑變化而不是亂跳
順帶說明 Trail 的內部原理:Remotion 在 render time 把 useCurrentFrame() 的值偏移 -lagInFrames * i,把同一個 children 多次用不同時間點 render、降低 opacity 疊上去。所以本質上是「同一個元件同時呈現過去 N 個 frame 的樣子」——這也是為什麼 children 內部一定要用 useCurrentFrame() 驅動動畫,否則 Trail 沒東西可偏移。
原理解析:mixBlendMode 的選擇
顆粒層的 mix-blend-mode 選錯,整個調性就毀了。三個選擇:
| 模式 | 效果 | 適合 |
|---|---|---|
overlay | 顆粒保留亮暗對比,最電影感 | 一般 film grain |
screen | 顆粒只加亮,整體偏明亮 | 想做「曝光過度」感 |
multiply | 顆粒只加暗,整體變暗 | 想做「沖洗失敗」感 |
範例 scene 用的是 screen,因為主畫面整體偏暗(背景 #1a1410),用 screen 才能讓顆粒看得到。如果你的背景是亮的,改用 overlay 通常最自然。
實務建議:先用 overlay 試。看不到顆粒就調高 opacity;顆粒太搶戲就調低 baseFrequency(變粗)並降 opacity。
常見問題
Q:Trail 讓 render 變超慢?
A:layers={12} 的意思是同一個 children 會被 render 12 次——render time 直接乘 12 倍。正式輸出 6~8 layers 通常就夠了,視覺差異很小但速度差很多。如果 logo 動很快可以用 12,慢慢滑動的話 6 就 OK。
Q:noise2D 輸出看起來不夠「髒」?
A:兩個方向:(1) 把振幅放大(* 10 而不是 * 5),(2) 疊多個頻率做出 fractal noise:
const x =
noise2D('a', frame * 0.1, 0) * 0.6 +
noise2D('a', frame * 0.5, 0) * 0.3 +
noise2D('a', frame * 2.0, 0) * 0.1;低頻給「整體晃」、中頻給「抖動」、高頻給「微震」,疊起來才像真實物理運動。
Q:feTurbulence 在某些瀏覽器效能很差? A:是的,Safari 的 SVG filter 特別慢。但你不用擔心——Remotion render 用的是 Chromium headless,跨瀏覽器相容只在你做 web 預覽時才有意義。Studio preview 也是 Chromium。
Q:放映機閃爍要怎麼做更真實?
A:可以加一個非常低機率的「掉幀黑」:當 noise2D('drop', frame * 0.05, 0) > 0.92 時整個畫面變黑 1~2 frame,模擬底片接縫處的瑕疵。
Q:可以把 Trail 跟 feTurbulence 放在同一個 Sequence 嗎? A:可以。不過記得 Trail 要包在最內層的「會動」元素外面,feTurbulence 要在最外層當 overlay,順序不能反。
本篇涵蓋的官方文件
- /docs/motion-blur/trail —
<Trail>完整 API - /docs/motion-blur —
@remotion/motion-blur套件總覽 - /docs/noise/noise-2d —
noise2D函式簽名與範例 - /docs/noise —
@remotion/noise套件總覽
下一步
- T17:SVG Path 動畫 — 用
pathLength跟stroke-dashoffset做出畫線、寫字、勾描效果 - T18:Lottie + Rive — 把 After Effects / Rive 做的動畫嵌進 Remotion,跟 React 元件混用
學到這篇你已經有能力做出「不依賴外部素材就有電影感」的片頭——這是 Remotion 相對其他工具最強的地方:所有效果都是程式,所有程式都是參數,所有參數都能被資料驅動。
有問題歡迎到 FB 社群 討論!