Godot 4 で 2D ゲームを作っていると、敵や弾、エフェクトなど「画面外に出たらもう動かなくていいオブジェクト」が山ほど出てきますよね。
素直に作ると、各シーンに _process()_physics_process() が書かれていて、そこに「画面外なら return」みたいなガードを書くことになります。

でもこれ、

  • プレイヤー、敵、ギミックなど、すべてのスクリプトに似たような if not on_screen: return を書くことになる
  • 「画面外停止のロジック」があちこちに分散して、あとから挙動を変えたくなったときに修正漏れが出やすい
  • 継承でまとめようとすると「画面外停止用のベースクラス」を無理に挟むことになり、クラス階層が窮屈になる

…と、地味に面倒です。

そこで「継承で頑張る」のをやめて、「コンポーネントをポン付けするだけ」で解決してしまいましょう。
今回紹介する VisibilityOptimizer コンポーネントは、VisibleOnScreenNotifier2D を内部で使い、画面外に出たら自動で親ノードの処理を止めてくれる小さなユーティリティです。


【Godot 4】画面外はサボらせろ!「VisibilityOptimizer」コンポーネント

このコンポーネントを親ノード(プレイヤー、敵、弾など)にアタッチするだけで、

  • 画面に映っている間だけ _process() / _physics_process() を動かす
  • 画面外に出たら自動で処理を停止し、CPU を節約
  • 再び画面内に入ってきたら自動で処理を再開

といった挙動を、継承なしで「合成(コンポーネント)」として実現できます。


フルコード(VisibilityOptimizer.gd)


extends Node2D
class_name VisibilityOptimizer
## 画面内にいるときだけ親ノードの処理を有効化するコンポーネント。
## VisibleOnScreenNotifier2D を内部で使い、
## 画面外に出たタイミングで親の process / physics_process を止めます。

@export_category("VisibilityOptimizer Settings")

## 監視対象のノード。
## 通常は空のままでOK。その場合は自動的に get_parent() を対象にします。
@export var target_node: Node = null

## _process() を制御するかどうか。
## ほとんどの場合 ON で良いですが、物理処理だけ止めたい場合は OFF に。
@export var control_process: bool = true

## _physics_process() を制御するかどうか。
## 物理挙動を持つ CharacterBody2D / RigidBody2D などでは ON にしておきましょう。
@export var control_physics_process: bool = true

## 起動時に「画面外ならすぐ停止」するかどうか。
## スポーン直後だけは動かしたい場合などは false に。
@export var disable_on_start_if_offscreen: bool = true

## VisibleOnScreenNotifier2D の監視対象を、親ではなく「自分自身」にするかどうか。
## true: このコンポーネント(Node2D)の位置/サイズで可視判定
## false: target_node(通常は親)の AABB で可視判定(推奨)
@export var use_self_as_notifier_target: bool = false

## 画面外に出てから実際に停止するまでの遅延時間(秒)。
## 0 の場合は即停止。弾丸などは 0、敵AI などは 0.2〜0.5 くらいが扱いやすいです。
@export_range(0.0, 10.0, 0.05, "or_greater") var offscreen_delay: float = 0.0

## 画面内に入ったとき、すぐに処理を再開するかどうか。
## false にしておくと「一度止めたら手動で再開」させるような挙動も作れます。
@export var auto_enable_on_screen_enter: bool = true

## デバッグ用。画面外/画面内の状態をエディタの Output にログ出力します。
@export var debug_log: bool = false


# 内部用ノード
var _notifier: VisibleOnScreenNotifier2D
var _offscreen_timer: Timer
var _is_target_enabled: bool = true


func _ready() -> void:
    # 監視対象が未指定なら、親ノードを対象にする
    if target_node == null:
        target_node = get_parent()
        if debug_log:
            print("[VisibilityOptimizer] target_node is null. Using parent: ", target_node)

    if target_node == null:
        push_warning("VisibilityOptimizer: target_node が設定されておらず、親も存在しません。何も制御できません。")
        return

    # VisibleOnScreenNotifier2D を動的に追加(エディタで直に置いてもOKですが、ここでは自動生成)
    _setup_notifier()

    # 遅延停止用の Timer を用意
    _setup_timer()

    # 初期状態で画面外なら、設定に応じてすぐ停止
    if disable_on_start_if_offscreen and not _notifier.is_on_screen():
        if debug_log:
            print("[VisibilityOptimizer] Off-screen at start. Disabling immediately.")
        _set_target_processing(false)
        _is_target_enabled = false
    else:
        _is_target_enabled = true

    # 念のため、初期状態をログ
    if debug_log:
        print("[VisibilityOptimizer] Ready. on_screen =", _notifier.is_on_screen(),
              " target =", target_node)


