Unityを触り始めた頃によくあるのが、Update() にゲームロジックを全部書いてしまうパターンですね。
プレイヤーの移動、敵AI、アニメーション制御、エフェクト更新……すべてが1つの巨大スクリプトに詰め込まれていくと、だんだん何がどこで動いているのか分からなくなります。

さらに、画面外にいる敵やオブジェクトまでずっと Update() を回し続けていると、処理負荷も地味に積もっていきます。
「見えていないなら止めたいけど、どうやってキレイに止めるの?」というところで、また巨大な if 文が増えてしまいがちです。

そこでこの記事では、「画面に映っている間だけ動かす」 という責務だけを持った小さなコンポーネント
VisibilityOptimizer を作って、VisibleOnScreenNotifier2D と組み合わせて
「画面外に出たら親の処理(Process)を停止する」仕組みをコンポーネント指向で実現してみましょう。

【Unity】見えているときだけ動かす!「VisibilityOptimizer」コンポーネント

今回の設計のポイントは次の2つです。

  • 処理の中身(何をするか)は別コンポーネントに任せる
  • 「見えているかどうか」の情報だけを元に、処理コンポーネントをON/OFFする

つまり、VisibilityOptimizer は「見えているかどうかを監視して、
親オブジェクトにある Process コンポーネントを有効/無効にする」だけのコンポーネントです。
このおかげで、敵の行動ロジックや動く床の制御ロジックはそれぞれシンプルな Process コンポーネントとして分離できます。

前提:Process 側のインターフェース

この記事では、「親の処理(Process)」を次のようなシンプルなコンポーネントとして定義します。

  • MonoBehaviour を継承した任意のスクリプト
  • 有効化されている間だけ Update()FixedUpdate() などが動く

例えば、敵の移動処理ならこんな感じです。

// これは「処理の中身」側の例:敵を左右に動かすだけのコンポーネント
using UnityEngine;

public class EnemyProcess : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 2f;
    [SerializeField] private float moveRange = 3f;

    private Vector3 _startPosition;

    private void Awake()
    {
        _startPosition = transform.position;
    }

    private void Update()
    {
        // sin波で左右に動く簡単な処理
        float offset = Mathf.Sin(Time.time * moveSpeed) * moveRange;
        transform.position = _startPosition + new Vector3(offset, 0f, 0f);
    }
}

この EnemyProcess は、enabled = false にされると Update() が呼ばれなくなります。
VisibilityOptimizer は、この enabled のON/OFFだけを担当します。


VisibilityOptimizer 本体コード

それでは本題の VisibilityOptimizer コンポーネントのフルコードです。

using UnityEngine;
using UnityEngine.Rendering.Universal; // VisibleOnScreenNotifier2D が含まれている名前空間

/// <summary>
/// 画面に映っている間だけ、指定したコンポーネント(Process)を有効にする最適化コンポーネント。
/// VisibleOnScreenNotifier2D のイベントを利用して、
/// 画面外に出たら自動で Process を停止します。
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(VisibleOnScreenNotifier2D))]
public class VisibilityOptimizer : MonoBehaviour
{
    // 画面内/外の判定を行うコンポーネント(必須)
    [SerializeField]
    private VisibleOnScreenNotifier2D visibleNotifier2D;

    // 有効/無効を切り替えたい対象コンポーネント(親の処理 Process)
    // 例:EnemyProcess, MovingPlatformProcess など
    [SerializeField]
    private MonoBehaviour targetProcess;

    // 初期状態で画面外にいても、起動時だけは有効化しておきたい場合に使う
    [SerializeField]
    private bool enableProcessOnStart = false;

    // デバッグ用:状態変化をログに出すかどうか
    [SerializeField]
    private bool showDebugLog = false;

    // キャッシュ用フラグ:現在 Process が有効かどうか
    private bool _isProcessEnabled;

