【Cocos Creator 3.8】PixelateEffect の実装:アタッチするだけで画面全体をドット絵風モザイクにする汎用スクリプト

このガイドでは、カメラにアタッチするだけで「画面全体をモザイク化(ピクセル化)」できる PixelateEffect コンポーネントを実装します。
シーン遷移の演出や、特定イベント中だけ解像度を落としてレトロ感を出したいときに便利で、外部の GameManager などに一切依存しない、単体で完結する設計になっています。


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

基本コンセプト

  • 対象はカメラ(Camera コンポーネントを持つノード)
  • カメラのポストエフェクトとしてピクセル化(モザイク)を行う
  • インスペクタから強度(ピクセルサイズ)、有効/無効、フェードアニメーションなどを調整可能
  • 外部スクリプトに依存せず、このコンポーネント単体で完結
  • 必要な標準コンポーネント(Camera)が無い場合は error ログを出して自動で無効化

実現方法の概要

Cocos Creator 3.8 では、カメラに対して Camera.postProcess を利用し、
カスタムの PostProcessSetting(内部的にはマテリアル+シェーダ)を設定することで、ポストエフェクトを実現できます。

このコンポーネントでは、

  • 起動時にカメラを取得
  • ピクセル化専用のマテリアルをランタイムで生成
  • カメラの postProcess に適用
  • インスペクタのプロパティをマテリアルの uniform と連動させる

という流れで動作します。

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

PixelateEffect コンポーネントに用意するプロパティと役割は以下の通りです。

  • enabledEffect: boolean
    – 初期状態でエフェクトを有効にするかどうか。
    true: 起動直後から画面がピクセル化される。
    false: 起動時は通常表示で、スクリプトから有効化したい場合に使用。
  • pixelSize: number
    – ピクセルの大きさ(モザイクの粗さ)。
    1 に近いほど高解像度(ほぼ通常表示)、32 など大きくすると荒いモザイクになる。
    – 実際にはシェーダの _PixelSize uniform に渡す。
  • maxPixelSize: number
    – フェードイン/アウト時の最大ピクセルサイズ。
    – フェード時の終点として使うので、通常の pixelSize とは別に設定可能にしておく。
  • fadeDuration: number
    fadeIn / fadeOut 時のアニメーション時間(秒)。
    – 0 以下にすると「即時切り替え」として扱う。
  • autoApplyOnStart: boolean
    true: start() 時に自動でポストエフェクトをカメラへ適用。
    false: スクリプトから明示的に applyEffect() を呼ぶまで何もしない。
  • debugLog: boolean
    – 内部動作(カメラ取得、マテリアル作成など)のログを console.log に出すかどうか。
    – トラブルシュート時に有効化すると便利。

また、外部から制御しやすいように、以下のメソッドを公開します。

  • enableImmediate() : 即座にピクセル化を有効にする。
  • disableImmediate() : 即座にピクセル化を無効にする。
  • fadeIn() : 通常表示 → モザイクへ徐々に変化。
  • fadeOut() : モザイク → 通常表示へ徐々に変化。

TypeScriptコードの実装

