【Cocos Creator】アタッチするだけ!VisibilityOptimizer (画面外停止)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】VisibilityOptimizer の実装:アタッチするだけで「画面外に出たら自動で処理を停止・再開」する汎用スクリプト

本記事では、Cocos Creator 3.8.7 と TypeScript で、VisibleOnScreenNotifier2D を利用して「ノードが画面外に出たら自動で処理を止め、再び画面に入ったら処理を再開する」ための汎用コンポーネント VisibilityOptimizer を実装します。
移動する敵キャラ・エフェクト・ギミックなどにアタッチするだけで、画面外の無駄な処理をカットしてパフォーマンスを最適化できるのが狙いです。


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

1. コンポーネントの役割

VisibilityOptimizer は、以下のような挙動を行う単体完結のコンポーネントです。

  • VisibleOnScreenNotifier2D を利用して、ノードがカメラの画面内/外にあるかを監視する。
  • 画面外に出たとき:
    • 指定した「処理対象ノード(Process ノード)」の Component.update()lateUpdate() が呼ばれないように enabled を false にする。
    • 任意で RigidBody2D の物理シミュレーションを停止enabled = false)する。
  • 画面内に戻ってきたとき:
    • 停止していた Component / RigidBody2D を enabled = true に戻し、処理を再開する。

このコンポーネント自体は、他のカスタムスクリプトに一切依存せず、アタッチするだけで動作します。
「Process ノード」に何のスクリプトが付いていても、その enabled をまとめて制御することで「処理停止」を実現します。

2. 外部依存をなくすための設計アプローチ

  • 必要な参照はすべて @property で Inspector から設定可能にする。
  • VisibleOnScreenNotifier2D は、コンポーネント内で getComponent して取得を試みる。
    • 存在しなければ エラーログを出し、動作を停止
    • 記事内で「ノードに VisibleOnScreenNotifier2D を追加する手順」を明示する。
  • Process ノードを指定しなかった場合は、自ノード自身を Process 対象とみなす。
  • 停止対象とするコンポーネントの種類は、インスペクタのオプションで柔軟に制御できるようにする。
    • すべてのコンポーネントを停止するか
    • 特定の種類(例: RigidBody2D)を含める/含めないか

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

実装する @property とその役割は以下の通りです。

  • processRoot: Node | null
    • 説明: 処理のオン/オフを切り替えたい「Process ノード」のルート。
    • 未指定(null)の場合は この VisibilityOptimizer が付いているノード自身が対象。
    • 例: 敵キャラのルートノードを指定すると、その子階層にある全てのコンポーネントを一括制御できる。
  • includeChildren: boolean
    • 説明: processRoot の子階層を含めてすべてのコンポーネントを対象にするか。
    • true: processRoot とその子孫ノードすべてのコンポーネントを停止/再開。
    • false: processRoot 自身に付いているコンポーネントだけを対象にする。
  • affectRigidBody2D: boolean
    • 説明: 物理挙動の最適化用。対象ノードにある RigidBody2D を停止対象に含めるか。
    • true: 画面外で RigidBody2D.enabled = false にして物理計算を止める。
    • false: 物理挙動はそのまま(他のコンポーネントのみ停止)。
  • affectAnimation: boolean
    • 説明: Animation / AnimationComponent などのアニメーション系コンポーネントを停止対象に含めるか。
    • true: 画面外でアニメーションを止める。
    • false: アニメーションは動かしたままにする。
  • affectAllComponents: boolean
    • 説明: 対象ノードに付いている ほぼすべてのコンポーネントenabled を切り替えるか。
    • true: RigidBody2D やアニメーションを含む全コンポーネントを停止(ただし VisibilityOptimizer 自身は除外)。
    • false: 「自前スクリプト(Component 派生)」のみを対象とし、描画系(Sprite, Label など)は止めない。
  • logDebug: boolean
    • 説明: 画面内/外の切り替え時にログを出すかどうか。
    • true: コンソールに「visible / hidden」や停止したコンポーネント数をログ出力。
    • false: ログを出さない(リリースビルド向け)。
  • initiallyPauseWhenHidden: boolean
    • 説明: シーン開始時にすでに画面外だった場合に自動で停止状態から始めるか。
    • true: 開始時に「画面外判定」なら即停止。
    • false: VisibleOnScreenNotifier2D から最初の通知が来るまでは停止しない。

