【Godot 4】LowPassFilter (水中音響) コンポーネントの作り方

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で水中表現をしようとすると、まず思いつくのが「プレイヤーが水に入ったらBGMを変える」「SEを専用のバスに切り替える」といった実装ですね。でも、シーンごとにAudioBusを切り替えたり、各AudioStreamPlayerにスクリプトを書き足したりすると、だんだん管理がカオスになってきます。

さらに、Godot標準のやり方だと「水中用シーンを継承して作る」「水中プレイヤーと地上プレイヤーを分ける」みたいな継承ベースの構造に走りがちです。そうすると、ちょっと挙動を変えたいだけでシーン階層をいじる必要が出てきて、保守がつらくなります。

そこで今回は、どのシーンにもポン付けできる「水中ローパスフィルタ」コンポーネントを用意して、継承ではなく合成(Composition)で水中音響を実現してみましょう。水中エリアに入ったら AudioBus にローパスフィルタをかけて音をこもらせ、出たら元に戻す――この処理をひとつのコンポーネントにまとめます。

【Godot 4】水中に入ったら自動で音がこもる!「LowPassFilter」コンポーネント

この LowPassFilter コンポーネントは、エリア(Area2D / Area3D)にアタッチするだけで、水中エリア内にいる間だけ指定した AudioBus にローパスフィルタをかけてくれます。

  • 水中エリアに入ったら:ローパスON(周波数を下げて音をこもらせる)
  • 水中エリアから出たら:ローパスOFF(元の値に戻す)

また、段階的なフェード(徐々にこもらせる/徐々に戻す)にも対応できるようにしてあります。


GDScript フルコード


extends Area2D
class_name LowPassFilter
## 水中エリアに入ったオブジェクトに対して、
## 指定した AudioBus のローパスフィルタをON/OFFするコンポーネント。
##
## 想定用途:
## - 水中エリア
## - 魔法の結界内の音響変化
## - ドア越し・壁越しのこもった音 など

@export_category("Target Bus")
## ローパスをかける対象のAudioBus名。
## 例: "Master", "SFX", "Music" など。Audio Bus Layout に合わせて指定。
@export var target_bus_name: String = "Master"

@export_category("Low Pass Settings")
## 水中時に設定するローパスのカットオフ周波数(Hz)。
## 小さいほどこもった音になる。例: 500〜2000あたりが水中感を出しやすい。
@export_range(100.0, 20000.0, 10.0, "or_greater", "or_lesser")
@export var underwater_cutoff_hz: float = 1500.0

## 水中時に設定するローパスのレゾナンス(Q)。
## 大きいほどカットオフ付近が強調される。0.1〜2.0程度が扱いやすい。
@export_range(0.1, 4.0, 0.1, "or_greater", "or_lesser")
@export var underwater_resonance: float = 1.0

@export_category("Transition")
## ローパスON/OFFをどれくらいの時間でフェードさせるか(秒)。
## 0にすると即時切り替え。
@export_range(0.0, 5.0, 0.05, "or_greater", "or_lesser")
@export var transition_time: float = 0.4

## 対象とするボディのグループ名。
## 例: "player" を指定すると、playerグループのノードのみがトリガーになる。
## 空文字のときは、全てのボディに反応する。
@export_category("Filter")
@export var required_body_group: String = "player"

## デバッグ用: trueにすると、エリア入退時にログを出す。
@export_category("Debug")
@export var debug_log: bool = false

# --- 内部状態 ---

var _bus_index: int = -1

# 元のローパス設定を保持しておく(出たときに戻すため)
var _original_cutoff_hz: float = 0.0
var _original_resonance: float = 0.0

# 現在の補間状態
var _current_cutoff_hz: float = 0.0
var _current_resonance: float = 0.0

# 目標値
var _target_cutoff_hz: float = 0.0
var _target_resonance: float = 0.0

# フェード用タイマー
var _transition_elapsed: float = 0.0
var _is_transitioning: bool = false

# いま水中状態かどうか(1つ以上の対象ボディが中にいるか)
var _is_underwater: bool = false