    private void Reset()
    {
        // コンポーネントがアタッチされたときに自動で VisibleOnScreenNotifier2D をセット
        visibleNotifier2D = GetComponent<VisibleOnScreenNotifier2D>();

        // 親または自分にある MonoBehaviour を自動で探して、最初に見つかったものを targetProcess に設定
        // (もちろん、後からインスペクタで差し替え可能)
        if (targetProcess == null)
        {
            // 自分自身以外のコンポーネントから探す
            MonoBehaviour[] candidates = GetComponentsInParent<MonoBehaviour>(includeInactive: true);
            foreach (var mb in candidates)
            {
                if (mb == this) continue;
                if (mb is VisibleOnScreenNotifier2D) continue;

                targetProcess = mb;
                break;
            }
        }
    }

    private void Awake()
    {
        // インスペクタでセットされていなければ、自動取得を試みる
        if (visibleNotifier2D == null)
        {
            visibleNotifier2D = GetComponent<VisibleOnScreenNotifier2D>();
        }

        if (visibleNotifier2D == null)
        {
            Debug.LogError(
                $"[VisibilityOptimizer] VisibleOnScreenNotifier2D が見つかりません。ゲームオブジェクト: {name}",
                this
            );
        }

        if (targetProcess == null)
        {
            Debug.LogWarning(
                $"[VisibilityOptimizer] targetProcess が設定されていません。画面内/外の制御対象がありません。ゲームオブジェクト: {name}",
                this
            );
        }
    }

    private void OnEnable()
    {
        // VisibleOnScreenNotifier2D のイベントに登録
        if (visibleNotifier2D != null)
        {
            visibleNotifier2D.onBecameVisible += HandleBecameVisible;
            visibleNotifier2D.onBecameInvisible += HandleBecameInvisible;
        }

        // 起動時の Process 状態をセット
        InitializeProcessState();
    }

    private void OnDisable()
    {
        // イベント登録解除(メモリリーク・多重登録防止)
        if (visibleNotifier2D != null)
        {
            visibleNotifier2D.onBecameVisible -= HandleBecameVisible;
            visibleNotifier2D.onBecameInvisible -= HandleBecameInvisible;
        }
    }

    /// <summary>
    /// 起動時の Process の状態を初期化。
    /// enableProcessOnStart が true なら一度だけ有効化し、
    /// そうでなければ現在の可視状態に合わせる。
    /// </summary>
    private void InitializeProcessState()
    {
        if (targetProcess == null) return;

        if (enableProcessOnStart)
        {
            SetProcessEnabled(true, reason: "Start");
            return;
        }

        if (visibleNotifier2D != null)
        {
            bool isVisible = visibleNotifier2D.isVisible;
            SetProcessEnabled(isVisible, reason: "Initialize (by current visibility)");
        }
        else
        {
            // Notifier が無い場合は安全側として有効化しておく
            SetProcessEnabled(true, reason: "Initialize (no notifier)");
        }
    }

    /// <summary>
    /// 画面内に入ったときに呼ばれるハンドラ。
    /// </summary>
    private void HandleBecameVisible(VisibleOnScreenNotifier2D notifier)
    {
        SetProcessEnabled(true, reason: "OnBecameVisible");
    }

    /// <summary>
    /// 画面外に出たときに呼ばれるハンドラ。
    /// </summary>
    private void HandleBecameInvisible(VisibleOnScreenNotifier2D notifier)
    {
        SetProcessEnabled(false, reason: "OnBecameInvisible");
    }

    /// <summary>
    /// Process の有効/無効を切り替える共通関数。
    /// すでに同じ状態なら何もしない。
    /// </summary>
    private void SetProcessEnabled(bool enabled, string reason)
    {
        if (targetProcess == null) return;

        if (_isProcessEnabled == enabled)
        {
            // 状態が変わっていなければ何もしない
            return;
        }

        _isProcessEnabled = enabled;
        targetProcess.enabled = enabled;

        if (showDebugLog)
        {
            Debug.Log(
                $"[VisibilityOptimizer] Process: {targetProcess.GetType().Name} を " +
                $"{(enabled ? "有効化" : "無効化")} ({reason}) - GameObject: {name}",
                this
            );
        }
    }
}

