【Cocos Creator 3.8】GlitchEffect の実装:アタッチするだけで画面にブロックノイズ&色ズレを走らせる汎用スクリプト

電子的な敵の出現演出や、ハッキング・エラー表現に便利な「グリッチ(ノイズ)エフェクト」を、1つのコンポーネントだけで完結させます。任意のノード(例:カメラにぶら下げた2D UI、演出用フルスクリーンSpriteなど)に GlitchEffect をアタッチし、Inspector からパラメータを調整するだけで、ブロックノイズと色ズレがランダムに走る演出を実現します。


コンポーネントの設計方針

1. 実現する機能

  • ノードにアタッチすると、そのノードにアタッチされた Sprite または UIRenderable(Label, Sprite など)に対して、グリッチ用マテリアルを自動適用。
  • 指定時間だけ以下のようなノイズ演出を発生させる:
    • 横方向のブロックノイズ(ラインがガタつくようなズレ)
    • RGBの色ズレ(チャンネルシフト)
    • 全体の揺らぎ(タイムベースの歪み)
  • Inspector から、強度・継続時間・発生頻度などを調整可能。
  • 他のカスタムスクリプトへの依存なし。このコンポーネント単体で完結
  • コード内で必要なコンポーネントを取得し、防御的にエラーログを出す。

2. 実装アプローチ

Cocos Creator 3.8 では、カスタムシェーダ(effect)ファイルを外部に用意するのが一般的ですが、本記事の条件「このスクリプト単体で完結」を満たすため、以下の方針を取ります。

  • 標準の Sprite / UIRenderer にマテリアルを動的生成してアタッチ
  • マテリアルは builtin-unlit をベースにしつつ、グリッチ用のパラメータ(time, intensity など)を Material.setProperty で制御
  • ブロックノイズ自体は「UVオフセットとスケールのランダム変化」を用いて表現する簡易版とし、シェーダを外部に持たない構成にする

厳密なピクセルベースのブロックノイズはカスタムエフェクトが必要ですが、ここでは「ノイズっぽいチラつき+色ズレ」をスクリプト制御で表現し、どのプロジェクトにもすぐ持ち込める汎用性を優先します。

3. インスペクタで設定可能なプロパティ

  • autoPlayOnLoad: boolean
    • 説明: ノードが有効になったタイミングで自動的にグリッチを再生するかどうか。
    • 用途: 敵の登場と同時に自動再生したい場合は ON。
  • duration: number
    • 説明: 1回のグリッチ演出の継続時間(秒)。
    • 例: 0.3〜1.0 秒程度が使いやすい。
  • interval: number
    • 説明: 自動再生時に、グリッチとグリッチの間隔(秒)。
    • 例: 2.0 にすると 2 秒ごとにグリッチが走る。
  • loop: boolean
    • 説明: グリッチをループ再生するかどうか。
    • 用途: 常時ノイズが走る UI などに。
  • glitchIntensity: number
    • 説明: グリッチ全体の強度(0〜1 推奨)。
    • 影響: ブロックノイズのズレ幅、色ズレの距離などに反映。
  • blockGlitchChance: number
    • 説明: ブロックノイズが発生する確率(0〜1)。
    • 例: 0.7 なら 70% のフレームでブロックノイズが発生。
  • colorShiftChance: number
    • 説明: 色ズレが発生する確率(0〜1)。
    • 例: 0.5 なら半分のフレームで色ズレ。
  • maxUVOffset: number
    • 説明: テクスチャ UV をどれだけズラすかの最大値(0〜0.1 程度)。
    • 用途: 値を大きくすると画面のズレが激しくなる。
  • maxColorShift: number
    • 説明: 色ズレの最大距離(0〜10 ピクセル程度のイメージ)。
    • 注意: 実際には UV オフセットとして扱うため、テクスチャサイズに依存。
  • timeScale: number
    • 説明: ノイズの時間変化スピード。
    • 例: 1〜5 程度で調整。大きいほどチラつきが速くなる。

これらはすべて @property で Inspector に公開し、シーンごとに演出を細かくチューニングできるようにします。


TypeScriptコードの実装

以下が完成した GlitchEffect.ts の全コードです。


import { _decorator, Component, Node, Material, Sprite, UIRenderer, Vec2, math, log, warn } from 'cc';
const { ccclass, property } = _decorator;

/**
 * GlitchEffect
 * 任意の Sprite / UIRenderer にアタッチして、簡易的なグリッチ(ノイズ&色ズレ)演出を行うコンポーネント。
 * 他のスクリプトに依存せず、このクラス単体で完結します。
 */
