Remotion LabRemotion Lab
視覺特效Glitch 故障藝術:RGB 分離、掃描線錯位、Datamosh、VHS 質感五種效果
glitchvhsclip-pathmix-blend-modeseeded-randomadvanced

Glitch 故障藝術:RGB 分離、掃描線錯位、Datamosh、VHS 質感五種效果

用純 CSS clip-path + mix-blend-mode 做出故障美學。5 個 phase:RGB 通道分離、scanline 切片抖動、datamosh 方塊錯位、VHS 雜訊。全部 seeded,無隨機抖動焦慮。

成品預覽

這是一支 10 秒、五個 phase 的故障美學示範。從乾淨的 BASE SIGNAL 開始,依序過渡到 RGB CHANNEL SPLITSCANLINE JITTERDATAMOSH、最後收在 VHS ARTIFACT。全程用 GLITCH + SIGNAL LOST 兩組文字當主體,讓觀眾在每種故障效果下都還能「勉強」讀得出來——這才是故障美學的精髓:不是破壞,而是受控的破壞


這篇會做出什麼

一支 10 秒 300 frame 的 Glitch Demo,分成 5 個 phase,每個 phase 60 frame:

  1. Phase 1(0~2s)BASE SIGNAL:乾淨白字黑底 + 永久 scanline + CRT vignette,當作「對照組」
  2. Phase 2(2~4s)RGB CHANNEL SPLIT:三份內容分別染成純紅/綠/藍,每 3 frame 抖動,mix-blend-mode: screen 相加回近白
  3. Phase 3(4~6s)SCANLINE JITTER:畫面切成 12 條水平條,每條 clip-path: inset() 顯示自己那塊,各自水平位移
  4. Phase 4(6~8s)DATAMOSH:12 個隨機方塊區域,各自裁切並位移,像壓縮失敗的 P-frame 殘影
  5. Phase 5(8~10s)VHS ARTIFACT:sine wave 水平扭曲 + 飽和度呼吸 + 厚重 scanline + 輕微 RGB bleed

核心精神:GLITCHSIGNAL LOST 兩組文字在每個 phase 都要保持可讀。故障不是馬賽克,是資訊在破碎邊緣求生。


前置知識

💡 這篇全程不需要 GLSL、不需要 @remotion/shapes、不需要外部圖片。只有 CSS


原理解析:故障美學的三個 CSS 技巧

這段是整篇的核心,讀懂它你之後自己都能發明新故障效果。

1. RGB channel split 的 mix-blend-mode: screen 魔法

傳統上要做色差(chromatic aberration),要嘛進 Photoshop 手動分離 R/G/B 通道,要嘛在 GLSL shader 用 texture2D 對同一張紋理做 3 次 UV 偏移採樣。CSS 的做法優雅很多:渲染三份完全相同的內容,分別染成純紅 #ff0000、純綠 #00ff00、純藍 #0000ff,三層都加 mix-blend-mode: screen,再給每層一點點 translate 偏移。

screen加法混色out = 1 - (1 - a)(1 - b)。三層純色完全疊在一起會回到白色,只有單層紅色的地方就看到紅,紅+綠看到黃,完全對應 RGB 光的加色模型。

<AbsoluteFill style={{ mixBlendMode: "screen", transform: "translate(-6px, 0)" }}>
  <BaseContent color="#ff0040" />
</AbsoluteFill>
{/* 綠層 translate(0,0) #00ff80、藍層 translate(6px,0) #0080ff,結構相同 */}

💡 為什麼不是 multiply multiply 是減法混色(印刷 CMYK 邏輯),純紅×純綠=黑,你會得到一團黑,沒有故障感。screen 才是螢幕發光邏輯。

2. clip-path: inset() 做 scanline 切片

clip-path: inset(top right bottom left) 的四個值是從各邊向內裁掉多少。例如 inset(40% 0 55% 0) 代表「從上方切掉 40%、從下方切掉 55%」,結果只剩下畫面 40%~45% 那條橫帶。

