R RenderComp
remotion ピクセルアート 8bit レトロ アニメーション チュートリアル

Remotionでピクセルアート動画を作る — レトロ8bit演出の実装ガイド

Remotionでピクセルアート動画を作る — レトロ8bit演出の実装ガイド

従来の動画編集ソフトは、ピクセルアートにとことん相性が悪いツールです。エッジには勝手にアンチエイリアスがかかり、スプライトのトゥイーンはサブピクセルのにじみを生み、丹精込めて打った32×32のキャラクターは拡大した瞬間にぼやけます。ピクセルアートには固有のルールがあります——整数倍スケーリング、量子化された移動、限定パレット、コマ送りのフレームステップ。タイムライン型のツールは、そのどれも前提に作られていません。

Remotionははるかに良い選択肢です。理由は単純で、これらのルールがすべてコードとして表現できるからです。スプライトシートを進めるフレームカウンター、仮想グリッドにスナップされた座標、4つのHEX値の配列そのものであるパレット——どれもTypeScriptなら数行です。この記事では、Chromiumでピクセルをくっきり描画する方法から、useCurrentFrame() によるスプライトシートアニメーション、限定パレット、CRT・走査線ポストエフェクト、そして絶対にちらつかない決定論的レンダリングまで、パイプライン全体を解説します。


なぜ今、動画コンテンツでピクセルアートなのか

レトロな美学は、ゲーム・音楽ビジュアル・SNSコンテンツを横断して強く再燃しています。インディーゲームのトレーラー、チップチューンのリリックビデオ、8bit風の解説動画——これらが伸びるのは、フィードを埋め尽くすグラデーション主体のモーションデザインと明確に「違って見える」からです。

プログラマティック動画の観点では、もうひとつ実用的な理由があります。ピクセルアートはパラメータ化のコストが極端に低いのです。スプライトは小さなPNG1枚、パレットは4〜16色の配列。ビジュアル言語全体が小さく離散的な部品でできているため、レンダリングごとにprops——キャラクター・パレット・テキスト——を差し替えるだけで、同じコンポジションから見た目の大きく異なる動画が量産できます。テンプレート駆動の制作において、ピクセルアートは最もレバレッジの効くスタイルのひとつです。


ピクセルをくっきり描画する — image-rendering・整数スケーリング・グリッドスナップ

RemotionのChromiumベースレンダラーでピクセルアートをくっきり保つルールは3つ。どれかひとつでも破ると、出力はぼやけます。

ルール1: image-rendering: pixelated

Chromiumはデフォルトで画像の拡大縮小時にスムージングをかけます。ピクセルアートに必要なのはニアレストネイバー(最近傍)拡大で、CSSでは image-rendering: pixelated として指定します。

import { Img, staticFile } from 'remotion';

<Img
  src={staticFile('sprites/hero.png')}
  style={{
    width: 32 * 8, // 32pxのソースを8倍に拡大
    height: 32 * 8,
    imageRendering: 'pixelated',
  }}
/>

RemotionはChromiumを通してレンダリングするため、Chromeで見えるのと同じ image-rendering の挙動がそのままMP4に焼き込まれます。

ルール2: スケールは整数倍のみ

32×32のスプライトを8倍にすれば256×256でシャープなまま。7.5倍にすると物理ピクセルの間に落ち、最近傍サンプリングがピクセル幅の不揃いな「よれ」を生みます。ソースサイズには必ず整数を掛けてください。

const SPRITE_SIZE = 32;
const SCALE = 8; // 整数のみ — 7.5は不可

const displaySize = SPRITE_SIZE * SCALE; // 256

スプライトを「成長」させたい場合は、連続的なトゥイーンではなく整数スケール間のステップ(4倍 → 5倍 → 6倍)で切り替えます。カクッと大きさが変わる感じ自体が、この様式美の一部です。

ルール3: 移動をピクセルグリッドにスナップする

滑らかなサブピクセル移動は幻想を壊します。実際の8bitハードウェアは、スプライトを1ピクセル単位でしか動かせませんでした。座標は通常どおり interpolate()spring() で計算し、その後に仮想グリッド(アート上の1ピクセル = 画面上の SCALE ピクセル)へ量子化します。