このコンポーネント自体はかなり小さいですが、責務は明確です。

  • 何をするか:VisibleOnScreenNotifier2D のイベントを受けて、targetProcess.enabled を切り替える
  • 何をしないか:移動、攻撃、アニメーションなどのゲームロジックは一切持たない

これにより、「見えているときだけ動かす」 という関心事を1つのコンポーネントに閉じ込められます。


使い方の手順

ここからは、実際の利用シーンを例に手順を見ていきましょう。
例として「2Dアクションゲームの敵キャラ」を使いますが、動く床やギミックにも同じ手順で使えます。

手順①:Process 側のコンポーネントを用意する

まずは「親の処理(Process)」を表すコンポーネントを作ります。
敵キャラがプレイヤーに向かって歩いてくるだけの簡単な例です。

using UnityEngine;

/// <summary>
/// 画面に映っている間だけ動かしたい「処理の中身」の例。
/// プレイヤーの方向に向かって歩き続ける単純な敵AI。
/// </summary>
public class SimpleChaseEnemyProcess : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 2f;
    [SerializeField] private Transform player;

    private void Update()
    {
        if (player == null) return;

        // プレイヤーの方向へ移動
        Vector3 direction = (player.position - transform.position).normalized;
        transform.position += direction * (moveSpeed * Time.deltaTime);

        // 向きの調整(2D想定で左右反転)
        if (direction.x != 0f)
        {
            Vector3 scale = transform.localScale;
            scale.x = Mathf.Sign(direction.x) * Mathf.Abs(scale.x);
            transform.localScale = scale;
        }
    }
}

ポイントは、何も特別なインターフェースを実装していない普通の MonoBehaviour であることです。
VisibilityOptimizerMonoBehaviour を受け取って enabled を切り替えるだけなので、既存のスクリプトにもそのまま適用できます。

手順②:VisibleOnScreenNotifier2D をアタッチする

次に、敵キャラの GameObject(例:Enemy プレハブ)に VisibleOnScreenNotifier2D を追加します。

  1. Hierarchy で敵オブジェクトを選択
  2. Inspector の「Add Component」から VisibleOnScreenNotifier2D を検索して追加
  3. 2D Renderer(Universal Render Pipeline 2D)環境で動かしていることを確認

VisibleOnScreenNotifier2D は、カメラに映った/映らなくなったタイミングで
onBecameVisible / onBecameInvisible のイベントを発火してくれるコンポーネントです。
これを VisibilityOptimizer が購読します。

手順③:VisibilityOptimizer をアタッチし、Process を指定する

  1. 同じ敵オブジェクトに VisibilityOptimizer を追加
  2. Inspector 設定
    • Visible Notifier 2D:自動で自分の VisibleOnScreenNotifier2D が入るはずです
    • Target ProcessSimpleChaseEnemyProcess(または動かしたい任意のスクリプト)をドラッグ&ドロップ
    • Enable Process On Start:起動直後から動かしたいなら ON、画面に入るまで完全停止でいいなら OFF
    • Show Debug Log:挙動確認したいときだけ ON にしておくと、状態切り替えが Console に出ます

これで、ゲーム再生時に「敵がカメラに映った瞬間に Process が有効化され、画面外に出たら自動で停止する」ようになります。

手順④:他のオブジェクトにも再利用する(動く床やギミック)

同じ仕組みは、動く床やトラップなどのギミックにもそのまま使えます。
例えば、一定距離を往復する動く床を MovingPlatformProcess として作っておき、

  • 床のオブジェクトに VisibleOnScreenNotifier2D を追加
  • 同じオブジェクトに VisibilityOptimizer を追加し、Target ProcessMovingPlatformProcess を指定