把同一份內容複製 12 份,每份分配一條 stripPct = 100/12 ≈ 8.33% 的橫帶,用 inset(i*stripPct% 0 (100-(i+1)*stripPct)% 0) 切出該條,再對每條獨立 translateX(seeded)關鍵洞察:12 條加起來就是完整畫面,所以文字永遠沒有完全消失,只是被水平撕裂。

{Array.from({ length: 12 }).map((_, i) => {
  const top = (i * 100) / 12;
  const bottom = 100 - ((i + 1) * 100) / 12;
  const dx = rand(tick * 31 + i * 17, -40, 40);
  return (
    <AbsoluteFill
      key={i}
      style={{
        clipPath: `inset(${top}% 0 ${bottom}% 0)`,
        transform: `translateX(${dx}px)`,
      }}
    >
      <BaseContent />
    </AbsoluteFill>
  );
})}

3. Seeded jitter 的時間步進

如果 jitter offset 每一幀都變,你得到的是純噪點——眼睛抓不到形狀,只覺得畫面在沸騰。如果 10 幀以上才變一次,又太慢,看起來像卡頓不像故障。實驗出來的甜蜜點是每 3~5 幀換一次 seed,也就是每個偏移值停留 3~5 幀,產生那種典型的「咚、咚、咚」chunky glitch 節奏。

做法是把 frame 做 floor 除法產生「tick」,tick 當 seed:

const frame = useCurrentFrame();
const tick = Math.floor(frame / 3);        // 每 3 幀升一階
const jx = rand(tick * 7 + 1, -10, 10);    // seed 隨 tick 變,同一 tick 內保持不變

💡 RGB split 我用 /3、scanline 我用 /4、datamosh 我用 /5——不同效果刻意錯開頻率,三個 phase 疊看就不會覺得節奏太機械。這是故障設計的小秘密。


Step 1:Base layer 設定

先把「對照組」的底建起來:黑底 + 大字 GLITCH + 副標 SIGNAL LOST + 永久 scanline + CRT vignette。這層之後每個 phase 都會重用。

Claude Code:

新增 Composition "TutorialGlitchDemo":
- 1920x1080 @ 30fps
- durationInFrames 300(10 秒)
- 背景 #050814(接近黑但帶一點藍)

抽出 BaseContent 元件:
- 置中 column
- "GLITCH" fontWeight 900, fontSize 360, letterSpacing 0.02em
- "SIGNAL LOST" monospace, fontSize 64, letterSpacing 0.4em
- BaseContent 可接 color prop,預設 #ffffff

再建兩個永久 overlay:
- ScanlineOverlay:repeating-linear-gradient 2px 深黑 + 2px 透明
  mixBlendMode multiply
- Vignette:radial-gradient 外圍 0.75 黑
const BaseContent: React.FC<{ color?: string }> = ({ color = "#ffffff" }) => (
  <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
    <div style={{ fontWeight: 900, fontSize: 360, color, letterSpacing: "0.02em" }}>GLITCH</div>
    <div style={{ marginTop: 24, fontSize: 64, color, letterSpacing: "0.4em", opacity: 0.85 }}>
      SIGNAL LOST
    </div>
  </AbsoluteFill>
);

為什麼把 BaseContent 抽出來? 後面 5 個 phase 都會把它當積木用,有的染色、有的切片、有的位移。抽出來就能保證所有 phase 的「原始內容」完全一致,只差效果層。


Step 2:Phase 1 + Phase 2 RGB Split

Phase 1 就是 <BaseContent />,零特效。Phase 2 才是重頭戲:

