Godot 4で3D音を扱うとき、素直に AudioStreamPlayer3D を使うのが定番ですよね。ただ、
- 2D/3Dをまたいで共通の「音の出し方」を書きたい
- ゲーム独自の距離減衰カーブやパン(左右バランス)ロジックを使いたい
- 「プレイヤー=リスナー」を前提にしたくない(カットシーン用カメラなど)
といった要件が出てくると、組み込みの3D音響だけではちょっと扱いづらくなります。さらに、
- プレイヤー、敵、ギミックなど、いろんなシーンに同じ3D音処理をコピペする
- キャラシーンを継承して「サウンド付きキャラ」「サウンドなしキャラ」を作り分ける
といった「継承ベース」の構成に陥ると、あとから仕様変更したときに地獄を見がちです。
そこで今回は、「どのノードにもポン付けできる」コンポーネントとして、距離と方向に応じて音量と左右パンを自動調整する SpatialAudio コンポーネントを作っていきましょう。
ノード階層はシンプルなまま、「音の振る舞い」だけをコンポーネントとして合成するスタイルです。
【Godot 4】どのノードも“立体音源”に!「SpatialAudio」コンポーネント
今回の SpatialAudio は、ざっくり言うと:
- 任意の「リスナー」ノード(通常はカメラ)を参照
- 自分(音源)との距離と方向から
- 音量(距離減衰)
- 左右パン(ステレオバランス)
を計算
- 内部の
AudioStreamPlayerに適用して再生
という、「3D位置 → 2Dステレオ音」変換コンポーネントです。
Godot標準の AudioStreamPlayer3D ではなく、あえて AudioStreamPlayer ベースで作ることで、2D/3Dの両方に同じロジックを適用しやすくなります。
フルコード:SpatialAudio.gd
extends Node3D
class_name SpatialAudio
##
## SpatialAudio
## 距離と方向に応じて、音量と左右パンを自動調整する3D音響コンポーネント。
## - 任意の Node3D を「音源」にできる
## - 任意の Node3D(通常はカメラ)を「リスナー」に指定可能
## - 内部で AudioStreamPlayer を使い、volume_db と pan を制御する
##
@export_category("Audio Source")
## 再生する音源。SE・環境音などをここに設定します。
@export var stream: AudioStream
## ループ再生するかどうか。環境音やエンジン音などに便利。
@export var loop: bool = false
## 自動再生するかどうか。true の場合、ready 時に自動で再生します。
@export var autoplay: bool = false
@export_category("Listener")
## リスナー(通常はカメラ)への参照。
## 空の場合は、現在のシーンの Camera3D を自動検索します。
@export var listener: NodePath
@export_category("Distance Attenuation")
## この距離までは音量をフル(0dB)で維持します。
@export var min_distance: float = 1.0
## この距離を超えると完全に聞こえなくなります。
@export var max_distance: float = 30.0
## 減衰カーブの指数。1.0=線形, 2.0=2乗で急激に減衰。
@export_range(0.1, 4.0, 0.1)
@export var attenuation_power: float = 1.5
## 最大音量(dB)。0dB が標準、負の値で全体の音量を下げられます。
@export_range(-40.0, 6.0, 0.5)
@export var base_volume_db: float = 0.0
@export_category("Panning")
## パンの強さ。1.0 でフル、0.0 でパンなし(常に中央)。
@export_range(0.0, 1.0, 0.05)
@export var pan_strength: float = 1.0
## 左右に最大どのくらい振るか。1.0 でフル L/R。
@export_range(0.0, 1.0, 0.05)
@export var max_pan: float = 1.0
## パンの計算に使う「正面方向」。通常は (0,0,-1)(-Z 方向)。
@export var listener_forward_axis: Vector3 = Vector3.FORWARD * -1.0
@export_category("Debug")
## デバッグ用に、現在の距離・音量・パンを表示するかどうか。
@export var debug_print: bool = false
## 内部で使用する AudioStreamPlayer
var _player: AudioStreamPlayer
var _listener_node: Node3D
func _ready() -> void:
# 内部用の AudioStreamPlayer を生成して子ノードに追加
_player = AudioStreamPlayer.new()
_player.bus = "Master" # 必要に応じてバスを変更してください
_player.stream = stream
_player.autoplay = false
add_child(_player)
_resolve_listener()
# ループ設定
if stream is AudioStreamWAV:
(stream as AudioStreamWAV).loop_mode = AudioStreamWAV.LOOP_FORWARD if loop else AudioStreamWAV.LOOP_DISABLED
elif stream is AudioStreamOggVorbis:
(stream as AudioStreamOggVorbis).loop = loop
# その他のストリーム型の場合は、必要に応じて対応を追加
if autoplay:
play()
func _process(delta: float) -> void:
if not _player or not _player.playing:
return
if not _listener_node:
_resolve_listener()
if not _listener_node:
return
_update_spatial_audio()
func _resolve_listener() -> void:
## リスナーを解決するヘルパー。
## 1. listener に NodePath が設定されていればそれを使う
## 2. なければ現在のシーンから Camera3D を探して、その親 Node3D をリスナーとみなす
if listener != NodePath():
var node := get_node_or_null(listener)
if node and node is Node3D:
_listener_node = node
return
# 自動検出
var tree := get_tree()
if not tree:
return
var current_scene := tree.current_scene
if not current_scene:
return
var cameras := current_scene.get_tree().get_nodes_in_group("cameras")
# もし "cameras" グループを使っていない場合は、シーン内を総当たりで Camera3D を探す
var camera_node: Camera3D = null
if cameras.size() > 0:
for c in cameras:
if c is Camera3D:
camera_node = c
break
else:
camera_node = current_scene.get_node_or_null("Camera3D") as Camera3D
if not camera_node:
# 総当たり検索(コストは高めなので必要なら最適化を)
for node in current_scene.get_children(true):
if node is Camera3D:
camera_node = node
break
if camera_node:
# カメラ自身をリスナーとみなす(親が Node3D なら好みでそちらでもOK)
_listener_node = camera_node
func _update_spatial_audio() -> void:
## 距離と方向から volume_db と pan を計算して AudioStreamPlayer に反映する。
# 距離計算
var source_pos: Vector3 = global_transform.origin
var listener_pos: Vector3 = _listener_node.global_transform.origin
var to_source: Vector3 = source_pos - listener_pos
var distance: float = to_source.length()
# 距離減衰(0.0~1.0)
var volume_factor := _compute_distance_attenuation(distance)
# 向き(左右パン)の計算
var pan := _compute_pan(to_source)
# dB に変換(0.0 → -80dB くらいにしてほぼ無音にする)
var volume_db := base_volume_db
if volume_factor <= 0.0001:
volume_db = -80.0
else:
volume_db += linear_to_db(volume_factor)
_player.volume_db = volume_db
_player.pan = pan
if debug_print:
print("SpatialAudio: dist=%.2f, vol_factor=%.2f, vol_db=%.2f, pan=%.2f"
% [distance, volume_factor, volume_db, pan])
func _compute_distance_attenuation(distance: float) -> float:
## 距離から 0.0~1.0 の音量係数を計算する。
if distance <= min_distance:
return 1.0
if distance >= max_distance:
return 0.0
var t := (distance - min_distance) / (max_distance - min_distance)
# t=0 で 1.0, t=1 で 0.0 になるように反転させる
var factor := pow(1.0 - clamp(t, 0.0, 1.0), attenuation_power)
return factor
func _compute_pan(to_source: Vector3) -> float:
## リスナーから見た音源の方向ベクトルから、左右パン(-1.0~1.0)を計算する。
if pan_strength <= 0.0:
return 0.0
var basis := _listener_node.global_transform.basis
var forward := basis.z * -1.0 # Camera3D の「前」を想定(-Z 方向)
var right := basis.x # +X を右とみなす
# 水平方向だけを考慮(高さは無視)
var to_source_flat := to_source
to_source_flat.y = 0.0
if to_source_flat.length() == 0.0:
return 0.0
to_source_flat = to_source_flat.normalized()
# 右ベクトルとの内積で左右成分を取得
var right_dot := right.normalized().dot(to_source_flat)
# -1.0(左)~1.0(右)
var pan := clamp(right_dot, -1.0, 1.0)
# 前後の位置によってもパンを弱める(真後ろの音は左右差が小さく感じられるイメージ)
var forward_dot := forward.normalized().dot(to_source_flat)
var front_factor := clamp((forward_dot + 1.0) * 0.5, 0.0, 1.0) # -1~1 → 0~1
pan *= front_factor
# 強さと最大値を適用
pan *= pan_strength * max_pan
pan = clamp(pan, -1.0, 1.0)
return pan
# ===== 公開API =====
## 音を再生します。すでに再生中なら先頭から再生し直します。
func play(from_position: float = 0.0) -> void:
if not _player:
await ready
_player.stream = stream
_player.play(from_position)
## 再生を停止します。
func stop() -> void:
if _player:
_player.stop()
## 一時停止/再開をトグルします。
func toggle_pause() -> void:
if not _player:
return
_player.stream_paused = not _player.stream_paused
## 一時停止状態を直接設定します。
func set_paused(paused: bool) -> void:
if _player:
_player.stream_paused = paused
## 現在再生中かどうか。
func is_playing() -> bool:
return _player and _player.playing
使い方の手順
ここからは、実際にシーンへ組み込む手順を見ていきましょう。例として:
- プレイヤーの足音
- 敵キャラのうなり声
- 回転するギミックからの機械音
などに同じ SpatialAudio を使い回すイメージです。
手順①:スクリプトを用意する
- 上記コードを
res://components/spatial_audio/SpatialAudio.gdなどに保存します。 - Godot エディタを再読み込みすると、ノード追加 ダイアログのスクリプト一覧に
SpatialAudioが出てきます。
手順②:プレイヤーにアタッチして足音を3D化する
例として、3Dアクションゲームのプレイヤーシーン構成を考えます:
Player (CharacterBody3D) ├── MeshInstance3D ├── CollisionShape3D ├── Camera3D └── SpatialAudio (Node3D) ← 足音用コンポーネント
Playerシーンを開き、ルートのCharacterBody3Dを選択。- 右クリック → 「子ノードを追加」 → 検索欄に
SpatialAudioと入力し、追加。 SpatialAudioノードを選択し、インスペクタで:- Audio Source / stream:足音の
AudioStreamを設定 - loop:連続足音なら true、1回鳴らすだけなら false
- autoplay:足音は任意タイミングで鳴らすので false 推奨
- Distance Attenuation:
min_distance = 1.0,max_distance = 25.0など - Panning:
pan_strength = 1.0,max_pan = 0.8など - Listener:空のままにしておけば、自動で
Camera3Dを見つけます
- Audio Source / stream:足音の
プレイヤーのスクリプト側では、移動開始時/停止時に SpatialAudio の play() / stop() を呼ぶだけです:
# Player.gd (抜粋)
extends CharacterBody3D
@onready var footstep_audio: SpatialAudio = $SpatialAudio
var _was_moving := false
func _physics_process(delta: float) -> void:
# 入力などから velocity を更新する処理がある前提
var is_moving := velocity.length() > 0.1
if is_moving and not _was_moving:
# 動き始めた瞬間に足音再生
footstep_audio.play()
elif not is_moving and _was_moving:
# 止まったら足音停止
footstep_audio.stop()
_was_moving = is_moving
手順③:敵キャラにもそのまま再利用
敵キャラにも同じコンポーネントをポン付けできます:
Enemy (CharacterBody3D) ├── MeshInstance3D ├── CollisionShape3D └── SpatialAudio (Node3D) ← うなり声用コンポーネント
インスペクタで stream を「うなり声SE」に変えるだけで、同じ距離減衰・パンロジックを再利用できます。
プレイヤーの位置やカメラの位置が変わっても、SpatialAudio が自動で位置関係を見てくれます。
手順④:ギミックの環境音として使う
回転するギミック(歯車など)から常に機械音を出したい場合:
SpinningGear (Node3D) ├── MeshInstance3D ├── CollisionShape3D ├── SpatialAudio (Node3D) ← 機械音コンポーネント └── AnimationPlayer
SpatialAudio.autoplay = trueloop = true
としておけば、シーンが読み込まれた瞬間から「位置に応じて音が変わる環境音」が自動で鳴り続けます。
ギミックを別の場所に複製しても、音の距離減衰とパンは勝手にいい感じに調整されます。
メリットと応用
SpatialAudio をコンポーネントとして切り出すことで、いくつか良いことがあります。
- シーン構造がスッキリする
「音を出したいノードにSpatialAudioを1個ぶら下げる」だけなので、
継承ツリーを増やさずに済みます。
PlayerWithSound/EnemyWithSoundみたいな派生シーンを作らなくてOKです。 - ロジックを一箇所で管理できる
距離減衰やパンの計算式を変えたくなったら、SpatialAudio.gdを直すだけ。
全キャラ・全ギミックのサウンド挙動が一括で更新されます。 - 2D/3Dの差を吸収しやすい
今回はNode3Dベースですが、同じ思想でNode2D版を作れば、
「2Dゲームだけどなんちゃって3D音響」も簡単に実装できます。 - リスナーを差し替えやすい
カットシーン中だけ別のカメラをリスナーにしたり、マルチプレイで各プレイヤーごとに
リスナーを変えたり、といった拡張もlistenerを差し替えるだけで対応できます。
「音の出し方」をキャラやギミック本体から切り離して、合成(Composition)で足していくスタイルにすると、
後からの仕様変更や再利用が本当に楽になりますね。
改造案:距離に応じてローパスフィルタをかける
「遠くの音はこもって聞こえる」演出を入れたい場合、AudioEffectLowPassFilter を使って、
距離に応じてカットオフ周波数を変えてやるとそれっぽくなります。
例えば、SpatialAudio 内にこんな関数を追加して、_update_spatial_audio() の最後で呼ぶ形です:
func _apply_distance_lowpass(distance: float) -> void:
# Audio バス "SFX" に LowPassFilter を挿しておき、
# その 0 番目のエフェクトを距離に応じて調整する例。
var bus_index := AudioServer.get_bus_index("SFX")
if bus_index == -1:
return
var effect := AudioServer.get_bus_effect(bus_index, 0)
if not (effect is AudioEffectLowPassFilter):
return
# 近いほど高い周波数、遠いほど低い周波数になるように補間
var t := clamp((distance - min_distance) / (max_distance - min_distance), 0.0, 1.0)
var cutoff := lerp(8000.0, 1200.0, t) # 8kHz → 1.2kHz
(effect as AudioEffectLowPassFilter).cutoff_hz = cutoff
このように、コンポーネント化しておけば、「距離減衰+パン+ローパス」みたいな
ゲーム固有の3D音響ルールも、1ファイルの中で完結して管理できます。
ぜひ自分のゲーム用にゴリゴリ改造してみてください。




