【Godot】ステルスゲームの視界システム - Area3D + RayCast3DでFOVを作る

作成: 2026-02-08最終更新: 2026-07-08

Area3Dで範囲を絞り、ドット積で視野角を判定し、RayCast3Dで遮蔽を確認する、ステルスゲームのFOV(視野)システムの実装方法を解説。3段階判定、複数レイキャスト、警戒レベルの段階管理まで、コードと図で学びます。

敵の正面に立てば見つかるが、背後からそっと近づけば気づかれない——ステルスゲームの緊張感は、この 「敵の視界」 の再現から生まれます。単純な距離判定だけだと、敵の真後ろにいても検知されてしまい、そもそもステルスが成立しません。

リアルな視界には「敵からの距離」「敵の視線方向との角度」「間に壁があるか」の3つが必要です。この記事では、Area3D(範囲)+ ドット積(角度)+ RayCast3D(遮蔽) の3段階で、前方だけを見るFOV(Field of View:視野)システムを組み立てます。

俯瞰視点。敵が前方へ扇形の視野を広げ、その外側の斜め後ろにプレイヤーがしゃがんで隠れているステルス視界のイメージ

この記事でわかること

  • 視界判定の 3段階構造(範囲 → 角度 → 遮蔽)
  • ドット積 で視野角を判定する仕組み
  • 基本の FOV検知 の実装(正面/背後/壁裏を見分ける)
  • 複数レイキャスト で「頭だけ見えている」も検知
  • 警戒レベル の段階管理(気づく→疑う→発見)

Sponsored

視界判定の3段階構造

「正面にいるけど壁越しだから見えない」「すぐ近くだけど背後だから気づかない」——こうした状況を正しく捌くために、視界判定は 3段階で、軽い順に 絞り込みます。