func _ready() -> void:
    # AudioBus名からインデックスを取得
    _bus_index = AudioServer.get_bus_index(target_bus_name)
    if _bus_index == -1:
        push_warning("LowPassFilter: AudioBus '%s' が見つかりません。Audio Bus Layout を確認してください。" % target_bus_name)
        return

    # このバスにローパスエフェクトがあるか確認。
    # 無ければ自動で追加する(Bus Effect Slot 0 に追加)。
    var effect_count := AudioServer.get_bus_effect_count(_bus_index)
    var lowpass_effect: AudioEffectLowPassFilter = null

    for i in effect_count:
        var effect := AudioServer.get_bus_effect(_bus_index, i)
        if effect is AudioEffectLowPassFilter:
            lowpass_effect = effect
            break

    if lowpass_effect == null:
        # 無ければ新規に追加
        lowpass_effect = AudioEffectLowPassFilter.new()
        AudioServer.add_bus_effect(_bus_index, lowpass_effect, 0)
        if debug_log:
            print("LowPassFilter: Added AudioEffectLowPassFilter to bus '%s'." % target_bus_name)

    # 元の値を保存
    _original_cutoff_hz = lowpass_effect.cutoff_hz
    _original_resonance = lowpass_effect.resonance

    # 現在値を初期化
    _current_cutoff_hz = _original_cutoff_hz
    _current_resonance = _original_resonance

    # エリアのシグナル接続(エディタ上で未接続でも動くように)
    body_entered.connect(_on_body_entered)
    body_exited.connect(_on_body_exited)


func _process(delta: float) -> void:
    if not _is_transitioning:
        return
    if _bus_index == -1:
        return

    if transition_time <= 0.0:
        # 即時反映
        _current_cutoff_hz = _target_cutoff_hz
        _current_resonance = _target_resonance
        _apply_to_bus()
        _is_transitioning = false
        return

    _transition_elapsed += delta
    var t := clamp(_transition_elapsed / transition_time, 0.0, 1.0)

    # 線形補間(必要ならイージングに変えてもOK)
    _current_cutoff_hz = lerp(_current_cutoff_hz, _target_cutoff_hz, t)
    _current_resonance = lerp(_current_resonance, _target_resonance, t)

    _apply_to_bus()

    if t >= 1.0:
        _is_transitioning = false


func _apply_to_bus() -> void:
    # 実際にAudioBusのローパスエフェクトに値を適用する
    var effect_count := AudioServer.get_bus_effect_count(_bus_index)
    for i in effect_count:
        var effect := AudioServer.get_bus_effect(_bus_index, i)
        if effect is AudioEffectLowPassFilter:
            effect.cutoff_hz = _current_cutoff_hz
            effect.resonance = _current_resonance
            AudioServer.set_bus_effect_enabled(_bus_index, i, true)
            break


func _set_underwater(active: bool) -> void:
    if _is_underwater == active:
        return
    _is_underwater = active

    if _bus_index == -1:
        return

    if debug_log:
        print("LowPassFilter: underwater =", _is_underwater)

    if _is_underwater:
        # 水中に入った → ローパスON方向へ補間
        _target_cutoff_hz = underwater_cutoff_hz
        _target_resonance = underwater_resonance
    else:
        # 水中から出た → 元の設定へ戻す
        _target_cutoff_hz = _original_cutoff_hz
        _target_resonance = _original_resonance

    _transition_elapsed = 0.0
    _is_transitioning = true


func _on_body_entered(body: Node) -> void:
    if not _body_is_target(body):
        return
    if debug_log:
        print("LowPassFilter: body entered:", body.name)
    # 対象ボディが1つでも入ったら水中ON
    _set_underwater(true)


func _on_body_exited(body: Node) -> void:
    if not _body_is_target(body):
        return
    if debug_log:
        print("LowPassFilter: body exited:", body.name)

    # ここでは「最後の1体が出たかどうか」までは数えていません。
    # シンプルに、対象ボディが出たタイミングでOFFにします。
    # 複数プレイヤー対応が必要ならカウンタで管理しましょう。
    _set_underwater(false)


func _body_is_target(body: Node) -> bool:
    # グループ指定が空なら全て対象
    if required_body_group.is_empty():
        return true
    # 指定グループを持つノードのみ対象
    return body.is_in_group(required_body_group)


## --- おまけ: 手動で水中ON/OFFを切り替えたい場合のAPI ---

func force_underwater_on() -> void:
    ## スクリプトから強制的に水中状態にする
    _set_underwater(true)


func force_underwater_off() -> void:
    ## スクリプトから強制的に水中状態を解除する
    _set_underwater(false)

使い方の手順

ここでは 2D プロジェクトを例に説明しますが、Area3D に変えても考え方は同じです。

手順① AudioBus にローパスを用意する

  1. 上部メニューから Project > Project Settings… > Audio > Bus Layout… を開く。
  2. Master もしくは別のバス(例: SFX)を選択。
  3. 右側の「+」ボタンで AudioEffectLowPassFilter を追加しても良いですが、
    今回のコンポーネントは「無ければ自動で追加」するので、必須ではありません。
  4. バス名(例: Master, SFX)を覚えておきます。

手順② 水中エリアのシーンを作る

例として、プレイヤーが飛び込む池の水面エリアを作ってみます。

WaterArea (Area2D)
 ├── CollisionShape2D
 └── LowPassFilter (スクリプトをアタッチしたノードでもよい)

