Godotで「車がビューンと通り過ぎる」「弾丸が耳元をかすめる」みたいなシーンを作るとき、AudioStreamPlayer3Dをそのまま置いただけだと、どうしても「距離による音量変化」しか表現できないことが多いですよね。
「近づくときは音が高く、遠ざかるときは低く聞こえる」あのドップラー効果をちゃんと入れたいのに、毎回プレイヤーとの相対速度を計算して…とやっていると、各オブジェクトに似たようなコードをコピペするハメになります。
さらに、PlayerCar を継承した PoliceCar や EnemyCar にそれぞれドップラー処理を足していくと、クラス階層がどんどん重くなっていきがちです。
そこで今回は、「継承より合成」の思想で、ノードにポン付けできる DopplerEffect コンポーネント を作ってみましょう。
どんな移動ロジックのオブジェクトにもアタッチするだけで、相対速度に応じたピッチ変化を自動でかけてくれるようにします。
【Godot 4】通り過ぎる音が気持ちよくなる!「DopplerEffect」コンポーネント
今回のコンポーネントのゴール:
- プレイヤー(リスナー)とサウンド発生源の相対速度から、ピッチ(
pitch_scale)を自動計算 - 接近中はピッチを上げ、通過後に離れていくとピッチを下げる
AudioStreamPlayer3D/AudioStreamPlayer2Dの両方に対応できる設計- ノード階層に依存しない「コンポーネント」として、どのシーンにも再利用可能
Godot 4 には物理ベースのドップラー機能もありますが、「自分で制御したい」「2Dでも同じロジックを使いたい」「挙動を細かくカスタムしたい」といった場合に、スクリプトコンポーネントとして持っておくとかなり便利です。
フルコード:DopplerEffect.gd
extends Node
class_name DopplerEffect
@icon("res://icon.svg") # お好みで差し替え
##
# DopplerEffect コンポーネント
#
# - AudioStreamPlayer2D / 3D の pitch_scale を、相対速度に応じて自動調整します。
# - 「誰から見たドップラーか?」を listener_node で指定します(通常はプレイヤー)。
# - サウンド発生源は、このコンポーネントの親ノード(owner)として扱います。
#
## 観測者(リスナー)となるノード。
## 通常は Player(カメラ位置に近いノード)をドラッグ&ドロップで指定します。
@export_node_path var listener_path: NodePath
## サウンドを再生する AudioStreamPlayer2D / 3D への参照。
## 未設定の場合、親ノードから自動検出を試みます。
@export var audio_player: Node
## 音速[m/s]。ゲームスケールに合わせて調整します。
## 3D なら 343.0 付近、2D のミニマップならもっと小さくしてもOKです。
@export var speed_of_sound: float = 343.0
## 最大ピッチ倍率(接近時 / 離脱時の上限)。
## 例: 1.5 なら、1.0〜1.5 の範囲にクランプされます。
@export var max_pitch_scale: float = 1.5
## 最小ピッチ倍率。
## 例: 0.5 なら、0.5〜1.0 の範囲にクランプされます。
@export var min_pitch_scale: float = 0.5
## ピッチ変化の補間速度。大きいほど瞬時に変化します。
@export_range(0.0, 20.0, 0.1) var pitch_lerp_speed: float = 10.0
## 2D か 3D かを明示的に指定したい場合に使います。
## "auto" の場合は、AudioStreamPlayer の型から自動判定します。
@export_enum("auto", "2d", "3d") var space_mode: String = "auto"
## ドップラー効果を有効にするかどうか。
@export var enabled: bool = true
## デバッグ用: 計算された相対速度をエディタ上で確認したいときにオン。
@export var debug_print: bool = false
var _listener: Node3D
var _listener_2d: Node2D
var _source_3d: Node3D
var _source_2d: Node2D
var _base_pitch_scale: float = 1.0
var _current_pitch_scale: float = 1.0
func _ready() -> void:
# AudioPlayer が未設定なら、親ノードから自動検出
if audio_player == null:
audio_player = _find_audio_player(get_parent())
if audio_player == null:
push_warning("DopplerEffect: Audio player not found. Please assign 'audio_player'.")
else:
# ベースとなる pitch_scale を保存
if "pitch_scale" in audio_player:
_base_pitch_scale = audio_player.pitch_scale
_current_pitch_scale = _base_pitch_scale
else:
push_warning("DopplerEffect: Assigned audio_player has no 'pitch_scale' property.")
# リスナーを解決
_resolve_listener()
# 発生源(サウンドの位置)を解決
_resolve_source()
# 空間モードを自動判定
if space_mode == "auto":
if audio_player is AudioStreamPlayer3D or _source_3d != null:
space_mode = "3d"
elif audio_player is AudioStreamPlayer2D or _source_2d != null:
space_mode = "2d"
else:
# デフォルトは 3D として扱う
space_mode = "3d"
func _physics_process(delta: float) -> void:
if not enabled:
return
if audio_player == null:
return
if listener_path.is_empty():
return
var relative_speed := 0.0
if space_mode == "3d":
if _listener == null or _source_3d == null:
_resolve_listener()
_resolve_source()
if _listener == null or _source_3d == null:
return
relative_speed = _compute_relative_speed_3d(delta)
else:
if _listener_2d == null or _source_2d == null:
_resolve_listener()
_resolve_source()
if _listener_2d == null or _source_2d == null:
return
relative_speed = _compute_relative_speed_2d(delta)
# 相対速度からドップラー係数を計算してピッチに反映
var target_pitch := _base_pitch_scale * _doppler_factor(relative_speed)
# ピッチ変化をなめらかに補間
_current_pitch_scale = lerp(_current_pitch_scale, target_pitch, clamp(pitch_lerp_speed * delta, 0.0, 1.0))
# クランプ
_current_pitch_scale = clamp(_current_pitch_scale, _base_pitch_scale * min_pitch_scale, _base_pitch_scale * max_pitch_scale)
# 実際に AudioStreamPlayer に反映
if "pitch_scale" in audio_player:
audio_player.pitch_scale = _current_pitch_scale
if debug_print:
print("DopplerEffect: relative_speed=", relative_speed, " pitch=", _current_pitch_scale)
func _resolve_listener() -> void:
if listener_path.is_empty():
return
var node := get_node_or_null(listener_path)
if node == null:
push_warning("DopplerEffect: Listener node not found at path: %s" % listener_path)
return
if node is Node3D:
_listener = node
_listener_2d = null
elif node is Node2D:
_listener_2d = node
_listener = null
else:
push_warning("DopplerEffect: Listener node must be Node2D or Node3D.")
func _resolve_source() -> void:
var parent := get_parent()
if parent is Node3D:
_source_3d = parent
_source_2d = null
elif parent is Node2D:
_source_2d = parent
_source_3d = null
else:
# どちらでもない場合は、このノード自身を参照(あまりない想定)
if self is Node3D:
_source_3d = self
elif self is Node2D:
_source_2d = self
## 親ノード以下から AudioStreamPlayer2D / 3D を探します。
func _find_audio_player(root: Node) -> Node:
if root == null:
return null
if root is AudioStreamPlayer3D or root is AudioStreamPlayer2D:
return root
for child in root.get_children():
var found := _find_audio_player(child)
if found != null:
return found
return null
## 3D 空間での相対速度を計算
func _compute_relative_speed_3d(delta: float) -> float:
if delta <= 0.0:
return 0.0
var source_pos: Vector3 = _source_3d.global_position
var listener_pos: Vector3 = _listener.global_position
# 速度を近似: v ≒ Δx / Δt
var source_vel: Vector3 = (_source_3d.global_position - _source_3d.global_position) / delta
var listener_vel: Vector3 = (_listener.global_position - _listener.global_position) / delta
# 上の書き方だと常にゼロになるので、本来は前フレーム位置を保存して使うべきですが、
# シンプルにするために Godot の物理ボディが持つ linear_velocity を優先して利用します。
if "linear_velocity" in _source_3d:
source_vel = _source_3d.linear_velocity
if "linear_velocity" in _listener:
listener_vel = _listener.linear_velocity
var rel_vel: Vector3 = source_vel - listener_vel
var dir: Vector3 = (listener_pos - source_pos).normalized()
# 観測者から見て「どれだけ近づく / 離れる方向に動いているか」をスカラーで取得
var relative_speed := rel_vel.dot(dir)
# relative_speed > 0: 接近中(音が高く) / < 0: 離脱中(音が低く)
return relative_speed
## 2D 空間での相対速度を計算
func _compute_relative_speed_2d(delta: float) -> float:
if delta <= 0.0:
return 0.0
var source_pos: Vector2 = _source_2d.global_position
var listener_pos: Vector2 = _listener_2d.global_position
var source_vel: Vector2 = Vector2.ZERO
var listener_vel: Vector2 = Vector2.ZERO
if "linear_velocity" in _source_2d:
source_vel = _source_2d.linear_velocity
if "linear_velocity" in _listener_2d:
listener_vel = _listener_2d.linear_velocity
var rel_vel: Vector2 = source_vel - listener_vel
var dir: Vector2 = (listener_pos - source_pos).normalized()
var relative_speed := rel_vel.dot(dir)
return relative_speed
## 相対速度[m/s]からドップラー係数を計算
##
## シンプルなモデル:
## f' = f * (c / (c - v_rel))
## v_rel > 0: 接近(f' が大きく) / v_rel < 0: 離脱(f' が小さく)
##
func _doppler_factor(relative_speed: float) -> float:
if speed_of_sound <= 0.0:
return 1.0
# 極端な値を防ぐためにクランプ
var v := clamp(relative_speed, -speed_of_sound * 0.9, speed_of_sound * 0.9)
var factor := speed_of_sound / (speed_of_sound - v)
return factor
## 外部からベースピッチを変更したい場合に呼び出します。
func set_base_pitch_scale(pitch: float) -> void:
_base_pitch_scale = pitch
_current_pitch_scale = pitch
使い方の手順
ここでは 3D の「車がプレイヤーの横を通過する」シーンと、2D の「高速で飛ぶ弾丸」の例で使い方を見ていきます。
手順① コンポーネントスクリプトを用意する
- 上記の
DopplerEffect.gdをプロジェクト内に保存します(例:res://components/audio/DopplerEffect.gd)。 - Godot エディタで「スクリプト」タブから開き、エラーがないか確認します。
手順② 3D 車オブジェクトにアタッチする例
シーン構成例:
Player (CharacterBody3D) ├── Camera3D └── AudioListener3D (※カメラに付けてもOK) TrafficCar (RigidBody3D) ├── MeshInstance3D ├── CollisionShape3D ├── EngineSound (AudioStreamPlayer3D) └── DopplerEffect (Node)
TrafficCarシーンを開き、子ノードとしてNodeを追加し、名前をDopplerEffectにします。- そのノードに
DopplerEffect.gdをアタッチします。 - インスペクタで以下を設定します:
- listener_path:
../..../Player(シーンツリーからドラッグ&ドロップ) - audio_player:
EngineSoundノードを指定(未指定でも自動検出される想定) - speed_of_sound: 343.0(ゲームスケールに合わせて調整)
- max_pitch_scale: 1.4 くらい
- min_pitch_scale: 0.7 くらい
- space_mode: “auto” のままでOK(3D と判定されます)
- listener_path:
TrafficCarには物理挙動としてlinear_velocityがあるので、そのまま相対速度が計算されます。
これで、車がプレイヤーに向かって走ってくるとエンジン音のピッチが少し高くなり、すれ違って遠ざかるとピッチが低くなっていきます。
プレイヤー側が動いていても、Player の linear_velocity を拾って相対速度を計算するので、追い抜き・追い越されるシチュエーションでもそれっぽくなります。
手順③ 2D 弾丸にアタッチする例
2D シューティングで、弾がプレイヤーの近くを通るたびに「ヒューン」と音程が変わる表現をしたい場合:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── AudioListener2D (任意) Bullet (RigidBody2D or CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── BulletSound (AudioStreamPlayer2D) └── DopplerEffect (Node)
Bulletシーンを開き、子ノードとしてNodeを追加し、DopplerEffectスクリプトをアタッチ。- インスペクタで:
- listener_path:
../..../Player - audio_player:
BulletSound - space_mode: “auto”(2D と判定)
- speed_of_sound: ゲームスケールに合わせて 200〜400 あたりから調整
- listener_path:
BulletがRigidBody2DやCharacterBody2Dであれば、linear_velocityから速度を取得できます。
弾丸がプレイヤーの手前から向こう側へ抜けていくとき、近づく間はピッチが高く、通り過ぎてからは低くなっていきます。
「音の通過感」が出るだけで、ゲームの臨場感がかなり変わりますね。
手順④ プレイヤー以外をリスナーにする応用
例えば「監視カメラ視点でドップラーを感じたい」「特定の NPC から見た音をシミュレーションしたい」といった場合は、listener_path にそのノードを指定するだけでOKです。
SecurityCamera (Node3D) ├── Camera3D └── AudioListener3D Drone (RigidBody3D) ├── MeshInstance3D ├── PropellerSound (AudioStreamPlayer3D) └── DopplerEffect (Node) # listener_path = ../../SecurityCamera
このように、「誰から見たドップラーか?」を差し替えるだけで、同じコンポーネントを様々なシチュエーションに再利用できます。
メリットと応用
この DopplerEffect コンポーネントを導入するメリットを整理してみましょう。
- シーン構造がスッキリする
車、弾丸、ドローンなど、音を出すオブジェクトごとに「ドップラー計算ロジック」を書く必要がなくなります。
各オブジェクトは「移動のことだけ考える」、DopplerEffectは「音程のことだけ考える」という分離ができて、メンテナンス性が上がります。 - 継承に縛られない
CarWithDoppler,BulletWithDopplerのような派生クラスを増やさなくて済みます。
どんなベースクラスのノードでも、DopplerEffectノードを子としてアタッチするだけで機能追加できるので、「継承ツリーが伸びすぎる問題」を回避できます。 - 2D / 3D を同じ思想で扱える
space_modeやlistener_pathを変えるだけで、2D/3D 両方のシーンに同じコンポーネントを使い回せます。
プロジェクトが 2D と 3D をまたいでいても、ロジックを共有しやすくなります。 - ゲームスケールに合わせて簡単にチューニング
speed_of_sound,max_pitch_scale,min_pitch_scale,pitch_lerp_speedをいじるだけで、
「アニメ的にオーバーなドップラー」から「リアル寄りな控えめドップラー」まで簡単に調整できます。
改造案:距離に応じてドップラー効果を弱める
「遠くで聞こえる音には、ドップラー効果をあまり効かせたくない」という場合、距離に応じて係数をブレンドするのがおすすめです。
以下のような関数を追加して、_physics_process 内で target_pitch を計算するときに使ってみてください。
## 距離に応じてドップラー効果の強さを調整する例
## max_effect_distance: この距離まではフルで効かせ、それ以上は徐々に弱める。
func _apply_distance_fade(doppler_factor_value: float, distance: float, max_effect_distance: float = 50.0) -> float:
if max_effect_distance <= 0.0:
return doppler_factor_value
var t := clamp(distance / max_effect_distance, 0.0, 1.0)
# t=0 のとき 100% ドップラー、t=1 で 0%(ピッチ1.0)になるように補間
return lerp(doppler_factor_value, 1.0, t)
3D の場合なら distance = _source_3d.global_position.distance_to(_listener.global_position) のように距離を計算して、
_doppler_factor() の戻り値にこの関数をかけてあげると、近距離でだけ強いドップラーがかかる自然な挙動になります。
こんな感じで、コンポーネントをベースに少しずつ改造していくと、「自分のゲーム専用のサウンド演出ライブラリ」が育っていきます。
継承ベースの巨大クラスにロジックを詰め込むより、こうした小さなコンポーネントを組み合わせていく方が、Godot 4 では圧倒的に開発しやすいので、ぜひ試してみてください。