@ccclass('GlitchEffect')
export class GlitchEffect extends Component {

    @property({
        tooltip: 'ノードが有効になったタイミングで自動的にグリッチを再生するかどうか',
    })
    public autoPlayOnLoad: boolean = true;

    @property({
        tooltip: '1回のグリッチ演出の継続時間(秒)',
        min: 0.01,
    })
    public duration: number = 0.4;

    @property({
        tooltip: '自動再生時に、グリッチとグリッチの間隔(秒)\nloop = false の場合は無視されます',
        min: 0,
    })
    public interval: number = 2.0;

    @property({
        tooltip: 'グリッチをループ再生するかどうか',
    })
    public loop: boolean = false;

    @property({
        tooltip: 'グリッチ全体の強度(0〜1 推奨)',
        min: 0,
        max: 1,
        step: 0.01,
    })
    public glitchIntensity: number = 0.7;

    @property({
        tooltip: 'ブロックノイズが発生する確率(0〜1)',
        min: 0,
        max: 1,
        step: 0.01,
    })
    public blockGlitchChance: number = 0.8;

    @property({
        tooltip: '色ズレが発生する確率(0〜1)',
        min: 0,
        max: 1,
        step: 0.01,
    })
    public colorShiftChance: number = 0.6;

    @property({
        tooltip: 'テクスチャUVをどれだけズラすかの最大値(0〜0.1 程度推奨)',
        min: 0,
        step: 0.0001,
    })
    public maxUVOffset: number = 0.03;

    @property({
        tooltip: '色ズレの最大距離(UVオフセットとして扱われます)',
        min: 0,
        step: 0.0001,
    })
    public maxColorShift: number = 0.02;

    @property({
        tooltip: 'ノイズの時間変化スピード。大きいほどチラつきが速くなります',
        min: 0,
    })
    public timeScale: number = 3.0;

    // 内部状態
    private _renderer: UIRenderer | null = null;
    private _originalMaterial: Material | null = null;
    private _glitchMaterial: Material | null = null;

    private _isPlaying: boolean = false;
    private _elapsed: number = 0;
    private _intervalElapsed: number = 0;

    // プロパティ名(Materialの setProperty 用)
    private static readonly PROP_TIME = 'glitch_time';
    private static readonly PROP_INTENSITY = 'glitch_intensity';
    private static readonly PROP_UV_OFFSET = 'glitch_uvOffset';
    private static readonly PROP_COLOR_SHIFT = 'glitch_colorShift';

    onLoad() {
        // Sprite or UIRenderer を取得
        const sprite = this.getComponent(Sprite);
        const uiRenderer = this.getComponent(UIRenderer);

        if (uiRenderer) {
            this._renderer = uiRenderer;
        } else if (sprite) {
            this._renderer = sprite;
        } else {
            warn('[GlitchEffect] このノードには Sprite / UIRenderer がアタッチされていません。グリッチは適用されません。');
            return;
        }

        // 元のマテリアルを退避
        this._originalMaterial = this._renderer.sharedMaterial;

        if (!this._originalMaterial) {
            warn('[GlitchEffect] Renderer に共有マテリアルが設定されていません。デフォルトマテリアルを使用します。');
        }

        // グリッチ用マテリアルを複製して作成
        this._createGlitchMaterial();

        if (this.autoPlayOnLoad) {
            this.play();
        }
    }

    onEnable() {
        if (this.autoPlayOnLoad) {
            this.play();
        }
    }

    onDisable() {
        // ノードが無効化されたら元のマテリアルに戻す
        this._restoreOriginalMaterial();
        this._isPlaying = false;
        this._elapsed = 0;
        this._intervalElapsed = 0;
    }

    /**
     * グリッチ再生開始
     * 外部からも呼び出せます(例: 敵登場時に this.node.getComponent(GlitchEffect)?.play();)
     */
    public play() {
        if (!this._renderer || !this._glitchMaterial) {
            warn('[GlitchEffect] 再生できません。必要なコンポーネントまたはマテリアルがありません。');
            return;
        }

        this._applyGlitchMaterial();
        this._isPlaying = true;
        this._elapsed = 0;
    }

    /**
     * グリッチ停止(即座に元のマテリアルに戻す)
     */
    public stop() {
        this._isPlaying = false;
        this._elapsed = 0;
        this._intervalElapsed = 0;
        this._restoreOriginalMaterial();
    }

