【Godot 4】SpatialAudio (3D音響) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

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 を使い回すイメージです。

手順①:スクリプトを用意する

  1. 上記コードを res://components/spatial_audio/SpatialAudio.gd などに保存します。
  2. Godot エディタを再読み込みすると、ノード追加 ダイアログのスクリプト一覧に SpatialAudio が出てきます。

手順②:プレイヤーにアタッチして足音を3D化する

例として、3Dアクションゲームのプレイヤーシーン構成を考えます:

Player (CharacterBody3D)
 ├── MeshInstance3D
 ├── CollisionShape3D
 ├── Camera3D
 └── SpatialAudio (Node3D)  ← 足音用コンポーネント
  1. Player シーンを開き、ルートの CharacterBody3D を選択。
  2. 右クリック → 「子ノードを追加」 → 検索欄に SpatialAudio と入力し、追加。
  3. SpatialAudio ノードを選択し、インスペクタで:
    • Audio Source / stream:足音の AudioStream を設定
    • loop:連続足音なら true、1回鳴らすだけなら false
    • autoplay:足音は任意タイミングで鳴らすので false 推奨
    • Distance Attenuationmin_distance = 1.0, max_distance = 25.0 など
    • Panningpan_strength = 1.0, max_pan = 0.8 など
    • Listener:空のままにしておけば、自動で Camera3D を見つけます

プレイヤーのスクリプト側では、移動開始時/停止時に SpatialAudioplay() / 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 = true
  • loop = 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ファイルの中で完結して管理できます。
ぜひ自分のゲーム用にゴリゴリ改造してみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!