import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';

const frame = useCurrentFrame();
const { fps } = useVideoConfig();

// 画面を横切って歩く
const rawX = interpolate(frame, [0, 120], [0, 640], {
  extrapolateRight: 'clamp',
});

// スプリング物理演算でジャンプ
const jump = spring({ frame, fps, config: { damping: 12, stiffness: 120 } });
const rawY = interpolate(jump, [0, 1], [0, -96]);

// 両方をアートピクセルのグリッドに量子化
const x = Math.round(rawX / SCALE) * SCALE;
const y = Math.round(rawY / SCALE) * SCALE;

<div style={{ transform: `translate(${x}px, ${y}px)` }} />

スプリングのタイミング——オーバーシュートも含めて——はそのまま活きますが、動きはグリッドに着地します。キャラクターは滑るのではなく、目に見えるピクセル単位でホップします。実機がスプライトを動かしていたのと同じ挙動です。


useCurrentFrameでスプライトシートをコマ送りする

スプライトシートは、全アニメーションフレームを横一列やグリッドに並べた1枚の画像です。再生とは、セルをひとつずつ表示すること——まさに useCurrentFrame() の得意分野です。

核となるパターンは、動画のフレームカウンターをホールド時間で割り、剰余(モジュロ)でループさせることです。

import { AbsoluteFill, Img, staticFile, useCurrentFrame } from 'remotion';

const SPRITE_SIZE = 32;   // 各セルは32×32
const SPRITE_COUNT = 8;   // 横一列に8セル
const SCALE = 8;
const HOLD = 6;           // 各セルを6フレーム表示(30fps時に実質5fps)

export const WalkCycle: React.FC = () => {
  const frame = useCurrentFrame();
  const cell = Math.floor(frame / HOLD) % SPRITE_COUNT;

  return (
    <div
      style={{
        width: SPRITE_SIZE * SCALE,
        height: SPRITE_SIZE * SCALE,
        overflow: 'hidden',
        position: 'relative',
      }}
    >
      <Img
        src={staticFile('sprites/walk-strip.png')}
        style={{
          position: 'absolute',
          left: -cell * SPRITE_SIZE * SCALE,
          width: SPRITE_SIZE * SCALE * SPRITE_COUNT,
          height: SPRITE_SIZE * SCALE,
          imageRendering: 'pixelated',
        }}
      />
    </div>
  );
};

外側の divoverflow: hidden の固定サイズの「窓」で、その背後をストリップ画像がセル単位でスライドします。素の <img> ではなくRemotionの <Img> を使うのが重要です。<Img> は画像のデコード完了までスクリーンショットを遅延させるため、読み込み途中のスプライトがフレームに写り込むことがありません。

チューニングすべきポイントは2つです。

  • HOLD がアニメーションの質感を決めます。 クラシックな歩行サイクルは8〜12fpsで動いていました。30fpsのコンポジションなら HOLD = 3 で10fps再生——本物らしく軽快です。HOLD = 6 は遅く、重い印象になります。
  • サイクルと移動の合成は単なる足し算です。 スプライトのセルを進めながら、前節のグリッドスナップ済み x で窓自体を動かします。どちらも同じフレーム値から導出されるため、自動的に同期します。

待機・歩行・ジャンプのような多段アニメーションは、各フェーズを長さ指定付きの <Sequence> で包めば、それぞれが0始まりのローカルフレームを受け取ります。連結パターンの詳細はSequenceとタイミング制御のガイドを参照してください。


限定8bitカラーパレットの扱い方

本物の8bitグラフィックに滑らかなグラデーションは存在しませんでした。ハードウェアパレットには数色しかなく、画面上のすべてがその色を使い回していました。この制約をRemotionで強制することが、「本当にレトロに見える」と「ピクセルが大きいだけのフラットデザイン」の分かれ目です。

パレットを定数として定義し、コンポジション内のすべての色をそこから引きます。