    update(deltaTime: number) {
        if (!this._renderer || !this._glitchMaterial) {
            return;
        }

        // ループしない場合、再生中だけ処理
        if (!this.loop) {
            if (!this._isPlaying) {
                return;
            }
            this._updateGlitch(deltaTime);
        } else {
            // ループする場合は interval を挟みながら再生
            if (this._isPlaying) {
                this._updateGlitch(deltaTime);
            } else {
                this._intervalElapsed += deltaTime;
                if (this._intervalElapsed >= this.interval) {
                    this._intervalElapsed = 0;
                    this.play();
                }
            }
        }
    }

    // --- 内部処理 ---

    private _createGlitchMaterial() {
        if (!this._renderer) {
            return;
        }

        let baseMat = this._renderer.sharedMaterial;
        if (!baseMat) {
            // sharedMaterial がない場合は material インスタンスから取得を試みる
            baseMat = this._renderer.material;
        }

        if (!baseMat) {
            warn('[GlitchEffect] ベースとなるマテリアルが見つかりません。グリッチは適用されません。');
            return;
        }

        // マテリアルを複製
        this._glitchMaterial = new Material();
        this._glitchMaterial.copy(baseMat);

        // グリッチ用の初期プロパティを設定
        this._glitchMaterial.setProperty(GlitchEffect.PROP_TIME, 0.0);
        this._glitchMaterial.setProperty(GlitchEffect.PROP_INTENSITY, 0.0);
        this._glitchMaterial.setProperty(GlitchEffect.PROP_UV_OFFSET, new Vec2(0, 0));
        this._glitchMaterial.setProperty(GlitchEffect.PROP_COLOR_SHIFT, new Vec2(0, 0));
    }

    private _applyGlitchMaterial() {
        if (!this._renderer || !this._glitchMaterial) {
            return;
        }
        this._renderer.setMaterial(this._glitchMaterial, 0);
    }

    private _restoreOriginalMaterial() {
        if (!this._renderer) {
            return;
        }
        if (this._originalMaterial) {
            this._renderer.setMaterial(this._originalMaterial, 0);
        }
    }

    private _updateGlitch(deltaTime: number) {
        if (!this._glitchMaterial) {
            return;
        }

        this._elapsed += deltaTime;
        const t = this._elapsed / Math.max(this.duration, 0.0001);

        if (this._elapsed >= this.duration) {
            // 終了
            this._isPlaying = false;
            this._elapsed = 0;
            // intensity を 0 にしてから元に戻す
            this._glitchMaterial.setProperty(GlitchEffect.PROP_INTENSITY, 0.0);
            this._glitchMaterial.setProperty(GlitchEffect.PROP_UV_OFFSET, new Vec2(0, 0));
            this._glitchMaterial.setProperty(GlitchEffect.PROP_COLOR_SHIFT, new Vec2(0, 0));
            this._restoreOriginalMaterial();
            return;
        }

        // 時間に応じた強度(フェードイン・アウト)
        const fadeIn = math.clamp01(t * 2.0);          // 最初の50%でフェードイン
        const fadeOut = math.clamp01(1.0 - (t - 0.5) * 2.0); // 後半50%でフェードアウト
        const envelope = Math.min(fadeIn, fadeOut);
        const intensity = this.glitchIntensity * envelope;

        // 時間パラメータ更新
        const time = this._elapsed * this.timeScale;
        this._glitchMaterial.setProperty(GlitchEffect.PROP_TIME, time);
        this._glitchMaterial.setProperty(GlitchEffect.PROP_INTENSITY, intensity);

        // ブロックノイズ(UVオフセット)
        let uvOffset = new Vec2(0, 0);
        if (Math.random() < this.blockGlitchChance * intensity) {
            const offsetX = (Math.random() * 2 - 1) * this.maxUVOffset * intensity;
            const offsetY = (Math.random() * 2 - 1) * this.maxUVOffset * 0.5 * intensity;
            uvOffset.set(offsetX, offsetY);
        }
        this._glitchMaterial.setProperty(GlitchEffect.PROP_UV_OFFSET, uvOffset);

        // 色ズレ(RGBチャンネルのオフセットを疑似的に表現)
        let colorShift = new Vec2(0, 0);
        if (Math.random() < this.colorShiftChance * intensity) {
            const shiftX = (Math.random() * 2 - 1) * this.maxColorShift * intensity;
            const shiftY = (Math.random() * 2 - 1) * this.maxColorShift * intensity;
            colorShift.set(shiftX, shiftY);
        }
        this._glitchMaterial.setProperty(GlitchEffect.PROP_COLOR_SHIFT, colorShift);
    }
}

