Remotion LabRemotion Lab
視覺特效電影感片頭:Motion Blur + Noise 做出懷舊膠捲質感
motion-blurnoisefilmadvanced

電影感片頭:Motion Blur + Noise 做出懷舊膠捲質感

用 @remotion/motion-blur 的 Trail 元件和 @remotion/noise 的 noise2D 函式,加上 SVG feTurbulence,做一個會閃爍的電影放映機片頭,logo 帶殘影滑入,全程有膠捲顆粒感。

成品預覽

這是一個 10 秒的電影放映機片頭,分成 4 個明確階段:

  1. 暖機閃爍(0~2 秒):放映機還沒對到焦,畫面忽明忽暗
  2. Logo 殘影滑入(2~5 秒):品牌 logo 從右側帶著 motion blur 殘影掃進來
  3. 靜止微抖(5~8 秒):logo 定住,但整個畫面有手持膠捲那種微妙晃動
  4. 最後閃白(8~10 秒):高潮收尾,畫面爆白像底片燒掉

全程膠捲顆粒、掃描線、暗角 vignette 都活著(不是靜態圖層),完全用 React 程式碼產生,沒有任何外部影像素材。


這篇會用到

  • @remotion/motion-blur<Trail> 元件 — 把 children 多次堆疊出殘影
  • @remotion/noisenoise2D(seed, x, y) — 確定性偽隨機,做閃爍與抖動
  • SVG <feTurbulence> — 真實底片顆粒(不是貼圖,是 GPU 即時算的)
  • CSS mix-blend-mode — 把顆粒層自然融入畫面
  • <Sequence> 控制四階段時間軸

前置知識

如果你還沒做過上面兩篇,建議先去看一下,這篇會把那些基礎當作已知。


Step 1:安裝套件

npm install @remotion/motion-blur @remotion/noise

兩個都是 Remotion 官方套件,版本號必須跟你專案裡的 remotion 對齊。如果你升級主套件,記得連這兩個一起升,否則會出現 type mismatch。


Step 2:用 noise2D 做放映機閃爍

老式 8mm 放映機的燈泡會不規則抖動,看起來有種「會呼吸」的暖光。要模擬這個效果,第一個直覺是 Math.random(),但這在 Remotion 是大忌——稍後會解釋原因。

正確做法是 @remotion/noisenoise2D

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每層往前偏移幾 frame1~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,順序不能反。


本篇涵蓋的官方文件


下一步

  • T17:SVG Path 動畫 — 用 pathLengthstroke-dashoffset 做出畫線、寫字、勾描效果
  • T18:Lottie + Rive — 把 After Effects / Rive 做的動畫嵌進 Remotion,跟 React 元件混用

學到這篇你已經有能力做出「不依賴外部素材就有電影感」的片頭——這是 Remotion 相對其他工具最強的地方:所有效果都是程式,所有程式都是參數,所有參數都能被資料驅動。

有問題歡迎到 FB 社群 討論!