TypeScriptコードの実装


import { _decorator, Component, Node, VisibleOnScreenNotifier2D, RigidBody2D, Animation, animation, warn, error, log } from 'cc';
const { ccclass, property } = _decorator;

/**
 * VisibilityOptimizer
 * 
 * 画面内 / 画面外の状態に応じて、指定ノード以下のコンポーネントを自動で有効化 / 無効化する汎用コンポーネント。
 * 
 * 必須:
 *   - 同じノードに VisibleOnScreenNotifier2D を追加しておくこと。
 */
@ccclass('VisibilityOptimizer')
export class VisibilityOptimizer extends Component {

    @property({
        type: Node,
        tooltip: '処理のオン/オフを切り替えたいルートノード。\n未指定の場合、このコンポーネントが付いているノード自身が対象になります。'
    })
    public processRoot: Node | null = null;

    @property({
        tooltip: '子階層を含めてすべてのコンポーネントを対象にするかどうか。'
    })
    public includeChildren: boolean = true;

    @property({
        tooltip: 'RigidBody2D コンポーネントも停止対象に含めるかどうか。'
    })
    public affectRigidBody2D: boolean = true;

    @property({
        tooltip: 'Animation / animation.AnimationController などのアニメーション系も停止対象に含めるかどうか。'
    })
    public affectAnimation: boolean = true;

    @property({
        tooltip: 'true の場合、ほぼすべてのコンポーネントの enabled を切り替えます(VisibilityOptimizer 自身は除外)。\nfalse の場合、自作スクリプトなど Component 派生のみを対象にします。'
    })
    public affectAllComponents: boolean = true;

    @property({
        tooltip: '画面内/外の切り替え時にログを出力するかどうか。'
    })
    public logDebug: boolean = false;

    @property({
        tooltip: 'シーン開始時にすでに画面外だった場合、最初から停止状態にするかどうか。'
    })
    public initiallyPauseWhenHidden: boolean = true;

    private _notifier: VisibleOnScreenNotifier2D | null = null;
    private _isVisible: boolean = true;
    private _cachedTargetNodes: Node[] = [];
    private _initialized: boolean = false;

    onLoad() {
        // VisibleOnScreenNotifier2D を取得
        this._notifier = this.getComponent(VisibleOnScreenNotifier2D);
        if (!this._notifier) {
            error('[VisibilityOptimizer] VisibleOnScreenNotifier2D が同じノードに存在しません。コンポーネントを追加してください。');
            return;
        }

        // イベント登録
        this._notifier.on('visible-on-screen', this._onBecameVisible, this);
        this._notifier.on('hidden-on-screen', this._onBecameHidden, this);

        // Process 対象ノードの決定
        const root = this.processRoot ?? this.node;
        if (!root) {
            error('[VisibilityOptimizer] processRoot が null で、this.node も取得できません。');
            return;
        }

        // 対象ノードのキャッシュ
        this._cacheTargetNodes(root);

        this._initialized = true;

        if (this.logDebug) {
            log('[VisibilityOptimizer] Initialized. processRoot =', root.name, 'targets =', this._cachedTargetNodes.length);
        }
    }

    start() {
        if (!this._initialized || !this._notifier) {
            return;
        }

        // VisibleOnScreenNotifier2D は onLoad 時点ではまだ「画面内判定」が確定していない可能性があるため、
        // start で状態を確認して初期状態を整える。
        const currentlyVisible = this._notifier.isOnScreen();
        this._isVisible = currentlyVisible;

        if (this.initiallyPauseWhenHidden && !currentlyVisible) {
            this._applyPause();
        } else {
            this._applyResume();
        }

        if (this.logDebug) {
            log('[VisibilityOptimizer] Start. initially visible =', currentlyVisible);
        }
    }