やり方:

  1. 新規シーンを作成し、ルートに Area2D を追加(名前: WaterArea)。
  2. 子として CollisionShape2D を追加し、水中エリアの範囲を設定。
  3. WaterAreaLowPassFilter.gd をアタッチするか、
    子ノードに Node を追加してそこにアタッチしてもOKです。
    (コンポーネントとして分離したい場合は後者がおすすめ)

コンポーネントとして分けた場合のシーン構成図はこんな感じです:

WaterArea (Area2D)
 ├── CollisionShape2D
 └── LowPassFilter (Node)  ← このノードにスクリプトをアタッチ

この場合は、LowPassFilterextendsArea2D ではなく Node に変え、
ownerArea2D としてシグナル接続する形になります。
「継承より合成」派ならこちらの構成もアリですね。

手順③ インスペクタでパラメータを設定する

LowPassFilter ノードを選択し、インスペクタで次のように設定します:

  • Target Bus
    • target_bus_name: 例として MasterSFX を指定。
  • Low Pass Settings
    • underwater_cutoff_hz: 1500〜2000Hz あたりが水中感を出しやすいです。
    • underwater_resonance: 1.0 前後でOK。派手にしたければ 1.5〜2.0 など。
  • Transition
    • transition_time: 0.3〜0.5 秒くらいにすると、自然にこもった感じになります。
  • Filter
    • required_body_group: 例として player を指定。
      こうしておくと、player グループのノードだけが水中トリガーになります。

プレイヤー側では、Node > Groups から player グループを追加しておきましょう。

手順④ プレイヤーシーンに組み込んで動作確認

プレイヤーのシーン構成例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D

ステージシーン例:

Level1 (Node2D)
 ├── Player (CharacterBody2D)
 ├── TileMap
 └── WaterArea (Area2D)
      ├── CollisionShape2D
      └── LowPassFilter (Node)  ← コンポーネント

この構成なら、プレイヤーシーンには一切「水中用のコード」を書かずに済みます。
水中エリア側にだけコンポーネントを付けるので、レベルデザイン時に WaterArea をコピペ配置するだけで、
どのステージでも同じ水中音響が再利用できます。


メリットと応用

この LowPassFilter コンポーネントを使うと、次のようなメリットがあります。

  • シーン構造がシンプル
    プレイヤーや敵キャラに「水中対応版クラス」を作る必要がなく、
    水中表現はすべて WaterArea + LowPassFilter 側に閉じ込められます。
  • レベルデザインが楽
    新しいマップを作るときは、水中エリアを置いてこのコンポーネントをアタッチするだけ。
    プレイヤーやサウンドのシーンをいじる必要がありません。
  • 使い回しが効く
    「水中」だけでなく、魔法の結界内は音がこもる扉の向こうはローパス など、
    同じコンポーネントを別のシーンにそのまま流用できます。
  • AudioBus ベースなので拡張しやすい
    ローパス以外のエフェクト(リバーブ、ディレイなど)も同じバスに積んでおけば、
    「水中に入ったらローパス+リバーブON」のようなリッチな表現も簡単です。

「継承で水中プレイヤーを作る」のではなく、水中エリアに音響コンポーネントを付けるという発想に切り替えると、
シーンの責務がきれいに分離されて、後からの変更にも強くなります。

改造案:プレイヤーの深さに応じてローパスを変える

もう一歩踏み込むと、「水面に近いほどあまりこもらず、深く潜るほど強くこもる」といった表現もできます。
例えば、Area2D の Y 座標とプレイヤーの Y 座標から「潜水率」を計算し、その値でカットオフを補間するイメージです。

以下は、そのための簡単な関数例です(LowPassFilter に追記する想定)。


func update_underwater_intensity(player_global_y: float) -> void:
    ## プレイヤーのY座標に応じてローパスの強さを変える改造案。
    ## 例: 水面Y=0, 一番深い位置Y=200 として、0〜1の強度を計算する。
    var water_surface_y := global_position.y
    var max_depth := 200.0  # 好きな値に調整
    var depth := clamp(player_global_y - water_surface_y, 0.0, max_depth)
    var intensity := depth / max_depth  # 0.0(水面)〜1.0(最深部)

    # intensity=0.0 なら元の設定、1.0なら underwater_cutoff_hz までローパス
    var target_cutoff := lerp(_original_cutoff_hz, underwater_cutoff_hz, intensity)
    var target_resonance := lerp(_original_resonance, underwater_resonance, intensity)

    _current_cutoff_hz = target_cutoff
    _current_resonance = target_resonance
    _apply_to_bus()

この関数をプレイヤーの _process から呼び出すようにすれば、潜るほど音がこもる表現も簡単に作れます。
コンポーネントをベースにしておけば、こうした拡張も「差分として足すだけ」で済むので、どんどん遊びやすくなりますね。

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