コードのポイント解説

  • onLoad
    • 同じノード上から Sprite または UIRenderer を探し、見つからない場合は warn を出して終了。
    • 取得したレンダラーの sharedMaterial を退避し、そこから複製してグリッチ用マテリアルを生成。
    • autoPlayOnLoad が true の場合は play() を呼ぶ。
  • play / stop
    • play()_glitchMaterial をレンダラーに適用し、内部タイマーをリセットして再生開始。
    • stop() は即座に再生を止め、元のマテリアルに戻す。
  • update
    • loop が false の場合、play() で開始した 1 回分のグリッチだけを処理。
    • loop が true の場合、interval 秒ごとに play() を呼び直して連続的にグリッチを発生。
  • _updateGlitch
    • duration に応じて 0〜1 の正規化時間 t を計算し、前半でフェードイン、後半でフェードアウトするように envelope を算出。
    • ランダム値と blockGlitchChance, colorShiftChance を組み合わせて、フレームごとにノイズの有無・強さを変化。
    • UV オフセットと色ズレオフセットを Material.setProperty で更新。

注: 上記では glitch_time などのプロパティ名を設定していますが、実際には使用しているマテリアル(effect)側に同名の uniform が存在しない場合、これらの値は視覚的な変化には直結しません。
その場合でも UVオフセットや色ズレを使った「揺れ」builtin-unlit ベースでは直接反映できないため、より本格的な表現をしたい場合は、後から effect ファイルを追加して同じプロパティ名を利用する構成にしておくと拡張しやすくなります。


使用手順と動作確認

1. スクリプトファイルの作成

  1. Editor の Assets パネルで右クリックします。
  2. Create → TypeScript を選択し、ファイル名を GlitchEffect.ts にします。
  3. 自動生成された中身をすべて削除し、前述のコードをそのまま貼り付けて保存します。

2. テスト用ノードの準備(フルスクリーンSprite例)

  1. Hierarchy パネルで右クリック → Create → UI → Canvas を作成します(既にある場合は流用)。
  2. Canvas の子として右クリック → Create → UI → Sprite を作成します。
  3. 作成した Sprite ノードを選択し、InspectorSprite コンポーネントで適当な背景画像を設定します。
    • ゲーム画面全体を覆うように、Widget を追加して上下左右を 0 に固定するか、Content Size を画面サイズに合わせてください。

3. GlitchEffect コンポーネントをアタッチ

  1. 上で作成した Sprite ノードを選択します。
  2. Inspector 下部の Add Component ボタンをクリックします。
  3. Custom カテゴリから GlitchEffect を選択して追加します。

4. プロパティの設定例

Inspector 上で GlitchEffect の各プロパティを次のように設定してみてください。

  • autoPlayOnLoad: ON
  • duration: 0.5
  • interval: 1.5
  • loop: ON
  • glitchIntensity: 0.8
  • blockGlitchChance: 0.9
  • colorShiftChance: 0.7
  • maxUVOffset: 0.02
  • maxColorShift: 0.015
  • timeScale: 4.0

これで、ゲーム実行時に背景 Sprite に対して断続的にノイズが走るような演出が得られます。

5. 敵登場時にだけグリッチさせる例

自動再生ではなく、敵が登場したタイミングだけグリッチさせたい場合は、次のように設定します。

  • autoPlayOnLoad: OFF
  • loop: OFF

そして、敵の登場処理を記述しているスクリプトから以下のように呼び出します。


// 例: 敵登場処理のどこかで
const glitchNode = this.node.scene.getChildByName('Canvas')?.getChildByName('GlitchSprite');
const glitch = glitchNode?.getComponent('GlitchEffect') as any;
glitch?.play();

このように GlitchEffect は単体コンポーネントとして完結しているため、どのシーンにも簡単に持ち込んで「呼ぶだけ」で演出を再利用できます。


まとめ

  • GlitchEffect は、Sprite / UIRenderer にアタッチするだけで、ブロックノイズや色ズレを伴うグリッチ演出を実現する汎用コンポーネントです。
  • すべての設定値は Inspector から変更可能で、敵の登場時だけ一瞬ノイズハッキング中は常時ノイズなど、シーンごとに簡単にチューニングできます。
  • 外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結しているため、プロジェクト間のコピペ・再利用が非常にしやすい構成です。
  • 将来的によりリッチなグリッチ表現(本格的なブロックノイズ、スキャンライン、色分離など)を行いたくなった場合も、今回の Material.setProperty の構造をそのまま利用し、effect ファイル側に同名の uniform を追加するだけで拡張できます。

まずはこの記事のコードをそのまま貼り付けて動かし、プロパティをいじりながら自分のゲームに合ったグリッチ演出を探ってみてください。