新增 Phase2 元件:
- useCurrentFrame 拿 frame
- tick = Math.floor(frame / 3)
- 用 mulberry32 seeded random 產 6 個 jitter 值(3 軸 × xy)
- 渲染 3 份 BaseContent,分別 #ff0040 / #00ff80 / #0080ff
- 三份都 mixBlendMode: "screen"
- 三份都 translate:基礎位移 -6 / 0 / +6 + jitter
- 範圍 jx ±10、jy ±6
const Phase2: React.FC = () => {
  const frame = useCurrentFrame();
  const tick = Math.floor(frame / 3);
  const jx1 = rand(tick * 7 + 1, -10, 10);
  const jy1 = rand(tick * 7 + 2, -6, 6);
  // ...jx2,jy2,jx3,jy3
  return (
    <AbsoluteFill style={{ backgroundColor: "#050814" }}>
      <AbsoluteFill style={{ mixBlendMode: "screen", transform: `translate(${-6 + jx1}px, ${jy1}px)` }}>
        <BaseContent color="#ff0040" />
      </AbsoluteFill>
      {/* ...綠、藍兩層同理 */}
    </AbsoluteFill>
  );
};

為什麼不用純 #ff0000 純紅在 screen blend 下偏橘,#ff0040 加了一點洋紅讓它更像 CRT 螢光粉、霓虹的感覺。綠藍同理,這是調色學不是技術問題。

💡 mulberry32 的實作可以從 T22 直接複製過來,或用這裡的版本。重點是輸入 seed → 輸出 [0,1) 的純函式,這樣每次渲染同一 frame 結果都一樣,可以放心用 Remotion render farm。


Step 3:Phase 3 Scanline Jitter

新增 Phase3 元件:
- STRIPS = 12
- stripPct = 100 / STRIPS
- tick = Math.floor(frame / 4)
- 先畫一層乾淨的 BaseContent(底層「無位移版」)
- 再 map 12 條 AbsoluteFill:
  clipPath: inset(i*stripPct% 0 (100-(i+1)*stripPct)% 0)
  transform: translateX(rand(tick*31 + i*17 + 11, -40, 40))
  裡面放一份 BaseContent
const Phase3: React.FC = () => {
  const frame = useCurrentFrame();
  const tick = Math.floor(frame / 4);
  const STRIPS = 12;
  const stripPct = 100 / STRIPS;
  return (
    <AbsoluteFill>
      <BaseContent />
      {Array.from({ length: STRIPS }).map((_, i) => {
        const top = i * stripPct;
        const bottom = 100 - (i + 1) * stripPct;
        const dx = rand(tick * 31 + i * 17 + 11, -40, 40);
        return (
          <AbsoluteFill
            key={i}
            style={{
              clipPath: `inset(${top}% 0 ${bottom}% 0)`,
              transform: `translateX(${dx}px)`,
            }}
          >
            <BaseContent />
          </AbsoluteFill>
        );
      })}
    </AbsoluteFill>
  );
};

為什麼底下要先鋪一層乾淨 BaseContent? 當 12 條都往同一方向位移時,邊緣會露出黑背景。底層作為「安全網」補那些露出的地方,看起來會比較像畫面局部撕裂而不是畫面整個破

💡 seed = tick*31 + i*17 + 11,用質數乘法避免 hash collision,是 seeded random 的常見小技巧。


Step 4:Phase 4 Datamosh

