【Godot 4】DopplerEffect (ドップラー効果) コンポーネントの作り方

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で「車がビューンと通り過ぎる」「弾丸が耳元をかすめる」みたいなシーンを作るとき、AudioStreamPlayer3Dをそのまま置いただけだと、どうしても「距離による音量変化」しか表現できないことが多いですよね。
「近づくときは音が高く、遠ざかるときは低く聞こえる」あのドップラー効果をちゃんと入れたいのに、毎回プレイヤーとの相対速度を計算して…とやっていると、各オブジェクトに似たようなコードをコピペするハメになります。

さらに、PlayerCar を継承した PoliceCarEnemyCar にそれぞれドップラー処理を足していくと、クラス階層がどんどん重くなっていきがちです。
そこで今回は、「継承より合成」の思想で、ノードにポン付けできる 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 の「高速で飛ぶ弾丸」の例で使い方を見ていきます。

手順① コンポーネントスクリプトを用意する

  1. 上記の DopplerEffect.gd をプロジェクト内に保存します(例: res://components/audio/DopplerEffect.gd)。
  2. Godot エディタで「スクリプト」タブから開き、エラーがないか確認します。

手順② 3D 車オブジェクトにアタッチする例

シーン構成例:

Player (CharacterBody3D)
 ├── Camera3D
 └── AudioListener3D  (※カメラに付けてもOK)

TrafficCar (RigidBody3D)
 ├── MeshInstance3D
 ├── CollisionShape3D
 ├── EngineSound (AudioStreamPlayer3D)
 └── DopplerEffect (Node)
  1. TrafficCar シーンを開き、子ノードとして Node を追加し、名前を DopplerEffect にします。
  2. そのノードに DopplerEffect.gd をアタッチします。
  3. インスペクタで以下を設定します:
    • 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 と判定されます)
  4. TrafficCar には物理挙動として linear_velocity があるので、そのまま相対速度が計算されます。

これで、車がプレイヤーに向かって走ってくるとエンジン音のピッチが少し高くなり、すれ違って遠ざかるとピッチが低くなっていきます。
プレイヤー側が動いていても、Playerlinear_velocity を拾って相対速度を計算するので、追い抜き・追い越されるシチュエーションでもそれっぽくなります。

手順③ 2D 弾丸にアタッチする例

2D シューティングで、弾がプレイヤーの近くを通るたびに「ヒューン」と音程が変わる表現をしたい場合:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── AudioListener2D (任意)

Bullet (RigidBody2D or CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── BulletSound (AudioStreamPlayer2D)
 └── DopplerEffect (Node)
  1. Bullet シーンを開き、子ノードとして Node を追加し、DopplerEffect スクリプトをアタッチ。
  2. インスペクタで:
    • listener_path: ../..../Player
    • audio_player: BulletSound
    • space_mode: “auto”(2D と判定)
    • speed_of_sound: ゲームスケールに合わせて 200〜400 あたりから調整
  3. BulletRigidBody2DCharacterBody2D であれば、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_modelistener_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 では圧倒的に開発しやすいので、ぜひ試してみてください。

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