【Godot 4】AudioOcclusion (遮蔽音) コンポーネントの作り方

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ゲームを作っていると、「音が壁をすり抜けて聞こえる問題」に一度はぶつかりますよね。
素直にやろうとすると…

  • 各サウンドごとに 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 を空のままでも自動で見つけてくれます。

② コンポーネントをアタッチする

  1. Radio シーンを開く
  2. 子ノードとして Node3D を追加し、名前を AudioOcclusion に変更
  3. その 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」コンポーネントの中に閉じているので、
他のシステムに影響を与えずに進化させていけるのが、コンポーネント指向の気持ちいいところですね。

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をコピーしました!