範囲(Area3D)→角度(dot product)→遮蔽(RayCast3D)の順に候補を絞り込む3段階フィルタの図
段階手法目的
1. 範囲Area3D(球状の検知範囲)近くにいるか
2. 角度ドット積(dot前方(視野角内)にいるか
3. 遮蔽RayCast3D壁などに遮られていないか

軽い順に並べる理由 は、コストの高い RayCast3D の回数を減らすためです。100体のNPCがいても、Area3D 内が3体なら、レイキャストは3回で済みます。まず安いチェックで大半をふるい落とすのが定石です。

ドット積で視野角を判定する

「前方だけを見る」を作る鍵が ドット積 です。これは 2つの方向がどれだけ同じ向きか を1つの数値で表す計算で、forward.dot(direction) のように書きます。

敵を中心に、正面が1.0、60度が0.5、真横が0.0、真後ろが-1.0となるドット積と角度の対応図。前方の扇形が視野内を表す
  • 1.0 :完全に同じ向き(正面・0度)
  • 0.5 :60度ずれている
  • 0.0 :直角(真横・90度)
  • -1.0 :正反対(真後ろ・180度)

視野角120度の敵なら、半角は60度。cos(60°) = 0.5 なので、 ドット積が0.5以上なら視野内、0.5未満なら視野外 と判定できます。角度そのものを計算するより、cos としきい値を1回比べるだけなので高速です。

Sponsored

基本のFOV検知を実装する

3段階を1つのスクリプトにまとめます。敵の子に検知用のノード(FOVDetector)を置き、Area3DRayCast3D を持たせます。

敵の視野扇形の中で、正面のプレイヤーは検知、背後のプ�レイヤーは非検知、壁裏のプレイヤーはレイが遮られて非検知となる俯瞰図
extends Node3D

@export var fov_angle: float = 120.0       # 視野角(度)
@export var detection_range: float = 15.0  # 検知距離

@onready var ray: RayCast3D = $RayCast3D

signal target_detected(target: Node3D)
signal target_lost(target: Node3D)

func _can_see_target(target: Node3D) -> bool:
    var to_target := target.global_position - global_position
    # ① 距離チェック(一番安い)
    if to_target.length() > detection_range:
        return false
    # ② 角度チェック(ドット積)
    var forward := -global_transform.basis.z.normalized()  # 3Dの前方は-Z
    var dot := forward.dot(to_target.normalized())
    if dot < cos(deg_to_rad(fov_angle / 2.0)):             # しきい値未満=視野外
        return false
    # ③ 遮蔽チェック(一番高い。ここまで来たものだけ)
    ray.target_position = to_target
    ray.force_raycast_update()                             # その場でレイを撃つ
    if ray.is_colliding():
        return ray.get_collider() == target               # 最初に当たったのが本人か
    return true

3段階を 軽い順に並べ、途中でダメなら即 return false するのがポイントです。forward-global_transform.basis.z——Godotの3Dでは Z軸マイナスが「前」 です。遮蔽チェックでは、レイが最初に当たった相手がターゲット本人なら「見えている」、壁など別のものなら「遮られている」と判断します。

検知結果は、Area3D の範囲内にいるターゲットに対して毎回この判定を行い、 状態が変わった瞬間だけ シグナルを飛ばすときれいです(見えた瞬間=target_detected、見失った瞬間=target_lost)。このシグナルを敵AI側で受け取って、追跡や警戒に使います。

ヒントRayCast3Dcollision_mask を、壁とプレイヤーのレイヤーだけに絞ります。装飾品などを含めると、遮蔽判定が誤作動します(レイヤーとマスク 参照)。

複数レイキャストで精度を上げる

レイ1本だと取りこぼしが出ます。 低い壁の陰にしゃがんだプレイヤー は、足元へのレイは壁に遮られますが、頭は見えている——このケースを1本では検知できません。

レイ1本は足元を狙うと壁で遮られ見逃すが、レイ3本(頭・胸・足)なら頭への1本が壁を越えて検知できる対比図

そこで、ターゲットの 頭・胸・足元 の3点へレイを飛ばし、 1本でも到達すれば検知 とします。角度チェックまでは同じで、遮蔽チェックだけを差し替えます。

# 遮蔽チェックを複数ポイントに(角度チェックまでは基本版と同じ)
var check_offsets := [
    Vector3(0, 1.7, 0),  # 頭
    Vector3(0, 1.0, 0),  # 胸
    Vector3(0, 0.1, 0),  # 足元
]
for offset in check_offsets:
    ray.target_position = (target.global_position + offset) - global_position
    ray.force_raycast_update()
    if not ray.is_colliding() or ray.get_collider() == target:
        return true      # 1箇所でも見えていればOK
return false             # 全ポイントが遮蔽されている

check_offsets のY値は、キャラの原点(多くは足元)からの高さです。モデルの原点が中心にある場合は値を調整します。何本飛ばすかは 2〜3本 が目安——多すぎると重く、少なすぎると精度が落ちます。

Sponsored

警戒レベルの段階管理

視界判定ができたら、それを どう使うか です。名作ステルスのように、見つかった瞬間に即ゲームオーバーではなく、 「?」から「!」へ段階的に警戒が上がる と、対処の猶予が生まれて緊張感が出ます。

疑念メーターが上がるにつれUNAWARE(気づかない)→SUSPICIOUS(?気配)→ALERT(!発見)と警戒段階が変わる図

3つの状態を、 疑念レベル(0〜100) の増減で管理します。見えている間は増え、見失うと徐々に減る。閾値に達したら ALERT です。

状態意味ゲームプレイ
UNAWARE気づいていない通常の巡回
SUSPICIOUS気配を感じた立ち止まって周囲を確認
ALERT発見!プレイヤーを追跡・攻撃
enum AlertState { UNAWARE, SUSPICIOUS, ALERT }

@export var suspicion_rate: float = 30.0    # 上昇速度(%/秒)
@export var suspicion_decay: float = 15.0   # 減少速度(%/秒)
@export var alert_threshold: float = 100.0

var suspicion := 0.0
var state := AlertState.UNAWARE
signal state_changed(new_state: AlertState)

func update_detection(is_visible: bool, delta: float) -> void:
    # 見えている間は増え、見失うと減る
    suspicion += (suspicion_rate if is_visible else -suspicion_decay) * delta
    suspicion = clampf(suspicion, 0.0, alert_threshold)

    var next := AlertState.UNAWARE
    if suspicion >= alert_threshold:
        next = AlertState.ALERT
    elif suspicion > 0.0:
        next = AlertState.SUSPICIOUS

    if next != state:              # 状態が変わった瞬間だけ通知
        state = next
        state_changed.emit(state)

state_changed が「SUSPICIOUS に変わった瞬間」だけ飛ぶので、そこで頭上に「?」を出す、といった演出が簡単に書けます。この段階管理は ステートマシンビヘイビアツリー と組み合わせると、巡回⇄警戒⇄追跡を自然に行き来する敵AIになります。追跡そのものは NavigationAgent2D/3D の担当です。

Sponsored

パフォーマンスとデバッグ

視界チェックは、敵が多いと負荷になります。仕上げに最適化とデバッグのコツを押さえます。

  • 検知は Timer で間引く :毎フレームでなく0.1〜0.2秒間隔で十分です。60FPSで毎フレーム60回のところ、0.15秒間隔なら約7回。体感精度はほぼ変わりません。
  • レイは2〜3本 に絞る:多いほど重くなります。
  • collision_mask を専用レイヤーに :壁とプレイヤーだけを対象にし、無関係な衝突を除外します。
  • Area3D は FOVより大きめ に:まず範囲で早期除外するのが3段階構造の要です。
  • 視野を可視化して調整 :視野角やレンジは、実際に 扇形(コーン)を描いて目で見る と一気に調整しやすくなります。@tool を付けたスクリプトと ImmediateMesh でエディタ上に視野メッシュを描いておき、リリース時に消すのが定番です。

おまけ:先に知っておくと良いこと

  • 音でも気づかせる :視界だけでなく、足音や物音で suspicion を上げると、より緊張感が出ます。「見る」と「聞く」を同じ疑念メーターに合流させると自然です。
  • 見失いに"間"を持たせるsuspicion_decay を小さめにすると、見失っても少しの間は警戒が続き、「隠れてやり過ごす」駆け引きが生まれます。
  • 2Dでも同じ発想 :2Dステルスなら Area2DRayCast2D + ドット積で、まったく同じ3段階が作れます。
  • AIの行動と接続するALERT になったら追う、UNAWARE に戻ったら巡回へ——判断は ステートマシン、移動は NavigationAgent2D と役割分担します。

まとめ

  • 3段階Area3D(範囲)→ ドット積(角度)→ RayCast3D(遮蔽)を、軽い順に絞り込む
  • ドット積forward.dot(direction)cos(半角) と比べ、視野角内かを高速判定
  • 複数レイキャスト(頭・胸・足)で「一部だけ見えている」も検知できる
  • 警戒レベル は疑念メーターの増減で段階管理し、即発見でなく緊張感を演出する
  • 最適化Timer 間引きとレイヤー設定が基本

まずは敵に Area3DRayCast3D を付け、正面のプレイヤーだけを検知するところから始めてみてください。背後や壁裏で見つからないことを確認できたら、ステルスの土台は完成です。そこに警戒レベルと追跡を足せば、ぐっと緊張感のある敵AIになります。

さらに学ぶために