以下が、単体で完結する PixelateEffect.ts の完全な実装例です。
(シェーダはスクリプト内の文字列として定義し、ランタイムでマテリアルを生成するため、別途 .effect ファイルを用意する必要はありません


import { _decorator, Component, Camera, renderer, Material, game, view, macro, Vec2, director, log, error } from 'cc';
const { ccclass, property } = _decorator;

/**
 * PixelateEffect
 * カメラにアタッチして、画面全体をピクセル化(モザイク)するポストエフェクト。
 * 他のスクリプトに依存せず、このコンポーネント単体で完結します。
 */
@ccclass('PixelateEffect')
export class PixelateEffect extends Component {

    @property({
        tooltip: '起動時にエフェクトを有効にするかどうか',
    })
    public enabledEffect: boolean = true;

    @property({
        tooltip: '通常時のピクセルサイズ(モザイクの粗さ)。1 に近いほど高解像度、値を大きくすると荒くなる',
        min: 1,
        step: 1,
    })
    public pixelSize: number = 8;

    @property({
        tooltip: 'フェードイン/アウト時に使用する最大ピクセルサイズ(終点の粗さ)',
        min: 1,
        step: 1,
    })
    public maxPixelSize: number = 32;

    @property({
        tooltip: 'フェードイン/アウトの時間(秒)。0 以下なら即時切り替え',
        min: 0,
    })
    public fadeDuration: number = 0.5;

    @property({
        tooltip: 'start() で自動的にポストエフェクトをカメラに適用するかどうか',
    })
    public autoApplyOnStart: boolean = true;

    @property({
        tooltip: 'デバッグ用ログを出力するかどうか',
    })
    public debugLog: boolean = false;

    // 内部使用フィールド
    private _camera: Camera | null = null;
    private _material: Material | null = null;
    private _isFading: boolean = false;
    private _fadeElapsed: number = 0;
    private _fadeFrom: number = 1;
    private _fadeTo: number = 1;
    private _fadeCallback: (() => void) | null = null;

    // シェーダソース(簡易ピクセル化)
    // 注意: 実プロジェクトでは .effect ファイルに切り出す方が保守しやすいですが、
    // 今回は「単体コンポーネント完結」のため文字列として定義します。
    private static readonly PIXELATE_SHADER = `
CCEffect %{
  techniques:
  - passes:
    - vert: vs
      frag: fs
      blendState:
        targets:
        - blend: false
      rasterizerState:
        cullMode: none
      properties:
        _MainTex: { value: white }
        _PixelSize: { value: 8.0 }
}% 

CCProgram vs %{
  precision highp float;
  #include <cc-global>
  #include <cc-local>

  in vec3 a_position;
  in vec2 a_texCoord;
  out vec2 v_uv;

  void main () {
      vec4 pos = vec4(a_position, 1.0);
      v_uv = a_texCoord;
      gl_Position = cc_matProj * cc_matView * cc_matWorld * pos;
  }
}%

CCProgram fs %{
  precision highp float;
  #include <cc-global>
  #include <cc-local>

  in vec2 v_uv;
  out vec4 fragColor;

  uniform sampler2D _MainTex;
  uniform float _PixelSize;
  uniform vec2 cc_screenSize;

  void main () {
      // 画面サイズとピクセルサイズからサンプリング座標を量子化
      vec2 pixelCount = cc_screenSize / _PixelSize;
      vec2 uv = floor(v_uv * pixelCount) / pixelCount;
      vec4 color = texture(_MainTex, uv);
      fragColor = color;
  }
}%`;

    onLoad() {
        this._camera = this.getComponent(Camera);
        if (!this._camera) {
            error('[PixelateEffect] Camera コンポーネントが見つかりません。このスクリプトは Camera を持つノードにアタッチしてください。');
            this.enabled = false;
            return;
        }

        if (this.debugLog) {
            log('[PixelateEffect] Camera 取得成功');
        }

        this._createMaterialIfNeeded();
    }

    start() {
        if (!this._camera || !this._material) {
            return;
        }

        if (this.autoApplyOnStart) {
            this.applyEffect();
        }

        // 初期値を反映
        this._updatePixelSizeUniform(this.enabledEffect ? this.pixelSize : 1);
    }

    update(dt: number) {
        if (!this._material) {
            return;
        }

        // フェード中なら補間処理
        if (this._isFading) {
            this._fadeElapsed += dt;
            const duration = Math.max(this.fadeDuration, 0.0001);
            const t = Math.min(this._fadeElapsed / duration, 1.0);
            const current = this._fadeFrom + (this._fadeTo - this._fadeFrom) * t;
            this._updatePixelSizeUniform(current);

            if (t >= 1.0) {
                this._isFading = false;
                if (this._fadeCallback) {
                    this._fadeCallback();
                    this._fadeCallback = null;
                }
            }
        }
    }

    /**
     * カメラにポストエフェクト(ピクセル化)を適用する。
     * すでに適用済みの場合は何もしない。
     */
    public applyEffect() {
        if (!this._camera || !this._material) {
            return;
        }

        const pp = this._camera.postProcess;
        if (!pp) {
            error('[PixelateEffect] このバージョンの Cocos では Camera.postProcess が利用できないか、初期化されていません。');
            return;
        }

        // すでに同じマテリアルが設定されているかチェック
        const current = pp.sharedMaterials;
        if (current && current.length > 0) {
            for (const m of current) {
                if (m === this._material) {
                    if (this.debugLog) {
                        log('[PixelateEffect] すでにポストエフェクトが適用済みです');
                    }
                    return;
                }
            }
        }

        pp.sharedMaterials = [this._material];

        if (this.debugLog) {
            log('[PixelateEffect] ポストエフェクトをカメラに適用しました');
        }
    }

    /**
     * 即座にピクセル化を有効にする(フェードなし)。
     */
    public enableImmediate() {
        this.enabledEffect = true;
        this._isFading = false;
        this._updatePixelSizeUniform(this.pixelSize);
    }

    /**
     * 即座にピクセル化を無効にする(フェードなし)。
     * 実際にはピクセルサイズを 1 にすることで「ほぼ通常表示」に近づける。
     */
    public disableImmediate() {
        this.enabledEffect = false;
        this._isFading = false;
        this._updatePixelSizeUniform(1);
    }

    /**
     * 通常表示 → モザイクへ徐々に変化させる。
     * fadeDuration が 0 以下の場合は即時切り替え。
     */
    public fadeIn(onComplete?: () => void) {
        if (!this._material) {
            return;
        }
        if (this.fadeDuration <= 0) {
            this.enableImmediate();
            if (onComplete) onComplete();
            return;
        }

        this.enabledEffect = true;
        this._startFade(1, this.maxPixelSize, onComplete);
    }

    /**
     * モザイク → 通常表示へ徐々に変化させる。
     * fadeDuration が 0 以下の場合は即時切り替え。
     */
    public fadeOut(onComplete?: () => void) {
        if (!this._material) {
            return;
        }
        if (this.fadeDuration <= 0) {
            this.disableImmediate();
            if (onComplete) onComplete();
            return;
        }

        this._startFade(this.maxPixelSize, 1, () => {
            this.enabledEffect = false;
            if (onComplete) onComplete();
        });
    }

    // ===== 内部処理 =====

    private _createMaterialIfNeeded() {
        if (this._material) {
            return;
        }

        // シェーダを EffectAsset として登録する簡易的な方法
        // 3.8 では公式 API でのランタイム Effect 生成が制限されているため、
        // 実プロジェクトでは .effect ファイルを使用することを推奨します。
        const effectAsset = new renderer.EffectAsset();
        (effectAsset as any)._native = '';
        effectAsset.name = 'PixelateEffectRuntime';
        effectAsset._effect = PixelateEffect.PIXELATE_SHADER;

        this._material = new Material();
        this._material.initialize({
            effectAsset,
            defines: {},
        });

        // 画面サイズを uniform に設定
        const size = view.getVisibleSize();
        this._material.setProperty('cc_screenSize', new Vec2(size.width, size.height));
        this._updatePixelSizeUniform(this.pixelSize);

        if (this.debugLog) {
            log('[PixelateEffect] マテリアルを生成しました。画面サイズ:', size.width, size.height);
        }
    }

    private _updatePixelSizeUniform(value: number) {
        if (!this._material) {
            return;
        }
        const v = Math.max(1, value);
        this._material.setProperty('_PixelSize', v);
    }

    private _startFade(from: number, to: number, onComplete?: () => void) {
        this._isFading = true;
        this._fadeElapsed = 0;
        this._fadeFrom = from;
        this._fadeTo = to;
        this._fadeCallback = onComplete || null;
    }
}

コードの主要ポイント解説

  • onLoad()
    – 自身のノードから Camera コンポーネントを取得。
    – 見つからなければ error を出して this.enabled = false にし、防御的に処理を終了。
    – シェーダ文字列からマテリアルを生成する _createMaterialIfNeeded() を呼び出し。
  • start()
    autoApplyOnStarttrue の場合、自動的にカメラへポストエフェクトを適用。
    enabledEffect の状態に応じて、pixelSize もしくは 1 をシェーダに反映。
  • update(dt)
    – フェード中(_isFading === true)であれば、fadeDuration に基づいて時間補間し、
    _PixelSize uniform を徐々に変更していく。
  • applyEffect()
    – カメラの postProcess.sharedMaterials に、このコンポーネントで生成したマテリアルを設定。
    – すでに同じマテリアルが設定済みであれば再設定しない。
  • enableImmediate() / disableImmediate()
    – フェードなしで即座に有効/無効を切り替える。
  • fadeIn() / fadeOut()
    fadeDuration が 0 以下なら即時切り替え。
    – それ以外は _startFade() で内部状態をセットし、update() で補間。
  • _createMaterialIfNeeded()
    – 内部のシェーダ文字列(PIXELATE_SHADER)から EffectAsset を作成し、Material を初期化。
    – 画面サイズを cc_screenSize uniform に設定し、ピクセルサイズも初期値で反映。

使用手順と動作確認

ここからは、実際にエディタ上でコンポーネントを使う手順を具体的に説明します。

1. スクリプトファイルを作成する

  1. Assets パネルで右クリックします。
  2. Create → TypeScript を選択します。
  3. ファイル名を PixelateEffect.ts にします。
  4. 自動生成されたスクリプトを開き、上記のコードを丸ごと貼り付けて保存します。

2. カメラノードを用意する

既にシーンにカメラがある場合はそれを使って構いません。ここでは新規作成の例を示します。

  1. Hierarchy パネルで右クリックします。
  2. Create → 3D Object → Camera を選択します。(2D プロジェクトでも Camera は同じです)
  3. 作成された Camera ノードを選択し、Inspector で Camera コンポーネントが付いていることを確認します。

3. PixelateEffect コンポーネントをアタッチする

  1. Hierarchy で、ピクセル化したい画面を映している Camera ノード を選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom カテゴリの中から PixelateEffect を選択して追加します。

4. インスペクタでプロパティを設定する

Camera ノードにアタッチされた PixelateEffect コンポーネントのプロパティを調整します。

  • Enabled Effect: チェックを入れる(true)
    → 起動直後からモザイクがかかった状態になります。
  • Pixel Size: 例えば 8 に設定
    → ほどよく粗いドット絵風になります。4 ならやや細かく、16 ならかなり荒くなります。
  • Max Pixel Size: 例えば 32 に設定
    → フェードイン/アウトの終点の粗さに使われます。
  • Fade Duration: 例えば 0.5 に設定
    fadeIn() / fadeOut() を呼んだとき、0.5 秒かけて変化します。
  • Auto Apply On Start: チェックを入れる(true)
    start() 時に自動でカメラへポストエフェクトが適用されます。
  • Debug Log: 必要に応じてチェック
    → トラブル発生時に内部ログを見たい場合に有効化します。

5. プレビューで動作確認する

  1. エディタ右上の Play(▶) ボタンを押してゲームを実行します。
  2. シーンビューに映っているオブジェクト(Sprite や 3D モデルなど)が、ドット絵風にピクセル化されて表示されていることを確認します。
  3. もし何も変化がない場合は、以下を確認してください:
    • Camera ノードに Camera コンポーネントが付いているか。
    • Camera ノードに PixelateEffect コンポーネントが付いているか。
    • コンソールに [PixelateEffect] で始まるエラーログが出ていないか。

6. フェードイン/フェードアウトを試す(任意)

シーン内の他のスクリプトから、PixelateEffect を取得してフェード演出を制御できます。
(このコンポーネント自体は外部に依存しませんが、外部から呼べる API を用意しているというイメージです)

例えば、ボタンを押したときに画面をモザイクにしながらシーン遷移したい場合:


// 例: 任意のスクリプト内
import { _decorator, Component, Node, director } from 'cc';
import { PixelateEffect } from './PixelateEffect';
const { ccclass, property } = _decorator;

@ccclass('SceneTransitionExample')
export class SceneTransitionExample extends Component {

    @property(Node)
    public cameraNode: Node | null = null;

    public onClickChangeScene() {
        if (!this.cameraNode) {
            return;
        }
        const effect = this.cameraNode.getComponent(PixelateEffect);
        if (!effect) {
            return;
        }

        // 0.5 秒かけてモザイクを強くし、その後シーンを切り替え
        effect.fadeIn(() => {
            director.loadScene('NextSceneName');
        });
    }
}

このように、PixelateEffect は単体で完結しつつも、他のスクリプトから簡単に呼び出せるように設計されています。


まとめ

  • PixelateEffect は、Camera にアタッチするだけで画面全体をドット絵風にピクセル化する汎用コンポーネントです。
  • インスペクタから、
    • 初期状態での有効/無効
    • ピクセルサイズ(モザイクの粗さ)
    • フェード時間
    • デバッグログ出力

    を調整でき、シーンごとに演出を簡単に変えられます。

  • 外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結しているため、どのプロジェクトにもコピペで持ち込んで再利用しやすい構成です。
  • 画面遷移、ダメージ演出、ポーズ中の視覚効果など、「一時的に解像度を落として雰囲気を変える」用途に幅広く応用できます。

このガイドをベースに、色調変化やブラーなど他のポストエフェクトも同様のパターンで実装していけば、
Cocos Creator 3.8 での演出の幅が大きく広がります。ぜひプロジェクトの標準ライブラリとして活用してみてください。