とするだけで、画面外の動く床の Update() を止めることができます。
レベル全体に何十個も動く床があっても、画面に映っているものだけ処理されるのでパフォーマンス的にも安心ですね。


メリットと応用

VisibilityOptimizer を導入することで、次のようなメリットがあります。

1. プレハブの責務がハッキリする

敵プレハブを見たときに、

  • SimpleChaseEnemyProcess:敵の行動ロジック
  • VisibilityOptimizer:見えているときだけ動かす最適化ロジック
  • VisibleOnScreenNotifier2D:可視状態の検知

というように、役割がコンポーネント単位ではっきり分かれます。
巨大な EnemyController.cs 1本に全部詰め込むより、どこを触れば何が変わるか が分かりやすくなります。

2. レベルデザイン時に「重さ」を気にしなくてよくなる

ステージの端から端まで敵やギミックを並べても、
「画面外にいる間は止まっている」ので、Update の数が爆発的に増えにくくなります。
レベルデザイナーは「ここに敵を置いたら面白いか?」に集中できて、
エンジニアは「見えているものだけ動かす仕組み」があるのでパフォーマンス面の不安を減らせます。

3. 既存のスクリプトにも簡単に後付けできる

VisibilityOptimizerMonoBehaviourenabled を切り替えるだけなので、
既にある EnemyAIMovingPlatform スクリプトにも、
Inspector から Target Process に設定するだけで適用できます。
コードを書き換える必要がないのは大きな利点ですね。

4. 応用:距離ベースの最適化や LOD と組み合わせる

画面内/外だけでなく、「プレイヤーから一定距離以上離れたら止める」といった
距離ベースの最適化と組み合わせると、さらに効率的にできます。
その場合は、VisibilityOptimizer のような「ON/OFF 管理コンポーネント」を別途用意して、
条件を増やしていくと良いですね。


改造案:フェードアウトしてから停止する

単に処理を止めるだけでなく、画面外に出るときにフェードアウトしてから停止したいというケースもあります。
その場合は、VisibilityOptimizer に次のようなメソッドを追加してみましょう。

/// <summary>
/// 画面外に出たタイミングで、一定時間かけてフェードアウトしてから Process を停止する改造例。
/// SpriteRenderer を前提としているので、必要に応じて MeshRenderer などに置き換えてください。
/// </summary>
private void FadeOutAndDisableProcess(float duration)
{
    if (targetProcess == null) return;

    SpriteRenderer sprite = GetComponentInParent<SpriteRenderer>();
    if (sprite == null)
    {
        // SpriteRenderer が無ければ即停止
        SetProcessEnabled(false, reason: "FadeOut (no sprite)");
        return;
    }

    // コルーチンでフェードアウトを実装
    StartCoroutine(FadeOutCoroutine(sprite, duration));
}

private System.Collections.IEnumerator FadeOutCoroutine(SpriteRenderer sprite, float duration)
{
    float time = 0f;
    Color startColor = sprite.color;

    while (time < duration)
    {
        time += Time.deltaTime;
        float t = Mathf.Clamp01(time / duration);

        // アルファ値だけを 1 → 0 に補間
        Color c = startColor;
        c.a = Mathf.Lerp(1f, 0f, t);
        sprite.color = c;

        yield return null;
    }

    // 完全に透明にしたあと、Process を停止
    SetProcessEnabled(false, reason: "FadeOut completed");
}

このように、「見えている間だけ動かす」という責務を1つのコンポーネントに切り出しておくと、
後から「フェードさせたい」「消える前にSEを鳴らしたい」などの要件が出てきても、
VisibilityOptimizer 周りだけを拡張すれば済むようになります。

巨大な God クラスを作るのではなく、小さなコンポーネントを組み合わせてゲームの挙動を作っていくと、
保守性も再利用性もぐっと上がります。ぜひ、自分のプロジェクトでも VisibilityOptimizer 的なコンポーネントを取り入れてみてください。