    onDestroy() {
        if (this._notifier) {
            this._notifier.off('visible-on-screen', this._onBecameVisible, this);
            this._notifier.off('hidden-on-screen', this._onBecameHidden, this);
        }
    }

    /**
     * ノード階層を走査して、対象とするノードをキャッシュする。
     */
    private _cacheTargetNodes(root: Node) {
        this._cachedTargetNodes.length = 0;

        if (this.includeChildren) {
            const stack: Node[] = [root];
            while (stack.length > 0) {
                const node = stack.pop()!;
                this._cachedTargetNodes.push(node);
                for (const child of node.children) {
                    stack.push(child);
                }
            }
        } else {
            this._cachedTargetNodes.push(root);
        }
    }

    /**
     * 画面内に入ったときのハンドラ
     */
    private _onBecameVisible() {
        if (!this._initialized) {
            return;
        }
        if (this._isVisible) {
            return; // すでに可視
        }
        this._isVisible = true;
        this._applyResume();

        if (this.logDebug) {
            log('[VisibilityOptimizer] Became VISIBLE. Resumed processes.');
        }
    }

    /**
     * 画面外に出たときのハンドラ
     */
    private _onBecameHidden() {
        if (!this._initialized) {
            return;
        }
        if (!this._isVisible) {
            return; // すでに不可視
        }
        this._isVisible = false;
        this._applyPause();

        if (this.logDebug) {
            log('[VisibilityOptimizer] Became HIDDEN. Paused processes.');
        }
    }

    /**
     * 対象ノード以下のコンポーネントを一括で停止する。
     */
    private _applyPause() {
        let affectedCount = 0;

        for (const node of this._cachedTargetNodes) {
            // VisibilityOptimizer 自身は止めない
            const components = node.components;
            for (const comp of components) {
                if (comp === this) {
                    continue;
                }
                // VisibleOnScreenNotifier2D 自体も止めない(可視状態を監視し続ける必要がある)
                if (comp instanceof VisibleOnScreenNotifier2D) {
                    continue;
                }

                // 物理
                if (!this.affectRigidBody2D && comp instanceof RigidBody2D) {
                    continue;
                }

                // アニメーション
                if (!this.affectAnimation && (comp instanceof Animation || comp instanceof animation.AnimationController)) {
                    continue;
                }

                // すべてのコンポーネントを対象にするか、自作スクリプトのみ対象にするか
                if (!this.affectAllComponents) {
                    // affectAllComponents = false の場合、
                    // 「cc.Component 派生で、かつ cc.Sprite や cc.Label などの描画系以外」
                    // という判定をしたいが、描画系クラスをすべて列挙するのは現実的ではない。
                    // ここでは「組み込みの代表的な描画コンポーネント」以外を対象とする簡易実装とする。
                    const className = (comp.constructor as any).name;
                    const isRendererLike =
                        className === 'Sprite' ||
                        className === 'Label' ||
                        className === 'RichText' ||
                        className === 'Mask' ||
                        className === 'UIOpacity' ||
                        className === 'UITransform';

                    if (isRendererLike) {
                        continue;
                    }
                }

                if (comp.enabled) {
                    comp.enabled = false;
                    affectedCount++;
                }
            }
        }

        if (this.logDebug) {
            log(`[VisibilityOptimizer] Paused components. count = ${affectedCount}`);
        }
    }