新增 Phase4 元件:
- BLOCKS = 12
- tick = Math.floor(frame / 5)
- palette = 6 色(紅綠藍黃洋紫青)
- 底層一份 BaseContent
- map 12 個方塊:
  - 隨機 x/y 位置
  - 隨機寬 120~360、高 40~140
  - 隨機內容位移 dx/dy ±120/±60
  - 隨機是否顯示「切片」(rand > 0.4)
  - 方塊用 overflow:hidden + 邊框 palette[i]
  - mixBlendMode: "screen"
  - 內部放一份 absolute 定位的 BaseContent,用 -x + dx 讓它「看起來是整個畫面錯位後被這個方塊框住」
{Array.from({ length: 12 }).map((_, i) => {
  const seed = tick * 101 + i * 53;
  const x = rand(seed + 1, 0, 1920 - 200);
  const y = rand(seed + 2, 0, 1080 - 100);
  const w = rand(seed + 3, 120, 360);
  const h = rand(seed + 4, 40, 140);
  const dx = rand(seed + 5, -120, 120);
  return (
    <div style={{ position: "absolute", left: x, top: y, width: w, height: h, overflow: "hidden", mixBlendMode: "screen" }}>
      <div style={{ position: "absolute", left: -x + dx, top: -y, width: 1920, height: 1080 }}>
        <BaseContent />
      </div>
    </div>
  );
})}

關鍵:left: -x + dx 這段。 方塊本身定位在 (x, y),內部子元素用負 x 把「大畫面」拉回原點,再加 dx 就是「錯位後的版本」。這樣每個小方塊看起來都是畫面的錯位切片,不是獨立的裝飾方塊。這是整個 datamosh 的靈魂。


Step 5:Phase 5 VHS

新增 Phase5 元件:
- wave = Math.sin(frame / 6) * 12   (水平扭曲)
- sat = 1.2 + (sin(frame/8)+1) * 0.4 (飽和度呼吸 1.2~2.0)
- 外層 filter: saturate(sat)
- 中間 BaseContent translateX(wave)
- 兩層 RGB bleed:紅 wave-4、藍 wave+4,mixBlendMode screen, opacity 0.55
- 厚 scanline overlay:repeating-linear-gradient 2px 白(0.08) + 4px 透明
const Phase5: React.FC = () => {
  const frame = useCurrentFrame();
  const wave = Math.sin(frame / 6) * 12;
  const sat = 1.2 + (Math.sin(frame / 8) + 1) * 0.4;
  return (
    <AbsoluteFill style={{ filter: `saturate(${sat})` }}>
      <AbsoluteFill style={{ transform: `translateX(${wave}px)` }}>
        <BaseContent />
      </AbsoluteFill>
      <AbsoluteFill style={{ transform: `translateX(${wave - 4}px)`, mixBlendMode: "screen", opacity: 0.55 }}>
        <BaseContent color="#ff0040" />
      </AbsoluteFill>
      {/* 藍通道同理 + scanline overlay */}
    </AbsoluteFill>
  );
};

VHS 跟 Phase 2 RGB split 的差別? Phase 2 用高頻 seeded jitter模擬「訊號中斷」,VHS 用低頻連續 sine wave模擬「磁帶老化」。一個是數位故障、一個是類比衰減,節奏完全不一樣。


Step 6:音效層 + phase transitions

在根 composition 放 5 個 Audio Sequence:
- from 0,   20 frame: audio/t13/01-rec-beep.mp3(Phase 1 開場)
- from 60,  20 frame: audio/t13/04-cut-arrow.mp3(Phase 2 進入)
- from 120, 20 frame: audio/t13/06-cut-storm.mp3(Phase 3 撕裂)
- from 180, 20 frame: audio/t13/08-flash-snap.mp3(Phase 4 碎裂)
- from 240, 40 frame: audio/t13/06-cut-storm.mp3 volume 0.5(Phase 5 衰減)

每個 Audio 都用 <Sequence from={X} durationInFrames={Y}> 包,
這樣 Remotion 才會在正確時間 mount/unmount。

💡 音效素材在 public/audio/t13/ 底下——T13 音效庫是整個 Remotion Community 的共用資源,所有 tutorial 都可以引用。

為什麼 phase 邊界才上音效? 故障效果如果 2 秒內一直響,觀眾會疲勞。只在**「從 A phase 切到 B phase」的 0.5 秒窗**放音效,大腦才會把「聽到的」跟「看到的」綁成同一個事件。這是影音同步的心理學。


調整故障強度

五個參數決定故障「量」:

