Godot 4 で 2D/3D のサウンドを扱うとき、AudioStreamPlayer2DAudioStreamPlayer3D の「距離減衰」機能をそのまま使うのもアリですが、

  • リスナー(プレイヤー)を別シーンで管理していて、距離の基準を柔軟に変えたい
  • 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 ではなく環境音(焚き火の音など)が小さくなる」ケースで説明します。

  1. コンポーネントスクリプトを用意
    上記の DistanceVolume.gd をプロジェクトのどこか(例: res://components/audio/DistanceVolume.gd)に保存します。
    class_name DistanceVolume を定義しているので、以後はシーンツリーから「スクリプトを持つノード」として簡単に追加できます。
  2. プレイヤー(リスナー)シーンを用意
    すでにプレイヤーがある前提で OK です。2D の例:
    Player (CharacterBody2D)
    ├── Sprite2D
    ├── CollisionShape2D
    └── Camera2D

    この Player を「音を聞く側(listener)」として DistanceVolume に渡します。


  3. 環境音シーンに DistanceVolume をアタッチ
    例えば焚き火の音を出すシーンをこんな感じにします:
    Campfire (Node2D)
    ├── Sprite2D
    ├── AudioStreamPlayer2D
    └── DistanceVolume <-- ここで今回のコンポーネントを追加

    手順:

    • Campfire シーン内のルートを Node2D にする
    • 焚き火のループ音を AudioStreamPlayer2D に設定しておく
    • ルート(または AudioStreamPlayer2D の子)に Node を追加し、スクリプトに DistanceVolume.gd をアタッチ

      (もしくは「ノードを追加」ダイアログで DistanceVolume を直接検索して追加)
  4. インスペクタでパラメータを設定
    DistanceVolume ノードを選択し、以下のように設定します:

    • listener: シーン内の Player ノードをドラッグ&ドロップ

    • is_2d: 2D ゲームなので true

    • min_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 にし、listenerCharacterBody3D などを指定すれば OK です。

メリットと応用

DistanceVolume をコンポーネントとして切り出すことで、いくつか嬉しいポイントがあります。

  • 継承地獄からの解放
    CampfireAudioPlayerFountainAudioPlayer のような「距離減衰付き 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 つのコンポーネントに閉じ込めておくと、あとからの拡張や差し替えがとても楽になります。継承より合成で、スッキリしたオーディオ設計を目指していきましょう。