// 初代ゲームボーイ(DMG)のグリーン4階調
const PALETTE = ['#0f380f', '#306230', '#8bac0f', '#9bbc0f'] as const;

面白いのはアニメーションです。色を連続的に補間する(パレット外の中間色が生まれてしまう)のではなく、離散的なパレットインデックスをステップで進めます。

import { interpolate, useCurrentFrame } from 'remotion';

const frame = useCurrentFrame();

// タイトルをパレット段階でフェードイン: 最暗色 → 最明色
const step = Math.min(
  PALETTE.length - 1,
  Math.floor(
    interpolate(frame, [0, 40], [0, PALETTE.length], {
      extrapolateRight: 'clamp',
    })
  )
);

<h1 style={{ color: PALETTE[step] }}>PRESS START</h1>

Math.floor が補間値を整数インデックスに量子化し、Math.min のガードが最終フレームで最後の色を突き抜けるのを防ぎます。結果は4段階の「フェード」——まさにファミコンやゲームボーイのゲームが、パレットを1段ずつ差し替えてシーンをフェードさせていた方式そのものです。同じテクニックはダメージ点滅や昼夜の遷移にも使えます。extrapolateオプションに馴染みがなければ、interpolate完全ガイドがリファレンスになります。

テンプレート設計の実践的なコツ: パレットをprops(palette: string[])として渡しましょう。HEX値4つの差し替えだけで、コンポジション全体の見た目が変わります——ゲームボーイグリーン、ファミコンブルー、モノクロアンバー——レイアウトの変更はゼロです。


CRT・走査線・ディザのポストエフェクト(CSSとCanvas)

フラットな背景に置いただけのピクセルアートは、どこか無機質に感じられます。時代考証に合ったディスプレイのアーティファクト——走査線、ビネット、蛍光体のにじみ——が、あの時代を連想させる質感を加えます。これらはすべて、シーンの上に重ねる <AbsoluteFill> オーバーレイとしてきれいにレイヤー化できます。

import { AbsoluteFill } from 'remotion';

export const CRTOverlay: React.FC = () => {
  return (
    <AbsoluteFill style={{ pointerEvents: 'none' }}>
      {/* 走査線: 物理4ピクセルごとに暗いライン */}
      <AbsoluteFill
        style={{
          backgroundImage:
            'repeating-linear-gradient(to bottom, transparent 0px, transparent 3px, rgba(0,0,0,0.22) 3px, rgba(0,0,0,0.22) 4px)',
        }}
      />
      {/* ビネット: ブラウン管の湾曲を思わせる四隅の暗がり */}
      <AbsoluteFill
        style={{
          background:
            'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.4) 100%)',
        }}
      />
    </AbsoluteFill>
  );
};

ルートコンポジションの最後の子としてマウントすれば、すべての上に重なります。素のCSSなので、レンダリングコストはほぼゼロです。

ディザリング——8bitゲームが中間の濃淡を擬似的に表現していた市松模様——は、repeating-conic-gradient を使えば画像アセットなしでピクセルパーフェクトなチェッカーボードが作れます。

<AbsoluteFill
  style={{
    backgroundImage:
      'repeating-conic-gradient(rgba(255,255,255,0.05) 0% 25%, transparent 0% 50%)',
    backgroundSize: '8px 8px', // SCALE = 4 のときアートピクセル2個分
  }}
/>

より重い処理——画像コンテンツへのBayerディザ、樽型歪み、色収差——が必要なら、現在のフレームをキーにした useEffect 内で <canvas> に描画します。描画がフレーム番号の純粋関数である限り、レンダリングは安全です。とはいえ大半のレトロ表現では、上記のCSSレイヤーだけで効果の9割を、はるかに低い複雑さで実現できます。


ピクセルレンダリングを決定論的に保つ(ランダムなちらつきを出さない)

レトロなシーンはランダム性が大好きです。またたく星空、砂嵐ノイズ、グリッチの明滅。ここに罠があります——Remotionはフレームを並列に、場合によっては複数プロセスや複数のLambda関数にまたがってレンダリングし、同じフレームが複数回レンダリングされることもあります。Math.random() を呼ぶと、レンダリングパスごとに異なる値が返り、フレーム間でノイズが一貫しないちらつきが生まれたり、レンダリングのたびに違う動画ができあがったりします。