參數SubtleMedium (本篇)Intense
RGB split 基礎位移±3 px±6 px±14 px
RGB split jitter 範圍±4 px±10 px±24 px
RGB split tick 間隔每 6 frame每 3 frame每 2 frame
Scanline 切片數6 條12 條24 條
Scanline 位移範圍±15 px±40 px±90 px
Datamosh 方塊數51224

調整原則:切片數跟位移範圍不要同時拉高。24 條切片 × ±90 px 位移的結果是「完全看不出來是什麼字」——你丟了可讀性,故障美學就不成立了。我自己的經驗是:先把位移拉到極限,再慢慢減到看得出輪廓為止,那個邊界就是你這支影片的「故障量甜蜜點」。

💡 想做一個「故障漸進加強」的 phase?把上面所有數值用 interpolate(frame, [0, 60], [subtle, intense]) 做線性過渡就好。


進階技巧

  • 加 horizontal tear:在 scanline phase 隨機挑 1~2 條切片,位移範圍從 ±40 px 爆到 ±200 px,其他維持原值。結果是「大部分畫面輕微抖動,偶爾一條狂甩」——這是真實 CRT 訊號中斷的特徵。用 if (rand(seed, 0, 1) > 0.85) dx *= 5 即可。
  • 加 color bleed:在最外層包 filter: hue-rotate(${frame % 360}deg),讓色相緩慢旋轉,配合 RGB split 會產生「訊號發散」的感覺。注意 hue-rotate 對黑白沒作用,底層要先有顏色(Phase 2 最適合)。
  • 加 static noise:用 SVG <feTurbulence baseFrequency="0.9" /> + <feColorMatrix> 產生灰階雜訊,外層 mixBlendMode: "overlay"。overlay 會保留底層明暗但注入雜訊質感,比 screen 自然很多。雜訊 seed 一樣用 tick = floor(frame/3) 階梯式。

FAQ

Q:mix-blend-mode 在 Safari 渲染有問題嗎? A:Safari 支援很好,但有一個陷阱:父層若是預設 isolation: auto,blend mode 會穿透到更上層,結果 Phase 2 的紅綠藍跟 Vignette 混在一起顏色全錯。解法是在 Phase 2 最外層加 isolation: "isolate" 強制建立 stacking context。Remotion render(headless Chromium)跟 Chrome 行為一致,通常只在 Safari preview 才踩到。

Q:為什麼 clip-path 沒有吃我的 transition? A:clip-path 的值必須形狀函式一樣才能 interpolate。inset(0% 0 50% 0)inset(0% 0 30% 0) 可以,但 inset()circle() 不行。Remotion 裡我們不用 CSS transition(每幀都重新 render),這個限制影響不大,但記得不要混用 clip-path 形狀函式。

Q:想做更極端的故障效果,CSS 做得到嗎還是要上 shader? A:真正的 displacement map、feedback loop(前一幀當本幀輸入)、2D convolution——這些 CSS 做不到,要上 GLSL。但 80% 的「故障 YouTube 片頭」需求 CSS 都能搞定。判斷分界:效果可以「分層」思考就用 CSS,需要「逐像素採樣」就上 shader。Remotion 支援 @remotion/shaders(beta)或直接寫 <canvas> WebGL。

Q:故障效果會影響無障礙閱讀嗎? A:會。高頻閃爍可能觸發光敏性癲癇,WCAG 2.1 規定每秒閃爍不能超過 3 次且不能占大面積。本篇的 seeded jitter 是「位移」不是「閃爍」(亮度變化小),相對安全。商業作品建議:亮度對比變化 < 10%、故障 phase 不超過 2 秒、關鍵字幕放在不受故障影響的靜止區域。


本篇涵蓋的官方文件

下一步

故障做爛的臨界點很微妙——太少像 bug、太多像壞掉——歡迎到 FB 社群 丟你的 demo 請大家 review 強度!