猫の手ツール

Algorithm & UI/UX

アナログの不完全さを計算で描く:
VHSグリッチのピクセル操作術

デジタルな写真を、あえて「壊す」ことで生まれる情緒。Canvas APIのImageDataをピクセル単位で書き換え、チャンネル分離とトラッキングエラーをシミュレートする実装の裏側を公開します。

1. チャンネル分離(RGB Shift)による色収差の再現

VHS特有の、赤や青が左右に滲む「色収差」は、物理的なテープの磁気転送エラーやレンズの特性を模したものです。デジタルでこれを再現するため、元のピクセルデータをメモリ上にクローンし、R(赤)とB(青)のチャンネルだけを参照する際のX座標を意図的にずらして合成しています。

単純に画像全体をずらすのではなく、ループ内でピクセルインデックスを再計算することで、後述する行単位のノイズともシームレスに連携できる設計にしています。

// 色収差のピクセル合成ロジック
for (let y = 0; y < canvasHeight; y++) {
    for (let x = 0; x < canvasWidth; x++) {
        const i = (y * canvasWidth + x) * 4;

        // 赤チャンネルと青チャンネルの参照座標を左右にオフセット
        // ここにユーザー設定に基づいた座標計算が入ります
        let rX = x + offsetValue;
        let bX = x - offsetValue;

        // キャンバス範囲内へのクランプ処理
        rX = Math.max(0, Math.min(canvasWidth - 1, rX));
        bX = Math.max(0, Math.min(canvasWidth - 1, bX));

        const rIndex = (y * canvasWidth + rX) * 4;
        const bIndex = (y * canvasWidth + bX) * 4;

        // クローンした元のデータから各チャンネルを抽出して再合成
        data[i]     = originalData[rIndex];     // R
        data[i + 1] = originalData[i + 1];      // G(基準として動かさない)
        data[i + 2] = originalData[bIndex + 2]; // B
    }
}

2. 行単位のランダム・トラッキングエラー

古いビデオデッキでテープの回転が不安定になった際に起こる「横方向の激しいブレ」を再現するため、特定の確率で行(Row)ごとにランダムな水平オフセットを付与するアルゴリズムを導入しています。

全行を一律にずらすのではなく、Math.random() による確率判定を Y 軸ループの直下で行うことで、VHSらしい突発的で予測不可能な映像の乱れを演出しています。

// 行単位のトラッキングノイズ判定
for (let y = 0; y < canvasHeight; y++) {
    let rowOffset = 0;
    
    // 独自のチューニング確率に基づき、特定の行だけを水平移動させる
    if (Math.random() < // 独自の出現確率閾値) {
        rowOffset = Math.floor((Math.random() - 0.5) * // ズレの最大幅);
    }

    for (let x = 0; x < canvasWidth; x++) {
        // この rowOffset を座標計算に加算することで、行全体が不連続にズレる
        // ここに色収差と組み合わせたピクセル書き戻し処理が入ります
    }
}

3. 走査線(Scanline)の数学的シミュレート

ブラウン管テレビ特有の横縞は、画像の輝度を周期的に減衰させることで表現しています。画像を重ねるのではなく、ピクセルループ内で y % 3 === 0 のような剰余演算を用い、特定の行に対して減衰係数を乗算しています。

この手法のメリットは、追加の描画コスト(drawImage等)をかけずに、既存のピクセルループのついでに処理を完結できるため、スマホブラウザでも60fpsに近いレスポンスを維持できる点にあります。

// 走査線の減衰計算
const isScanline = (y % // 独自の周期値 === 0);
const scanlineFactor = isScanline ? (1 - (intensity * // 独自の減衰率)) : 1;

// 最終的なRGB値に係数を掛けて暗くする
data[i]     = r * scanlineFactor;
data[i + 1] = g * scanlineFactor;
data[i + 2] = b * scanlineFactor;

Developer's Note

このツールの開発で最も時間をかけたのは、「完璧すぎないノイズ」の調整です。

デジタルの計算でノイズを作ると、どうしても均一で整った模様になりがちです。しかし、VHSの魅力はアナログならではの「不完全さ」にあります。そこで、あえて乱数の発生源を複数に分散させたり、色によってズレの幅を変えたりすることで、機械的な冷たさを排除しました。

また、1000x1000pxクラスの画像をピクセル単位でループ処理するのはブラウザにとって非常に重いタスクです。willReadFrequently オプションの活用や、計算結果を Uint8ClampedArray でバッファリングするなどのメモリ最適化を施し、低スペックな端末でもスライダー操作に追従するリアルタイム性を追求しました。