    /**
     * 対象ノード以下のコンポーネントを一括で再開する。
     */
    private _applyResume() {
        let affectedCount = 0;

        for (const node of this._cachedTargetNodes) {
            const components = node.components;
            for (const comp of components) {
                if (comp === this) {
                    continue;
                }
                if (comp instanceof VisibleOnScreenNotifier2D) {
                    continue;
                }

                // 物理
                if (!this.affectRigidBody2D && comp instanceof RigidBody2D) {
                    continue;
                }

                // アニメーション
                if (!this.affectAnimation && (comp instanceof Animation || comp instanceof animation.AnimationController)) {
                    continue;
                }

                if (!this.affectAllComponents) {
                    const className = (comp.constructor as any).name;
                    const isRendererLike =
                        className === 'Sprite' ||
                        className === 'Label' ||
                        className === 'RichText' ||
                        className === 'Mask' ||
                        className === 'UIOpacity' ||
                        className === 'UITransform';

                    if (isRendererLike) {
                        continue;
                    }
                }

                if (!comp.enabled) {
                    comp.enabled = true;
                    affectedCount++;
                }
            }
        }

        if (this.logDebug) {
            log(`[VisibilityOptimizer] Resumed components. count = ${affectedCount}`);
        }
    }
}

コードのポイント解説

  • onLoad
    • VisibleOnScreenNotifier2DgetComponent で取得。
    • 取得できなかった場合は error() を出して終了(必須コンポーネントがないため)。
    • visible-on-screen / hidden-on-screen イベントにリスナー登録。
    • processRoot が未設定なら this.node を対象にし、_cacheTargetNodes で対象ノードをキャッシュ。
  • start
    • VisibleOnScreenNotifier2D.isOnScreen() で現在の可視状態を取得。
    • initiallyPauseWhenHidden が true かつ画面外であれば _applyPause() を呼び、最初から停止状態にする。
    • そうでなければ _applyResume() で有効状態に揃える。
  • _onBecameVisible / _onBecameHidden
    • VisibleOnScreenNotifier2D からのイベントコールバック。
    • 内部フラグ _isVisible を更新し、状態が変化したときのみ _applyPause() / _applyResume() を実行。
  • _applyPause / _applyResume
    • _cachedTargetNodes に含まれるノードを走査し、各コンポーネントの enabled を切り替える。
    • 自身 (VisibilityOptimizer) と VisibleOnScreenNotifier2D は停止しない。
    • affectRigidBody2D, affectAnimation, affectAllComponents の設定に応じて対象をフィルタリング。
    • logDebug が true の場合、停止/再開したコンポーネント数をログ出力。
  • _cacheTargetNodes
    • includeChildren が true の場合は深さ優先で子階層をすべてスタック走査し、対象ノードを配列に詰める。
    • false の場合は processRoot のみを対象とする。

使用手順と動作確認

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

  1. エディタ上部メニュー、または Assets パネルで作業用フォルダ(例: assets/scripts)を選択します。
  2. Assets パネルで右クリック → CreateTypeScript を選択します。
  3. ファイル名を VisibilityOptimizer.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、上記の TypeScript コードをそのまま貼り付けて保存します。

2. テスト用ノードの準備

ここでは、画面の外に移動する Sprite を例に動作確認します。

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、テスト用のノード(例: Enemy)を作成します。
  2. Enemy ノードを選択し、Inspector で Add ComponentUIVisibleOnScreenNotifier2D を追加します。
    • このコンポーネントがないと VisibilityOptimizer はエラーを出して動作しません。
  3. 同じく Enemy ノードに、移動などの処理をする自作スクリプトがある場合はすでにアタッチしておきます(例: EnemyMover.ts)。

3. VisibilityOptimizer のアタッチと設定

  1. Enemy ノードを選択した状態で、Inspector の Add ComponentCustomVisibilityOptimizer を選択して追加します。
  2. Inspector 上で、VisibilityOptimizer の各プロパティを次のように設定してみます。
  • Process Root:
    • 空欄のままで OK(デフォルトで Enemy 自身が対象になります)。
  • Include Children: true
    • 敵ノードの子階層にあるエフェクトやサブパーツもまとめて停止したい場合は true のまま。
  • Affect Rigid Body2D: true
    • もし EnemyRigidBody2D が付いているなら、画面外で物理計算を止めたいので true 推奨。
  • Affect Animation: true
    • アニメーションも画面外では止めたい場合は true。
  • Affect All Components: true
    • まずは true にして、すべてのコンポーネントを止める挙動を確認するのがおすすめです。
  • Log Debug: true
    • 最初は true にしておくと、コンソールに「Paused / Resumed」ログが出るので挙動が分かりやすいです。
  • Initially Pause When Hidden: true
    • シーン開始時にすでに画面外にいる敵などは、最初から処理停止にしておきたいので true のままで問題ありません。