func _setup_notifier() -> void:
    _notifier = VisibleOnScreenNotifier2D.new()
    add_child(_notifier)
    _notifier.owner = self.owner  # シーン保存時に一緒に保存されるように

    # use_self_as_notifier_target が false の場合、
    # 通常は「親(= target_node)」の見た目で可視判定させたいので、
    # 親の位置に合わせる(スプライトなどが親にぶら下がっている前提)。
    if not use_self_as_notifier_target and target_node is Node2D:
        # このコンポーネントの位置を target_node に追従させる
        global_position = (target_node as Node2D).global_position

    # シグナル接続
    _notifier.screen_entered.connect(_on_screen_entered)
    _notifier.screen_exited.connect(_on_screen_exited)


func _setup_timer() -> void:
    _offscreen_timer = Timer.new()
    _offscreen_timer.one_shot = true
    _offscreen_timer.autostart = false
    add_child(_offscreen_timer)
    _offscreen_timer.timeout.connect(_on_offscreen_timeout)


func _on_screen_entered() -> void:
    if debug_log:
        print("[VisibilityOptimizer] Screen entered. target =", target_node)

    # 画面内に入ったので、オフスクリーン用タイマーはキャンセル
    if _offscreen_timer.is_stopped() == false:
        _offscreen_timer.stop()

    # 自動で再開する設定なら、ここで有効化
    if auto_enable_on_screen_enter:
        _set_target_processing(true)
        _is_target_enabled = true


func _on_screen_exited() -> void:
    if debug_log:
        print("[VisibilityOptimizer] Screen exited. target =", target_node)

    if offscreen_delay <= 0.0:
        # 即停止
        _set_target_processing(false)
        _is_target_enabled = false
    else:
        # 遅延停止
        _offscreen_timer.start(offscreen_delay)


func _on_offscreen_timeout() -> void:
    # タイマーが鳴った時点でまだ画面外なら停止
    if not _notifier.is_on_screen():
        if debug_log:
            print("[VisibilityOptimizer] Off-screen timeout. Disabling target.")
        _set_target_processing(false)
        _is_target_enabled = false


func _set_target_processing(enable: bool) -> void:
    if target_node == null:
        return

    # 対象ノードが Node であることを前提として process を制御
    if control_process and "set_process" in target_node:
        target_node.set_process(enable)

    if control_physics_process and "set_physics_process" in target_node:
        target_node.set_physics_process(enable)

    if debug_log:
        print("[VisibilityOptimizer] set_process =", enable,
              " set_physics_process =", enable,
              " for target =", target_node)


## 外部から明示的に有効化/無効化したい場合の API。
## たとえば「ステージ開始時は全停止しておき、カメラが近づいたら手動で有効化」などに使えます。
func set_enabled(enable: bool) -> void:
    _set_target_processing(enable)
    _is_target_enabled = enable
    if enable:
        # 再開時はオフスクリーンタイマーは不要
        if _offscreen_timer and not _offscreen_timer.is_stopped():
            _offscreen_timer.stop()


## 現在このコンポーネントが「対象を有効とみなしているか」を返す。
func is_enabled() -> bool:
    return _is_target_enabled

使い方の手順

ここでは、代表的な 3 パターンを例にします。

  • ① プレイヤー(CharacterBody2D)
  • ② 敵(CharacterBody2D)
  • ③ 画面外で止まっていてほしい「動く床」(Node2D)

手順①: スクリプトをプロジェクトに追加

  1. res://scripts/components/VisibilityOptimizer.gd のようなパスで新規スクリプトを作成。
  2. 上記のコードをコピペして保存します。
  3. Godot エディタを開き直すか、スクリプトを保存すると、
    「VisibilityOptimizer」 がノード追加ダイアログに出てくるようになります(class_name のおかげ)。

手順②: プレイヤーにアタッチしてみる

まずはプレイヤーの例から。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── VisibilityOptimizer (Node2D)
  1. プレイヤーシーンを開きます。
  2. Player ノードを選択し、「子ノードを追加」→ 検索欄に VisibilityOptimizer と入力して追加。
  3. インスペクタで以下のように設定します:
    • target_node: 空のままで OK(自動で親 = Player を対象にします)
    • control_process: On(プレイヤーの _process() を止めたい場合)
    • control_physics_process: On(移動ロジックが _physics_process() の場合はこちらも On)
    • offscreen_delay: とりあえず 0.0(画面外に出たら即停止)
    • debug_log: 挙動を確認したい間だけ On

