Godot 4で3Dゲームを作っていると、「音が壁をすり抜けて聞こえる問題」に一度はぶつかりますよね。
素直にやろうとすると…
- 各サウンドごとに
RayCast3Dを仕込む - プレイヤーとの距離や遮蔽物を毎フレーム判定する
- 遮蔽物があるときだけ音量を下げるロジックを書く
…といった処理を、プレイヤーや敵、オブジェクトごとに書き散らかすことになりがちです。
さらに、「プレイヤー用のベースクラス」や「敵用のベースクラス」にロジックを詰め込むと、継承ツリーがどんどん太っていきます。
そこで今回は、「音の遮蔽」だけを担当するコンポーネントとして切り出した、
AudioOcclusion コンポーネントを紹介します。
任意の AudioStreamPlayer3D にポン付けするだけで、プレイヤーとの間に壁があるときに自動で音量を下げてくれる仕組みです。
【Godot 4】壁越しの音をいい感じに!「AudioOcclusion」コンポーネント
このコンポーネントはざっくり言うと、
- プレイヤー位置を監視
- 音源 → プレイヤー 方向に
RayCast3Dを飛ばす - 間に遮蔽物があれば音量を 減衰率 に応じて下げる
- 遮蔽物がなくなれば元の音量に戻す
ということを毎フレームやってくれる「音の遮蔽マネージャ」です。
音源側(敵、環境音、機械音など)にコンポーネントとしてアタッチするだけで動きます。
フルコード: AudioOcclusion.gd
extends Node3D
class_name AudioOcclusion
## AudioStreamPlayer3D 用の「遮蔽音」コンポーネント
##
## ・このノードを AudioStreamPlayer3D と同じシーンにアタッチ
## ・RayCast3D を内部で生成して、プレイヤーとの間の遮蔽物を検知
## ・遮蔽物があるときだけ音量を下げる(またはミュートに近づける)
@export var player_path: NodePath
## プレイヤー(リスナー)のノードパス。
## 通常は CharacterBody3D や Player ルートを指定します。
## 空のままでも動きますが、その場合は自動で "Player" という名前のノードを探します。
@export var audio_player_path: NodePath
## 対象の AudioStreamPlayer3D のノードパス。
## 空の場合は、親ノードから AudioStreamPlayer3D を自動検索します。
@export_range(0.0, 1.0, 0.01)
var occluded_volume_factor: float = 0.3
## 遮蔽されているときの音量係数。
## 1.0 = 音量そのまま、0.0 = 完全に無音。
## 例: 0.3 にすると、壁越しの音は 30% の音量になります。
@export_range(0.0, 1.0, 0.05)
var smoothing_speed: float = 0.2
## 音量の補間速度。
## 0.0 に近いほどゆっくり変化し、1.0 に近いほど即座に変化します。
@export var collision_mask: int = 1
## RayCast3D がチェックするコリジョンレイヤーのマスク。
## 「壁」「床」「障害物」などをまとめたレイヤーを指定しましょう。
@export var min_distance_to_check: float = 0.5
## プレイヤーが音源にかなり近い場合は、遮蔽チェックをスキップするための距離。
## 0 にすると常に RayCast を飛ばします。
@export var debug_draw: bool = false
## true にすると、RayCast の線を簡易的に描画してデバッグできます。
# 内部用
var _player: Node3D
var _audio_player: AudioStreamPlayer3D
var _ray: RayCast3D
var _base_volume_db: float = 0.0
var _current_factor: float = 1.0
func _ready() -> void:
_resolve_player()
_resolve_audio_player()
_setup_ray()
_init_volume()
func _process(delta: float) -> void:
if not is_instance_valid(_player) or not is_instance_valid(_audio_player):
return
# プレイヤーと音源の距離
var source_global: Vector3 = global_transform.origin
var player_global: Vector3 = _player.global_transform.origin
var distance: float = source_global.distance_to(player_global)
var target_factor: float = 1.0
# 近すぎるときは遮蔽を無視(直接耳元で鳴っているイメージ)
if distance >= min_distance_to_check:
# RayCast を更新
_update_ray(source_global, player_global)
_ray.force_raycast_update()
if _ray.is_colliding():
# 何かに遮られているので、occluded_volume_factor まで下げる
target_factor = occluded_volume_factor
# スムーズに補間
_current_factor = lerp(_current_factor, target_factor, smoothing_speed)
_apply_volume()
if debug_draw:
_debug_draw_ray(source_global, player_global, _ray.is_colliding())
# --- 初期化系 -------------------------------------------------------------
func _resolve_player() -> void:
if player_path != NodePath():
_player = get_node_or_null(player_path)
else:
# 自動で "Player" という名前の Node3D を探す簡易版
_player = get_tree().get_root().find_child("Player", true, false)
if _player == null:
push_warning("[AudioOcclusion] Player ノードが見つかりませんでした。player_path を設定してください。")
if _player == null:
push_warning("[AudioOcclusion] 有効なプレイヤーノードが取得できませんでした。遮蔽判定は行われません。")
func _resolve_audio_player() -> void:
if audio_player_path != NodePath():
_audio_player = get_node_or_null(audio_player_path) as AudioStreamPlayer3D
else:
# 親ノードから AudioStreamPlayer3D を探す
_audio_player = get_parent() as AudioStreamPlayer3D
if _audio_player == null:
_audio_player = find_child("AudioStreamPlayer3D", true, false) as AudioStreamPlayer3D
if _audio_player == null:
push_error("[AudioOcclusion] AudioStreamPlayer3D が見つかりません。audio_player_path を設定するか、親に AudioStreamPlayer3D を置いてください。")
func _setup_ray() -> void:
# コンポーネント内部に RayCast3D を生成して使う
_ray = RayCast3D.new()
add_child(_ray)
_ray.collision_mask = collision_mask
_ray.exclude_parent = true
_ray.target_position = Vector3.ZERO # 毎フレーム更新するのでここでは 0
func _init_volume() -> void:
if _audio_player:
_base_volume_db = _audio_player.volume_db
_current_factor = 1.0
# --- 更新処理 -------------------------------------------------------------
func _update_ray(source_global: Vector3, player_global: Vector3) -> void:
# RayCast はローカル空間なので、原点から「プレイヤー方向のベクトル」を target_position に設定
var to_player: Vector3 = player_global - source_global
_ray.global_transform.origin = source_global
_ray.target_position = to_player
func _apply_volume() -> void:
if not _audio_player:
return
# 線形係数を dB に変換して適用
# base_volume_db から、factor に応じて減衰させるイメージ
#
# factor = 1.0 → base_volume_db
# factor = 0.5 → base_volume_db - 6dB 前後(目安)
# factor = 0.0 → -80dB 近くまで下げる
var factor_clamped := clamp(_current_factor, 0.0, 1.0)
var volume_db: float
if factor_clamped <= 0.001:
volume_db = -80.0
else:
# 20 * log10(factor) で倍率→dB 変換
volume_db = _base_volume_db + 20.0 * log10(factor_clamped)
_audio_player.volume_db = volume_db
# --- デバッグ描画 ---------------------------------------------------------
func _debug_draw_ray(source_global: Vector3, player_global: Vector3, is_blocked: bool) -> void:
# Editor でも Game でも見えるように簡易な gizmo を描画
# 毎フレーム clear → add_line するのはコスト高なので、
# 本番では VisualInstance3D などに差し替えてもOKです。
var world := get_world_3d()
if world == null:
return
var debug_drawer := world.debug_draw
if debug_drawer == null:
return
var color: Color = is_blocked ? Color.RED : Color.GREEN
debug_drawer.draw_line(source_global, player_global, color)
使い方の手順
ここでは、「部屋の向こう側で鳴っているラジオ」を例にします。プレイヤーが別の部屋にいるときは音がこもって聞こえる、というイメージですね。
① シーン構成を用意する
まずはラジオ(音源)のシーンをこんな感じで作ります:
Radio (Node3D) ├── AudioStreamPlayer3D └── AudioOcclusion (Node3D) ← このスクリプトをアタッチ
プレイヤー側は例えば:
Player (CharacterBody3D) ├── Camera3D └── CollisionShape3D
プレイヤーのルートノード名を Player にしておくと、player_path を空のままでも自動で見つけてくれます。
② コンポーネントをアタッチする
Radioシーンを開く- 子ノードとして
Node3Dを追加し、名前をAudioOcclusionに変更 - その
AudioOcclusionノードに、先ほどのAudioOcclusion.gdをアタッチ
これで「音の遮蔽」ロジックは、ラジオ側のコンポーネントに完全に閉じ込められました。
プレイヤー側のスクリプトは一切いじらなくてOKです。
③ エクスポートパラメータを設定する
インスペクターで以下の項目を調整しましょう:
- player_path:
Playerノードをドラッグ&ドロップ(または未設定で自動検出) - audio_player_path: 親にある
AudioStreamPlayer3Dを指定(または未設定で自動検出) - occluded_volume_factor: 0.2〜0.4 くらいが「壁越しっぽい」感じでおすすめ
- collision_mask: 壁や床のレイヤーを指定(例: 1 = Walls)
- debug_draw: 最初は ON にして、Ray がちゃんと飛んでいるか確認すると安心です
④ 実行して挙動を確認する
ゲームを実行し、
- ラジオとプレイヤーの間に壁がないとき → 通常の音量
- 壁が挟まったとき → 音量がスッと下がって「壁越しの音」になる
という動きになっていれば成功です。
このコンポーネントを、敵のうめき声、機械音、環境音など、好きなだけコピペして再利用できます。
別パターンのシーン構成例
例えば「動く敵がうめき声を出す」ケースではこんな構成になります:
Zombie (CharacterBody3D) ├── MeshInstance3D ├── CollisionShape3D ├── AudioStreamPlayer3D └── AudioOcclusion (Node3D)
この場合も、AudioOcclusion 側で audio_player_path を空にしておけば、
親の AudioStreamPlayer3D を自動検出してくれるので、セットアップが楽ちんです。
メリットと応用
AudioOcclusion をコンポーネントとして切り出すことで、次のようなメリットがあります:
- プレイヤーのスクリプトが太らない
音の遮蔽ロジックをプレイヤー側に書かなくて済むので、「移動」「入力」「UI」などの本質的な処理に集中できます。 - 継承ツリーを増やさなくていい
「遮蔽対応プレイヤー」「遮蔽対応敵」みたいなベースクラスを作る必要がなく、
ただAudioOcclusionをアタッチするだけで機能を追加できます。 - シーン構造がシンプル
各オブジェクトは「見た目」「当たり判定」「音」「遮蔽」のように役割ごとにノードが分かれるので、
後から見ても何をしているか分かりやすくなります。 - レベルデザインが楽
レベルデザイナーは「この音は壁越しにしたいな」と思ったら、対象のシーンにAudioOcclusionを追加するだけ。
コードを触らずに、ゲーム内の没入感を上げられます。
コンポーネント指向でこういった「単機能のノード」を増やしていくと、
「深いノード階層」や「巨大な継承ツリー」から解放されて、合成(Composition)でゲームを組み立てられるようになりますね。
改造案: 遮蔽物の厚みに応じて音量を変える
今の実装では「遮蔽物があるかどうか」だけを見ていますが、
もう一歩踏み込んで、壁の厚み(距離)に応じて音量を変えることもできます。
例えば、以下のような関数を追加して、target_factor を決めると面白いです:
func _compute_occlusion_factor() -> float:
# RayCast が衝突していない場合は遮蔽なし
if not _ray.is_colliding():
return 1.0
var collision_point: Vector3 = _ray.get_collision_point()
var source_global: Vector3 = global_transform.origin
var player_global: Vector3 = _player.global_transform.origin
# 壁の「厚み」をざっくり、プレイヤー - 衝突点の距離で見る
var wall_thickness: float = collision_point.distance_to(player_global)
# 厚みが 0 のときは最大遮蔽、一定距離以上で遮蔽なし、のようなカーブを作る
var max_thickness: float = 3.0 # この距離以上は遮蔽がほぼないとみなす
var t := clamp(wall_thickness / max_thickness, 0.0, 1.0)
# t=0 → occluded_volume_factor, t=1 → 1.0 になるように補間
return lerp(occluded_volume_factor, 1.0, t)
この関数を使って、_process の中で target_factor を決めるようにすれば、
「薄い壁は少しだけ音を下げ、分厚い壁はかなり音を下げる」といった、よりリッチな表現も可能になります。
こうやって少しずつ機能を追加しても、全部が「AudioOcclusion」コンポーネントの中に閉じているので、
他のシステムに影響を与えずに進化させていけるのが、コンポーネント指向の気持ちいいところですね。