解決策はRemotion組み込みの random()——シード付きの決定論的な擬似乱数関数です。同じシードは常に同じ値を返します。

import { random, useCurrentFrame } from 'remotion';

const Starfield: React.FC<{ count: number }> = ({ count }) => {
  const frame = useCurrentFrame();

  return (
    <>
      {Array.from({ length: count }).map((_, i) => {
        // 位置と位相は星ごとに固定 — どのレンダリングパスでも同じ
        const x = Math.floor(random(`star-x-${i}`) * 240) * 8;
        const y = Math.floor(random(`star-y-${i}`) * 135) * 8;
        const phase = random(`star-phase-${i}`) * Math.PI * 2;

        // またたきはフレームの純粋関数 — 決定論的
        const bright = Math.sin(frame * 0.12 + phase) > 0.3;

        return (
          <div
            key={i}
            style={{
              position: 'absolute',
              left: x,
              top: y,
              width: 8,
              height: 8,
              backgroundColor: bright ? '#9bbc0f' : '#306230',
            }}
          />
        );
      })}
    </>
  );
};

すべての「ランダム」値は安定したシード文字列から導出され、すべての時間変化する値は frame から導出されています。このコンポジションを10回レンダリングすれば、10個の完全に同一なファイルが得られます——つまりQCでレンダリング結果をバイト単位で差分比較できるということです。星の位置も8pxグリッドにfloorされている点に注目してください。ランダム配置でさえ、ピクセルグリッドに従います。

霧や水面のゆらめきのような漂うエフェクトには、@remotion/noise が同じ決定論を保ったまま滑らかな変化を与えてくれます。パターンの全体像はノイズによる有機的アニメーションのガイドを参照してください。


よくある失敗 — アンチエイリアス・サブピクセル座標・ぼやけた書き出し

以下の失敗パターンで、「なぜかピクセルアートがぼやける」問題のほぼすべてが説明できます。

  • 拡大画像のどれかに image-rendering: pixelated を付け忘れる。 見落とされた1枚の <Img>——多くの場合は背景——だけが滑らかにレンダリングされ、他はくっきりという状態になります。すべてのラスター素材に適用してください。
  • 非整数のスケール係数。 scale(7.5) や、端数に解決されるパーセント幅は、ピクセル列の幅を不揃いにします。サイズは SPRITE_SIZE * SCALE からpxで計算しましょう。
  • 描画済みコンテンツへのCSS transform: scale() コンテナをtransformで拡大するとラスターが再サンプリングされ、スムージングが再発することがあります。要素は最初から最終ピクセル寸法でサイズ指定してください。
  • 生の補間値によるサブピクセル座標。 translate(133.7px) はエッジを物理ピクセルの間に置き、Chromiumがアンチエイリアスをかけます。Math.round(v / SCALE) * SCALE で量子化しましょう。
  • 半ピクセルのセンタリング。 奇数幅のコンテナに justifyContent: 'center' を使うと、スプライトが .5px オフセットに置かれます。代わりに Math.round((width - spriteWidth) / 2 / SCALE) * SCALE で計算してください。
  • ピクセルアートの上のベクターテキスト。 カクカクしたピクセルの上に滑らかなアンチエイリアス文字が載ると、様式は一瞬で崩れます。ビットマップ調のピクセルフォントをセルフホストで使いましょう——外部CDNからの読み込みは不可です。
import { loadFont } from '@remotion/fonts';
import { staticFile } from 'remotion';

// public/fonts/ に同梱したライセンス確認済みピクセルフォント — 外部リクエストなし
loadFont({
  family: 'PixelFont',
  url: staticFile('fonts/pixel-font.woff2'),
});