これで、カメラからプレイヤーが外れたタイミングで、
Player_process() / _physics_process() が自動で止まるようになります。
マルチプレイヤーや巨大マップなど、「画面外のプレイヤーは一時停止したい」ケースで便利ですね。

手順③: 敵キャラにアタッチ(大量に出るオブジェクト向け)

次に敵キャラ。大量にインスタンスされる敵ほど、画面外で止める恩恵が大きいです。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── VisibilityOptimizer (Node2D)
  1. 敵シーン(Enemy.tscn)を開き、上記のように VisibilityOptimizer を子として追加します。
  2. 設定例:
    • offscreen_delay: 0.3 秒程度(画面から少し外れてもすぐには止めない)
    • auto_enable_on_screen_enter: On(カメラが追いついたら自動で再開)
    • disable_on_start_if_offscreen: On(最初から画面外でスポーンしている場合は即停止)

例えばステージ全体に敵をばらまいておいても、実際に処理が走るのはカメラに写っている敵だけになるので、
シーンの数が増えてもフレームレートが安定しやすくなります。

手順④: 動く床やギミックにアタッチ

最後に「動く床」の例。

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── VisibilityOptimizer (Node2D)

MovingPlatform.gd(例):


extends Node2D

@export var speed: float = 80.0
@export var move_distance: float = 128.0

var _origin: Vector2
var _dir: int = 1

func _ready() -> void:
    _origin = global_position

func _physics_process(delta: float) -> void:
    var offset := global_position - _origin
    if abs(offset.x) >= move_distance:
        _dir *= -1
    global_position.x += speed * _dir * delta

このシーンに VisibilityOptimizer をつけておけば、
画面外にある動く床やギミックの _physics_process() が止まり、無駄な計算を抑えられます。


メリットと応用

このコンポーネントを使うメリットは、単に「画面外で止まる」だけではありません。

  • 継承に縛られない
    「画面外停止用ベースクラス」を作る必要がなく、既存のプレイヤー / 敵 / ギミックにそのまま後付けできます。
  • ノード階層を増やさず、責務を分離できる
    各キャラクターのスクリプトは「動き方」だけに集中させ、
    「いつ動くか(画面外で止めるか)」は VisibilityOptimizer に丸投げできます。
  • レベルデザインが楽になる
    ステージ全体に敵やギミックを好きなだけ配置しても、
    実際に動くのはカメラに映っているものだけなので、パフォーマンスを気にせずレベルを作りやすくなります。
  • 挙動変更が一箇所で済む
    「やっぱり画面外で 0.5 秒待ってから止めたいな…」となったら、
    コンポーネント側のロジックか、各インスタンスの offscreen_delay をいじるだけで済みます。
    各キャラクターの _process() に散らばった if not on_screen: return を探して書き換える必要はありません。

つまり、「深い継承ツリー」や「複雑なノード階層」にロジックを埋め込むのではなく、
小さなコンポーネントを組み合わせて挙動を作る方向に寄せられるわけですね。

改造案:画面外に出たら自動でキュー削除するバージョン

「止めるだけじゃなくて、画面外に出た弾や一部の敵は消してしまいたい」というケースも多いです。
そんなときは、以下のような関数を追加してみましょう。


## 画面外に出たら target_node を queue_free() する簡易モード。
## 弾丸など「一度画面外に出たら用済み」のオブジェクトに便利です。
@export var auto_free_on_offscreen: bool = false

func _on_offscreen_timeout() -> void:
    if not _notifier.is_on_screen():
        if auto_free_on_offscreen and is_instance_valid(target_node):
            if debug_log:
                print("[VisibilityOptimizer] Off-screen. Auto freeing target:", target_node)
            target_node.queue_free()
        else:
            if debug_log:
                print("[VisibilityOptimizer] Off-screen timeout. Disabling target.")
            _set_target_processing(false)
            _is_target_enabled = false

このように、小さなコンポーネントをベースにして、
「弾丸用」「敵用」「ギミック用」といったバリエーションを生やしていくのがコンポーネント指向の醍醐味ですね。
継承ツリーをいじらずに、ノードにペタペタ貼っていくだけで挙動が増やせるようにしていきましょう。