敵の正面に立てば見つかるが、背後からそっと近づけば気づかれない——ステルスゲームの緊張感は、この 「敵の視界」 の再現から生まれます。単純な距離判定だけだと、敵の真後ろにいても検知されてしまい、そもそもステルスが成立しません。
リアルな視界には「敵からの距離」「敵の視線方向との角度」「間に壁があるか」の3つが必要です。この記事では、Area3D(範囲)+ ドット積(角度)+ RayCast3D(遮蔽) の3段階で、前方だけを見るFOV(Field of View:視野)システムを組み立てます。
この記事でわかること
- 視界判定の 3段階構造(範囲 → 角度 → 遮蔽)
- ドット積 で視野角を判定する仕組み
- 基本の FOV検知 の実装(正面/背後/壁裏を見分ける)
- 複数レイキャスト で「頭だけ見えている」も検知
- 警戒レベル の段階管理(気づく→疑う→発見)
視界判定の3段階構造
「正面にいるけど壁越しだから見えない」「すぐ近くだけど背後だから気づかない」——こうした状況を正しく捌くために、視界判定は 3段階で、軽い順に 絞り込みます。

| 段階 | 手法 | 目的 |
|---|---|---|
| 1. 範囲 | Area3D(球状の検知範囲) | 近くにいるか |
| 2. 角度 | ドット積(dot) | 前方(視野角内)にいるか |
| 3. 遮蔽 | RayCast3D | 壁などに遮られていないか |
軽い順に並べる理由 は、コストの高い RayCast3D の 回数を減らすためです。100体のNPCがいても、Area3D 内が3体なら、レイキャストは3回で済みます。まず安いチェックで大半をふるい落とすのが定石です。
ドット積で視野角を判定する
「前方だけを見る」を作る鍵が ドット積 です。これは 2つの方向がどれだけ同じ向きか を1つの数値で表す計算で、forward.dot(direction) のように書きます。

- 1.0 :完全に同じ向き(正面・0度)
- 0.5 :60度ずれている
- 0.0 :直角(真横・90度)
- -1.0 :正反対(真後ろ・180度)
視野角120度の敵なら、半角は60度。cos(60°) = 0.5 なので、 ドット積が0.5以上なら視野内、0.5未満なら視野外 と判定できます。角度そのものを計算するより、cos としきい値を1回比べるだけなので高速です。
基本のFOV検知を実装する
3段階を1つのスクリプトにまとめます。敵の子に検知用のノード(FOVDetector)を置き、Area3D と RayCast3D を持たせます。

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側で受け取って、追跡や警戒に使います。
ヒント:
RayCast3Dのcollision_maskを、壁とプレイヤーのレイヤーだけに絞ります。装飾品などを含めると、遮蔽判定が誤作動します(レイヤーとマスク 参照)。
複数レイキャストで精度を上げる
レイ1本だと取りこぼしが出ます。 低い壁の陰にしゃがんだプレイヤー は、足元へのレイは壁に遮られますが、頭は見えている——このケースを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本 が目安——多すぎると重く、少なすぎると精度が落ちます。
警戒レベルの段階管理
視界判定ができたら、それを どう使うか です。名作ステルスのように、見つかった瞬間に即ゲームオーバーではなく、 「?」から「!」へ段階的に警戒が上がる と、対処の猶予が生まれて緊張感が出ます。

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 の担当です。
パフォーマンスとデバッグ
視界チェックは、敵が多いと負荷になります。仕上げに最適化とデバッグのコツを押さえます。
- 検知は
Timerで間引く :毎フレームでなく0.1〜0.2秒間隔で十分です。60FPSで毎フレーム60回のところ、0.15秒間隔なら約7回。体感精度はほぼ変わりません。 - レイは2〜3本 に絞る:多いほど重くなります。
collision_maskを専用レイヤーに :壁とプレイヤーだけを対象にし、無関係な衝突を除外します。Area3Dは FOVより大きめ に:まず範囲で早期除外するのが3段階構造の要です。- 視野を可視化して調整 :視野角やレンジは、実際に 扇形(コーン)を描いて目で見る と一気に調整しやすくなります。
@toolを付けたスクリプトとImmediateMeshでエディタ上に視野メッシュを描いておき、リリース時に消すのが定番です。
おまけ:先に知っておくと良いこと
- 音でも気づかせる :視界だけでなく、足音や物音で
suspicionを上げると、より緊張感が出ます。「見る」と「聞く」を同じ疑念メーターに合流させると自然です。 - 見失いに"間"を持たせる :
suspicion_decayを小さめにすると、見失っても少しの間は警戒が続き、「隠れてやり過ごす」駆け引きが生まれます。 - 2Dでも同じ発想 :2Dステルスなら
Area2D+RayCast2D+ ドット積で、まったく同じ3段階が作れます。 - AIの行動と接続する :
ALERTになったら追う、UNAWAREに戻ったら巡回へ——判断は ステートマシン、移動は NavigationAgent2D と役割分担します。
まとめ
- 3段階 :
Area3D(範囲)→ ドット積(角度)→RayCast3D(遮蔽)を、軽い順に絞り込む - ドット積 で
forward.dot(direction)をcos(半角)と比べ、視野角内かを高速判定 - 複数レイキャスト(頭・胸・足)で「一部だけ見えている」も検知できる
- 警戒レベル は疑念メーターの増減で段階管理し、即発見でなく緊張感を演出する
- 最適化 は
Timer間引きとレイヤー設定が基本
まずは敵に Area3D と RayCast3D を付け、正面のプレイヤーだけを検知するところから始めてみてください。背後や壁裏で見つからないことを確認できたら、ステルスの土台は完成です。そこに警戒レベルと追跡を足せば、ぐっと緊張感のある敵AIになります。
さらに学ぶために
- ステートマシンで管理するAIとプレイヤーの状態 ——巡回⇄警戒⇄追跡の状態遷移
- ビヘイビアツリーでAIを設計する ——より複雑なAIの判断を組む
- NavigationAgent2Dによるパスファインディング ——発見したプレイヤーを追跡する
- Collision LayersとMasksでオブジェクトを整理する ——視線チェックの対象レイヤー設定
- Godot公式ドキュメント:RayCast3D ——遮蔽チェックの一次情報