その上で fontFamily: 'PixelFont', fontSize: 8 * SCALE のように、フォントのデザイングリッドの整数倍でサイズを保ちます。フォールバックには、プロポーショナルフォントより劣化の少ない等幅システムフォント(ui-monospace, Menlo, monospace)を指定しておくとよいでしょう。

  • プレビューはくっきりなのに書き出しがぼやける。 たいていは解像度の不一致です。1920×1080でレンダリングして720pに縮小すれば、全ピクセルが再サンプリングされます。ネイティブ解像度でレンダリングし、コンポジション寸法をアート解像度の正確な倍数にしてください(1920×1080 = SCALE = 8 でアートピクセル240×135)。

レトロピクセルアートのテンプレートパックの上に構築する

ここまでのすべて——グリッドスナップ、スプライトのコマ送り、パレットprops、CRTオーバーレイ、決定論的ランダム——は、再利用可能な部品として合成できます。それこそがRemotionでピクセルアートをやる本当の見返りです。歩行サイクル・星空・走査線オーバーレイが型付きコンポーネントとして一度存在してしまえば、次の動画を作る作業は「propsの差し替え」であって「描き直し」ではなくなります。

私たち自身のレトロピクセル系コンポジションもこの方式で構築しており、そこから得たアーキテクチャの教訓は一般化できます。レンダラー(グリッド・スケーリング・オーバーレイ)とコンテンツ(スプライト・パレット・テキスト)を分離して保つこと。決定論はQCも機械化します——同一のpropsは同一の出力を生まなければならず、それはハッシュ比較で自動検証できます。


よくある質問(FAQ)

Q: image-rendering: pixelated はRemotionのレンダラーで効きますか?

効きます。RemotionはヘッドレスChromiumを通してレンダリングするため、CSSはChromeとまったく同じに振る舞います。pixelated はStudioのプレビューだけでなく、最終動画でもニアレストネイバー拡大になります。

Q: 30fpsのコンポジション内で、スプライトの再生レートはいくつにすべきですか?

クラシックな8bitアニメーションはおおよそ8〜12fpsで動いていました。出発点としては各セルを3フレームホールド(10fps)。2だとせわしなく、5〜6だとゆったりした印象になります。コンポジション自体のfpsを変える必要はありません——ホールド時間で調整できます。

Q: PNGスプライトを使わず、ピクセルアート自体をコードで生成できますか?

できます。<div> セルのグリッドを描画するか、canvasに描き、すべての色をパレット配列から引きます。コード生成のピクセルアートは完全にパラメータ化でき、決定論的な random() によるプロシージャルパターンとの相性も抜群です。

Q: propsに応じて変わる長さでスプライトサイクルをループさせるには?

モジュロパターン(Math.floor(frame / HOLD) % SPRITE_COUNT)は無限にループするので、変えるべきはコンポジションの長さだけです。calculateMetadataでpropsから長さを計算すれば、テキストや音声が長くなってもシーンが自動で延び、サイクルはループし続けます。


まとめ

Remotionのピクセルアートアニメーションは、5つの制約をコードで強制することに尽きます。

  1. すべてのラスター素材に image-rendering: pixelated
  2. スケール係数は整数のみ — サイズはすべて SPRITE_SIZE * SCALE で計算
  3. すべての移動をアートピクセルのグリッドに量子化: Math.round(v / SCALE) * SCALE
  4. スプライトのセルとパレットのインデックスは Math.floor でステップ — 連続トゥイーン禁止
  5. random(seed) とフレーム由来の値だけを使い、全レンダリングをバイト単位で同一に

その上にCSSの走査線とビネットを重ね、パレットをpropsとして渡せば、1本きりのクリップではなく「レトロ動画システム」が手に入ります。


白紙のキャンバスからではなく、動くレトロコンポジションから始めたい方へ →

RenderComp のライブラリには、まさにこれらのパターンで構築されたレトロピクセルアートパックがあります。グリッドスナップ済みのスプライトシーン、パレット駆動のカラーシステム、CRTオーバーレイ、決定論的なプロシージャルエフェクト——編集可能なTypeScriptソースと型付きpropsでそのまま使えます。

販売中

1,000以上のRemotionテンプレートを一括入手

買い切り(一括払い)・サブスクなし・生涯アップデート無料。TypeScript製。

料金プランを見る →