Godot 4 で 2D/3D のサウンドを扱うとき、AudioStreamPlayer2D や AudioStreamPlayer3D の「距離減衰」機能をそのまま使うのもアリですが、
- リスナー(プレイヤー)を別シーンで管理していて、距離の基準を柔軟に変えたい
- 2D/3D/普通の
AudioStreamPlayerでも同じロジックを使い回したい - ゲーム全体で「距離減衰のカーブ」を統一したい
といったタイミングで、各ノードにバラバラの設定を書き込んでいくのは結構しんどいですよね。
さらに、AudioStreamPlayer2D を継承したカスタムノードを量産してしまうと、
- 継承ツリーが無駄に増える
- あとから仕様変更したときに、全部のシーンを修正する羽目になる
といった「継承のつらみ」にもハマりがちです。
そこで今回は、「どのサウンドノードにもポン付けできる」コンポーネントとして、距離に応じて親ノードの音量を自動調整する DistanceVolume コンポーネントを作ってみましょう。
プレイヤー(リスナー)との距離を測って、親ノードの volume_db をいい感じに減衰させるだけの、シンプルだけど汎用性の高いコンポーネントです。
【Godot 4】距離で音量をスマート制御!「DistanceVolume」コンポーネント
以下は Godot 4 用の GDScript フルコードです。
2D/3D 両対応で、親ノードが AudioStreamPlayer / AudioStreamPlayer2D / AudioStreamPlayer3D のいずれでも動作するようにしています。
extends Node
class_name DistanceVolume
## 距離に応じて「親の AudioStreamPlayer 系ノードの volume_db」を自動調整するコンポーネント。
##
## 想定する親ノード:
## - AudioStreamPlayer
## - AudioStreamPlayer2D
## - AudioStreamPlayer3D
##
## リスナー(プレイヤー)の位置は export で指定するか、
## グローバルの AutoLoad シングルトンから取得するなど、プロジェクトに合わせて設定してください。
@export_group("基本設定")
## プレイヤー(リスナー)ノードへの参照。
## 2D の場合は Node2D / CharacterBody2D / Player など、
## 3D の場合は Node3D / CharacterBody3D など、位置情報を持つノードを指定します。
@export var listener: Node3D
## 2D ゲームで使う場合は true にします。
## true の場合は Node2D / CharacterBody2D など 2D 座標を前提として距離計算します。
@export var is_2d: bool = true
## 音量が最大になる距離(これより近いと max_volume_db で固定)
@export_range(0.0, 10000.0, 0.1, "or_greater") var min_distance: float = 32.0
## 音量が完全に 0 になる距離(これより遠いとミュート扱い)
@export_range(0.0, 10000.0, 0.1, "or_greater") var max_distance: float = 512.0
## 一番近いときの音量(dB)。0.0 で「そのまま」、正の値でブースト、負の値で少し絞る。
@export_range(-80.0, 24.0, 0.1) var max_volume_db: float = 0.0
## 一番遠いときの音量(dB)。通常は -80.0 でミュート相当。
@export_range(-80.0, 24.0, 0.1) var min_volume_db: float = -80.0
@export_group("減衰カーブ")
## 減衰のタイプ:
## - 0: 線形 (Linear)
## - 1: 二乗 (Quadratic) … 近くで急に音が大きくなる感じ
## - 2: 逆数っぽい (Inverse) … 物理っぽい減衰に近づける簡易版
@export_enum("Linear", "Quadratic", "Inverse") var attenuation_type: int = 0
## 減衰の強さを微調整する係数。1.0 が標準。
## 大きくすると距離による変化がより急になります。
@export_range(0.1, 8.0, 0.1) var attenuation_power: float = 1.0
@export_group("パフォーマンス")
## 距離計算を行う間隔(秒)。0 にすると毎フレーム計算。
## 0.05〜0.2 くらいにすると、そこそこ軽くなります。
@export_range(0.0, 1.0, 0.01) var update_interval: float = 0.05
## 距離がこの値より小さく変化した場合は、volume_db を更新しないことで
## 無駄な更新を減らします(0 で無効化)。
@export_range(0.0, 100.0, 0.01) var distance_epsilon: float = 1.0
var _parent_player: Object = null
var _accum_time: float = 0.0
var _last_distance: float = -1.0
func _ready() -> void:
## 親ノードが AudioStreamPlayer 系かどうかをチェックしてキャッシュしておく
_parent_player = _find_parent_player()
if _parent_player == null:
push_warning("DistanceVolume: 親に AudioStreamPlayer / 2D / 3D が見つかりません。コンポーネントは何もしません。")
set_process(false)
return
if listener == null:
push_warning("DistanceVolume: listener が設定されていません。インスペクタから設定してください。")
# listener が後から設定されるかもしれないので、process は有効のままにしておく
set_process(true)
func _process(delta: float) -> void:
if listener == null or _parent_player == null:
return
# 更新間隔の制御
if update_interval > 0.0:
_accum_time += delta
if _accum_time < update_interval:
return
_accum_time = 0.0
var distance := _compute_distance_to_listener()
if distance < 0.0:
return
# 変化が小さければスキップ
if distance_epsilon > 0.0 and _last_distance >= 0.0:
if abs(distance - _last_distance) < distance_epsilon:
return
_last_distance = distance
var volume_db := _distance_to_volume_db(distance)
_apply_volume(volume_db)
func _find_parent_player() -> Object:
## 自分の親、もしくは親の親…をたどって AudioStreamPlayer 系を探す
var current := get_parent()
while current:
if current is AudioStreamPlayer \
or current is AudioStreamPlayer2D \
or current is AudioStreamPlayer3D:
return current
current = current.get_parent()
return null
func _compute_distance_to_listener() -> float:
## 2D/3D に応じて距離を計算
if is_2d:
if not (listener is Node2D):
push_warning("DistanceVolume: is_2d = true ですが、listener が Node2D 系ではありません。")
return -1.0
var owner_2d := _get_owner_2d()
if owner_2d == null:
push_warning("DistanceVolume: 親に Node2D が見つかりません。")
return -1.0
var pos_a: Vector2 = owner_2d.global_position
var pos_b: Vector2 = (listener as Node2D).global_position
return pos_a.distance_to(pos_b)
else:
if not (listener is Node3D):
push_warning("DistanceVolume: is_2d = false ですが、listener が Node3D 系ではありません。")
return -1.0
var owner_3d := _get_owner_3d()
if owner_3d == null:
push_warning("DistanceVolume: 親に Node3D が見つかりません。")
return -1.0
var pos_a: Vector3 = owner_3d.global_position
var pos_b: Vector3 = (listener as Node3D).global_position
return pos_a.distance_to(pos_b)
func _get_owner_2d() -> Node2D:
## 自分の親方向に向かって Node2D を探す
var current := get_parent()
while current:
if current is Node2D:
return current
current = current.get_parent()
return null
func _get_owner_3d() -> Node3D:
## 自分の親方向に向かって Node3D を探す
var current := get_parent()
while current:
if current is Node3D:
return current
current = current.get_parent()
return null
func _distance_to_volume_db(distance: float) -> float:
## min_distance より近い場合は最大音量
if distance <= min_distance:
return max_volume_db
## max_distance より遠い場合は最小音量
if distance >= max_distance:
return min_volume_db
# 0.0〜1.0 に正規化した「どれくらい遠いか」
var t := (distance - min_distance) / (max_distance - min_distance)
t = clamp(t, 0.0, 1.0)
# 減衰カーブを適用
match attenuation_type:
0: # Linear
t = pow(t, attenuation_power)
1: # Quadratic
t = pow(t, 2.0 * attenuation_power)
2: # Inverse っぽいカーブ(0 に近いほど大きく、遠くで急に下がる)
var inv := 1.0 - t
inv = pow(inv, attenuation_power)
t = 1.0 - inv
_:
t = pow(t, attenuation_power)
# t=0.0 で max_volume_db, t=1.0 で min_volume_db になるように線形補間
return lerp(max_volume_db, min_volume_db, t)
func _apply_volume(volume_db: float) -> void:
## 親がどの AudioStreamPlayer 系かによって volume_db をセット
if _parent_player == null:
return
if _parent_player is AudioStreamPlayer:
(_parent_player as AudioStreamPlayer).volume_db = volume_db
elif _parent_player is AudioStreamPlayer2D:
(_parent_player as AudioStreamPlayer2D).volume_db = volume_db
elif _parent_player is AudioStreamPlayer3D:
(_parent_player as AudioStreamPlayer3D).volume_db = volume_db
使い方の手順
ここでは 2D のトップダウンゲームを例に、「プレイヤーから離れると BGM ではなく環境音(焚き火の音など)が小さくなる」ケースで説明します。
- コンポーネントスクリプトを用意
上記のDistanceVolume.gdをプロジェクトのどこか(例:res://components/audio/DistanceVolume.gd)に保存します。class_name DistanceVolumeを定義しているので、以後はシーンツリーから「スクリプトを持つノード」として簡単に追加できます。 - プレイヤー(リスナー)シーンを用意
すでにプレイヤーがある前提で OK です。2D の例:Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── Camera2D
この
Playerを「音を聞く側(listener)」としてDistanceVolumeに渡します。 - 環境音シーンに DistanceVolume をアタッチ
例えば焚き火の音を出すシーンをこんな感じにします:Campfire (Node2D)
├── Sprite2D
├── AudioStreamPlayer2D
└── DistanceVolume <-- ここで今回のコンポーネントを追加
手順:
Campfireシーン内のルートをNode2Dにする- 焚き火のループ音を
AudioStreamPlayer2Dに設定しておく - ルート(または
AudioStreamPlayer2Dの子)にNodeを追加し、スクリプトにDistanceVolume.gdをアタッチ
(もしくは「ノードを追加」ダイアログでDistanceVolumeを直接検索して追加)
- インスペクタでパラメータを設定
DistanceVolumeノードを選択し、以下のように設定します:listener: シーン内のPlayerノードをドラッグ&ドロップis_2d: 2D ゲームなのでtruemin_distance: 64.0(この距離までは音量最大)max_distance: 512.0(この距離を超えるとほぼ聞こえない)max_volume_db: 0.0(音源のデフォルト音量)min_volume_db: -80.0(ミュート相当)attenuation_type:Quadratic(近くに寄ると急に大きくなる感じ)attenuation_power: 1.0(標準)update_interval: 0.05(1 秒間に約 20 回更新)distance_epsilon: 2.0(2px 以内の変化は無視)
これで、プレイヤーが焚き火に近づくと音が大きく、離れると小さくなるはずです。
同じコンポーネントを別のシーンにも簡単に再利用できます。例えば、洞窟内の水滴音や、街中の噴水など:
Fountain (Node2D) ├── Sprite2D ├── AudioStreamPlayer2D └── DistanceVolume
3D の場合もほぼ同じで、is_2d = false にし、listener に CharacterBody3D などを指定すれば OK です。
メリットと応用
DistanceVolume をコンポーネントとして切り出すことで、いくつか嬉しいポイントがあります。
- 継承地獄からの解放
CampfireAudioPlayerやFountainAudioPlayerのような「距離減衰付き AudioStreamPlayer の派生クラス」を量産する必要がありません。
すべて「素のAudioStreamPlayer*+DistanceVolume」で完結します。 - シーン構造がフラットでわかりやすい
距離減衰ロジックは常にDistanceVolumeという 1 ノードに閉じ込められるので、「この音はどういうルールで鳴ってるんだっけ?」と迷いにくくなります。 - プロジェクト全体で挙動を統一しやすい
減衰カーブを変えたくなったら、このコンポーネントだけを修正すれば全シーンに反映されます。
「やっぱり遠くの音はもう少し聞こえるようにしよう」といった調整も一発ですね。 - レベルデザイン時の配置が楽
レベルデザイナは「音源を置いて、プレイヤーを listener に設定する」だけで OK。
あとは距離パラメータをちょっといじるだけで、直感的にサウンドスケープを作れます。
応用として、例えば「特定のグループに入っているリスナーを自動的に探す」ようにしておけば、いちいち listener をシーンごとに設定しなくても済みます。以下はそのための簡単な改造案です。
## シーン内で一番近い "listener" グループのノードを自動で探す例
func find_nearest_listener_in_group(group_name: String = "listener") -> Node:
var candidates := get_tree().get_nodes_in_group(group_name)
if candidates.is_empty():
return null
var owner_pos: Vector3
if is_2d:
var owner_2d := _get_owner_2d()
if owner_2d == null:
return null
owner_pos = Vector3(owner_2d.global_position.x, owner_2d.global_position.y, 0.0)
else:
var owner_3d := _get_owner_3d()
if owner_3d == null:
return null
owner_pos = owner_3d.global_position
var nearest: Node = null
var nearest_dist := INF
for node in candidates:
var pos: Vector3
if is_2d and node is Node2D:
pos = Vector3(node.global_position.x, node.global_position.y, 0.0)
elif not is_2d and node is Node3D:
pos = node.global_position
else:
continue
var d := owner_pos.distance_to(pos)
if d < nearest_dist:
nearest_dist = d
nearest = node
return nearest
例えば _ready() の中で listener = find_nearest_listener_in_group() を呼ぶようにすれば、「とりあえずシーン内で一番近いプレイヤーをリスナーにする」ような柔軟な設計もできます。
こんな感じで、「距離減衰」というよくある処理を 1 つのコンポーネントに閉じ込めておくと、あとからの拡張や差し替えがとても楽になります。継承より合成で、スッキリしたオーディオ設計を目指していきましょう。
