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 にローパスを用意する
- 上部メニューから Project > Project Settings… > Audio > Bus Layout… を開く。
- Master もしくは別のバス(例: SFX)を選択。
- 右側の「+」ボタンで AudioEffectLowPassFilter を追加しても良いですが、
今回のコンポーネントは「無ければ自動で追加」するので、必須ではありません。 - バス名(例:
Master,SFX)を覚えておきます。
手順② 水中エリアのシーンを作る
例として、プレイヤーが飛び込む池の水面エリアを作ってみます。
WaterArea (Area2D) ├── CollisionShape2D └── LowPassFilter (スクリプトをアタッチしたノードでもよい)
やり方:
- 新規シーンを作成し、ルートに Area2D を追加(名前:
WaterArea)。 - 子として CollisionShape2D を追加し、水中エリアの範囲を設定。
- WaterArea に
LowPassFilter.gdをアタッチするか、
子ノードに Node を追加してそこにアタッチしてもOKです。
(コンポーネントとして分離したい場合は後者がおすすめ)
コンポーネントとして分けた場合のシーン構成図はこんな感じです:
WaterArea (Area2D) ├── CollisionShape2D └── LowPassFilter (Node) ← このノードにスクリプトをアタッチ
この場合は、LowPassFilter の extends を Area2D ではなく Node に変え、
owner を Area2D としてシグナル接続する形になります。
「継承より合成」派ならこちらの構成もアリですね。
手順③ インスペクタでパラメータを設定する
LowPassFilter ノードを選択し、インスペクタで次のように設定します:
- Target Bus
target_bus_name: 例としてMasterかSFXを指定。
- 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 から呼び出すようにすれば、潜るほど音がこもる表現も簡単に作れます。
コンポーネントをベースにしておけば、こうした拡張も「差分として足すだけ」で済むので、どんどん遊びやすくなりますね。