4. シンプルな移動スクリプトで挙動確認(任意)

画面外に出ることを確認するために、簡単な移動スクリプトを併用すると分かりやすくなります。以下のようなスクリプトを Enemy に付けておきましょう。


import { _decorator, Component, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

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

    @property({ tooltip: '右方向への移動速度(単位: unit/秒)' })
    speed: number = 200;

    update(deltaTime: number) {
        const pos = this.node.position;
        this.node.setPosition(pos.x + this.speed * deltaTime, pos.y, pos.z);
    }
}

この EnemyMover は、VisibilityOptimizer によって enabled = false にされると update() が呼ばれなくなり、移動が止まります。

5. Game View での動作確認

  1. シーンを保存し、上部ツールバーの Play ボタンでプレビューを開始します。
  2. Enemy が右方向に移動し、画面の外に出ていく様子を確認します。
  3. 画面外に出た瞬間、コンソールに以下のようなログが出ていれば正常です。
    • [VisibilityOptimizer] Became HIDDEN. Paused processes.
    • [VisibilityOptimizer] Paused components. count = 3(数値は環境により異なります)
  4. その瞬間に Enemy の移動(EnemyMover.update)も止まり、もしアニメーションがあればそれも停止しているはずです。
  5. カメラを動かす、または Enemy の移動方向を反転させて再び画面内に戻ってくるようにすると、
    • [VisibilityOptimizer] Became VISIBLE. Resumed processes.
    • [VisibilityOptimizer] Resumed components. count = 3

    とログが出て、再び移動・アニメーションが再開されます。

6. よくあるハマりポイント

  • VisibleOnScreenNotifier2D を付け忘れている
    • エラー: [VisibilityOptimizer] VisibleOnScreenNotifier2D が同じノードに存在しません。
    • 対処: 対象ノードに Add Component → UI → VisibleOnScreenNotifier2D を必ず追加してください。
  • Process Root を別ノードにしたが、子階層を含めたいのに Include Children を false のままにしている
    • 結果: ルートノードに付いているスクリプトだけが止まり、子のスクリプトは動き続ける。
    • 対処: Include Children = true に設定してください。
  • RigidBody2D が止まらない / 止まってしまう
    • RigidBody2D の動作をコントロールしたい場合は Affect Rigid Body2D のチェック状態を確認してください。
  • 描画だけは残したいのに消えてしまう
    • Affect All Components が true だと、Sprite など描画系も含めて enabled = false にされることがあります。
    • 対処: Affect All Components = false にして、「自作スクリプトなどのロジック系コンポーネント」だけを止めるようにしてください。

まとめ

今回実装した VisibilityOptimizer は、VisibleOnScreenNotifier2D を利用して「画面外に出たら処理を止める」というパフォーマンス最適化を、アタッチするだけで実現する汎用コンポーネントです。

  • 他のカスタムスクリプトやシングルトンには一切依存せず、このスクリプト単体で完結。
  • どのノードを Process 対象にするか(processRoot)、子階層を含めるか(includeChildren)、物理やアニメーションまで止めるか(affectRigidBody2D, affectAnimation)、描画系まで含めるか(affectAllComponents)をインスペクタから柔軟に切り替え可能。
  • 敵キャラ・弾・ギミック・パーティクルなど、大量に生成されるオブジェクトに一括適用することで、画面外の無駄な update() や物理計算を削減し、フレームレートの安定に寄与します。

ゲーム規模が大きくなるほど、「見えていないものをどれだけサボらせるか」がパフォーマンスの鍵になります。
この VisibilityOptimizer をプロジェクトの標準コンポーネントとして用意しておけば、どんなオブジェクトでも「とりあえず付けておく」だけで画面外最適化を自動化でき、開発効率と実行効率の両方を高めることができます。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!