Godot 4でSE(効果音)を鳴らしていると、よくある悩みが「同じ音が鳴りすぎてうるさい」「爆発音を連打するとミックスがグチャグチャになる」みたいな問題ですね。
素直に実装すると、各オブジェクトに AudioStreamPlayer や AudioStreamPlayer2D を継承したノードを生やして、必要なたびに play() を呼ぶ…という形になりがちです。
でもこのやり方だと:
- プレイヤー、敵、ギミックごとにプレイヤーを大量に持ってしまう
- 「同じSEの同時発音数」を一括で制御しづらい
- シーンツリーが「AudioStreamPlayerだらけ」になって管理がつらい
しかも、同じ音を連打すると、10個20個と重なってミックスが破綻してしまいます。
そこで「継承でAudioStreamPlayerを増やす」のではなく、「音の同時発音数を管理するコンポーネント」をシーンに1つポンと置いて、必要なノードからそこに「再生リクエスト」を投げる方式にしてしまいましょう。
今回紹介する AudioPool (同時発音制限) コンポーネントは、まさにそれをやるための小さなヘルパーです。
【Godot 4】SEの鳴らしすぎを一括制御!「AudioPool」コンポーネント
このコンポーネントは:
- シーン内に複数の
AudioStreamPlayer/AudioStreamPlayer2D/AudioStreamPlayer3Dをプールとして持つ - 同じ
AudioStreamごとに「同時発音数」を制限する - すでに上限まで鳴っている音は、条件に応じて「再生をスキップ」または「古いものを止めて差し替え」できる
という、いわゆる「SEマネージャー」をコンポーネントとして切り出したものです。
フルコード:AudioPool.gd
extends Node
class_name AudioPool
## 同時発音数を制御するオーディオプールコンポーネント
##
## - シーンに1つ置いて、他のノードから再生要求を投げる
## - 同じAudioStreamごとに同時再生数を制限する
## - 2D/3D/普通のAudioStreamPlayerを選択可能
## 使用するプレイヤーの種類
## "2D" なら AudioStreamPlayer2D
## "3D" なら AudioStreamPlayer3D
## それ以外は通常の AudioStreamPlayer
@export_enum("Normal", "2D", "3D")
var player_type: String = "Normal"
## プール全体で確保しておくプレイヤー数
## 大きくしすぎると無駄にリソースを食うので、ゲームの規模に合わせて調整
@export_range(1, 128, 1)
var pool_size: int = 16
## 1つのAudioStreamにつき、同時に鳴らしてよい最大数
## 例: 3 にすると、同じSEが4つ以上重ならないように制限される
@export_range(1, 32, 1)
var max_voices_per_stream: int = 4
## 同時発音数を超えたときの挙動
## - "skip" : 追加再生は行わない
## - "steal_oldest" : 一番古く鳴っているプレイヤーを止めて再利用する
@export_enum("skip", "steal_oldest")
var overflow_policy: String = "skip"
## 再生する音量のベース値(dB)
## 個別の再生時にオプションで上書き可能
@export_range(-40.0, 6.0, 0.1)
var base_volume_db: float = 0.0
## 再生するピッチのベース値
## 1.0 が等倍。個別再生でランダムピッチを付けるときの基準
@export_range(0.1, 4.0, 0.01)
var base_pitch_scale: float = 1.0
## デバッグ用: 再生/スキップのログを出すかどうか
@export var debug_log: bool = false
## 内部構造
## プール内のプレイヤー
var _players: Array[Node] = []
## 各プレイヤーが今再生しているAudioStream
var _player_streams: Array[AudioStream] = []
## 各プレイヤーの「最後に再生開始した時間(秒)」 - 古いものから奪うときに使用
var _player_start_times: Array[float] = []
## 同じAudioStreamごとの「現在の同時発音数」をカウントする辞書
## key: AudioStream, value: int
var _stream_voice_count: Dictionary = {}
func _ready() -> void:
_create_pool()
if debug_log:
print("[AudioPool] Initialized with %d players, type=%s" % [pool_size, player_type])
## プールとなるAudioStreamPlayer系ノードを生成する
func _create_pool() -> void:
_players.clear()
_player_streams.clear()
_player_start_times.clear()
for i in pool_size:
var player := _instantiate_player()
add_child(player)
player.bus = "SFX" if "SFX" in AudioServer.get_bus_list() else player.bus
player.finished.connect(_on_player_finished.bind(i))
_players.append(player)
_player_streams.append(null)
_player_start_times.append(0.0)
## プレイヤーを種類に応じて生成
func _instantiate_player() -> Node:
match player_type:
"2D":
return AudioStreamPlayer2D.new()
"3D":
return AudioStreamPlayer3D.new()
_:
return AudioStreamPlayer.new()
## 公開API: 効果音を再生する
##
## @param stream: 再生したいAudioStream
## @param volume_db: 省略時はbase_volume_dbを使用
## @param pitch_scale: 省略時はbase_pitch_scaleを使用
## @param position: 2D/3Dプレイヤーの場合のみ使用(グローバル座標)
##
## 使用例:
## AudioPool.play_sound(hit_sound)
## AudioPool.play_sound(explosion_sound, volume_db = -3.0, pitch_scale = randf_range(0.9, 1.1))
func play_sound(
stream: AudioStream,
volume_db: float = NAN,
pitch_scale: float = NAN,
position: Variant = null
) -> void:
if stream == null:
push_warning("[AudioPool] play_sound called with null stream")
return
# 現在の同時発音数を取得
var current_voices := _stream_voice_count.get(stream, 0)
if current_voices >= max_voices_per_stream:
# すでに上限に達している場合の処理
match overflow_policy:
"skip":
if debug_log:
print("[AudioPool] Skip playing stream (limit reached): ", stream)
return
"steal_oldest":
var idx := _find_oldest_player_for_stream(stream)
if idx == -1:
# 安全策: 見つからなければ普通にスキップ
if debug_log:
print("[AudioPool] No player to steal, skipping: ", stream)
return
_stop_player(idx)
_start_player(idx, stream, volume_db, pitch_scale, position)
return
_:
# 未知のポリシーはスキップ扱い
return
# まだ余裕があるので、空いているプレイヤーを探す
var free_idx := _find_free_player()
if free_idx == -1:
# 全部埋まっている場合、ポリシーに関係なく「一番古いプレイヤー」を奪う
free_idx = _find_oldest_player_global()
if free_idx == -1:
if debug_log:
print("[AudioPool] No available player at all, skipping: ", stream)
return
_start_player(free_idx, stream, volume_db, pitch_scale, position)
## 内部: 実際にプレイヤーで再生を開始する
func _start_player(
idx: int,
stream: AudioStream,
volume_db: float,
pitch_scale: float,
position: Variant
) -> void:
var player := _players[idx]
# 型チェックしてキャスト
var p_normal := player as AudioStreamPlayer
var p2d := player as AudioStreamPlayer2D
var p3d := player as AudioStreamPlayer3D
if p_normal:
p_normal.stream = stream
p_normal.volume_db = is_nan(volume_db) ? base_volume_db : volume_db
p_normal.pitch_scale = is_nan(pitch_scale) ? base_pitch_scale : pitch_scale
p_normal.play()
elif p2d:
p2d.stream = stream
p2d.volume_db = is_nan(volume_db) ? base_volume_db : volume_db
p2d.pitch_scale = is_nan(pitch_scale) ? base_pitch_scale : pitch_scale
if position != null and typeof(position) == TYPE_VECTOR2:
p2d.global_position = position
p2d.play()
elif p3d:
p3d.stream = stream
p3d.volume_db = is_nan(volume_db) ? base_volume_db : volume_db
p3d.pitch_scale = is_nan(pitch_scale) ? base_pitch_scale : pitch_scale
if position != null and typeof(position) == TYPE_VECTOR3:
p3d.global_position = position
p3d.play()
else:
push_warning("[AudioPool] Unknown player type at index %d" % idx)
return
# 状態更新
_player_streams[idx] = stream
_player_start_times[idx] = Time.get_unix_time_from_system()
# カウンタ更新
_stream_voice_count[stream] = _stream_voice_count.get(stream, 0) + 1
if debug_log:
print("[AudioPool] Play stream: ", stream, " on idx=", idx, " voices=",
_stream_voice_count[stream])
## 内部: プレイヤーを停止し、カウンタを減らす
func _stop_player(idx: int) -> void:
if idx < 0 or idx >= _players.size():
return
var player := _players[idx]
var stream := _player_streams[idx]
# 実際に停止
var p_normal := player as AudioStreamPlayer
var p2d := player as AudioStreamPlayer2D
var p3d := player as AudioStreamPlayer3D
if p_normal: p_normal.stop()
if p2d: p2d.stop()
if p3d: p3d.stop()
# カウンタを減らす
if stream != null and _stream_voice_count.has(stream):
_stream_voice_count[stream] -= 1
if _stream_voice_count[stream] <= 0:
_stream_voice_count.erase(stream)
_player_streams[idx] = null
_player_start_times[idx] = 0.0
## 各プレイヤーのfinishedシグナルから呼ばれる
func _on_player_finished(idx: int) -> void:
_stop_player(idx)
if debug_log:
print("[AudioPool] Player finished idx=", idx)
## 空いている(再生していない)プレイヤーを探す
func _find_free_player() -> int:
for i in _players.size():
var player := _players[i]
var p_normal := player as AudioStreamPlayer
var p2d := player as AudioStreamPlayer2D
var p3d := player as AudioStreamPlayer3D
var is_playing := false
if p_normal:
is_playing = p_normal.playing
elif p2d:
is_playing = p2d.playing
elif p3d:
is_playing = p3d.playing
if not is_playing:
return i
return -1
## 指定したAudioStreamを鳴らしているプレイヤーのうち、
## 「最も古く再生を開始したもの」のインデックスを返す
func _find_oldest_player_for_stream(stream: AudioStream) -> int:
var oldest_time := INF
var oldest_idx := -1
for i in _players.size():
if _player_streams[i] == stream:
var t := _player_start_times[i]
if t < oldest_time:
oldest_time = t
oldest_idx = i
return oldest_idx
## 全プレイヤーの中で「最も古く再生を開始したもの」のインデックスを返す
func _find_oldest_player_global() -> int:
var oldest_time := INF
var oldest_idx := -1
for i in _players.size():
if _player_streams[i] != null:
var t := _player_start_times[i]
if t < oldest_time:
oldest_time = t
oldest_idx = i
return oldest_idx
使い方の手順
ここからは、実際のシーンにコンポーネントとして組み込む手順を見ていきましょう。
① AudioPoolノードをシーンに追加する
まずは、ゲーム全体をまとめている「メインシーン」や、「ステージシーン」に AudioPool を1個だけ置きます。
(2Dゲームを想定して AudioStreamPlayer2D を使う例にします)
Main (Node) ├── Player (CharacterBody2D) ├── EnemySpawner (Node) ├── AudioPool (Node) ← このノードにAudioPool.gdをアタッチ └── その他...
AudioPoolノードを追加し、スクリプトにAudioPool.gdを指定- インスペクタで以下を設定
- player_type:
2D - pool_size: 16 くらいからスタート
- max_voices_per_stream: 3 〜 5 くらいが無難
- overflow_policy: とりあえず
skip推奨
- player_type:
これで、シーン内に「SEを鳴らすための共通プール」が1つできあがりました。
② スクリプトからAudioPoolを取得する
プレイヤーや敵など、SEを鳴らしたいノードから AudioPool にアクセスします。
パスはプロジェクトの構成次第ですが、例として ../AudioPool にあるとします。
# Player.gd (例)
extends CharacterBody2D
@export var hit_sound: AudioStream
var audio_pool: AudioPool
func _ready() -> void:
audio_pool = get_node("../AudioPool") as AudioPool
これで Player から audio_pool.play_sound(...) が呼べるようになります。
③ 実際にSEを鳴らす
例えば、プレイヤーが敵に当たったときにヒット音を鳴らす処理はこんな感じです:
func _on_hit_enemy() -> void:
if audio_pool and hit_sound:
# 軽くピッチをランダムにして耳障りを減らす
var pitch := randf_range(0.95, 1.05)
audio_pool.play_sound(
hit_sound,
volume_db = -2.0,
pitch_scale = pitch,
position = global_position # 2DなのでVector2を渡す
)
敵側も同じように書けます。
# Enemy.gd (例)
extends CharacterBody2D
@export var death_sound: AudioStream
var audio_pool: AudioPool
func _ready() -> void:
audio_pool = get_node("../AudioPool") as AudioPool
func die() -> void:
if audio_pool and death_sound:
audio_pool.play_sound(
death_sound,
volume_db = -4.0,
pitch_scale = randf_range(0.9, 1.1),
position = global_position
)
queue_free()
④ シーン構成図の具体例
プレイヤー、敵、動く床が全部同じ AudioPool を共有している例です。
Main (Node)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Player.gd
├── Enemy (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Enemy.gd
├── MovingPlatform (Node2D)
│ ├── Sprite2D
│ └── MovingPlatform.gd
└── AudioPool (Node)
└── AudioPool.gd ← コンポーネントとしてアタッチ
例えば MovingPlatform.gd では「床が端に到達したときのカチッ音」を鳴らしたいだけですが、
ここでも AudioStreamPlayer2D を持たせるのではなく、AudioPool に投げるだけでOKです。
# MovingPlatform.gd (例)
extends Node2D
@export var move_sound: AudioStream
var audio_pool: AudioPool
func _ready() -> void:
audio_pool = get_node("../AudioPool") as AudioPool
func _on_reach_edge() -> void:
if audio_pool and move_sound:
audio_pool.play_sound(move_sound, volume_db = -6.0, position = global_position)
これで、どのオブジェクトからでも 「AudioPoolに再生を依頼するだけ」 という統一インターフェイスになります。
各ノードがそれぞれAudioStreamPlayerを抱え込む必要はありません。
メリットと応用
継承ベースの「各ノードにAudioStreamPlayerを1個ずつ持たせる」方式 と比べると、このコンポーネント方式にはかなりのメリットがあります。
- シーン構造がスッキリ
PlayerやEnemyの子にAudioStreamPlayerが増殖しないので、ツリーが見やすくなります。
「どのノードがどのバスに出しているのか」も、AudioPool側だけ見ればOKです。 - 同時発音数の制御が一括でできる
「爆発SEは3つまで」「足音は5つまで」などをmax_voices_per_streamで制御できるので、
音が重なりすぎてミックスが破綻するのを防げます。 - 使い回しが効く
このAudioPoolコンポーネントを別プロジェクトに持っていっても、そのまま使えます。
依存関係が薄いので、プラグイン的に扱えますね。 - テスト・デバッグがしやすい
デバッグログをONにしておけば、「今どのSEが何個鳴っているか」を簡単に確認できます。
「このSEだけやたら多く鳴ってるな?」といった問題も見つけやすくなります。
つまり、「各オブジェクトに音の再生ロジックを継承で抱え込ませる」のではなく、
「音の再生という責務」をAudioPoolというコンポーネントに委譲する ことで、
Godotプロジェクト全体の構造がかなりクリーンになります。
改造案:ストリームごとの個別設定(ボリューム・上限)を持たせる
さらに踏み込んで、「SEごとに上限数や音量を変えたい」という場合は、辞書で設定を持たせるのがおすすめです。
ざっくりした改造例を示します。
# AudioPool.gd 内に追加する例
## SEごとの個別設定
## key: AudioStream
## value: { "max_voices": int, "volume_db": float }
var per_stream_config: Dictionary = {}
func set_stream_config(stream: AudioStream, max_voices: int, volume_db: float) -> void:
per_stream_config[stream] = {
"max_voices": max_voices,
"volume_db": volume_db,
}
func _get_max_voices_for(stream: AudioStream) -> int:
if per_stream_config.has(stream):
return per_stream_config[stream].get("max_voices", max_voices_per_stream)
return max_voices_per_stream
func _get_base_volume_for(stream: AudioStream) -> float:
if per_stream_config.has(stream):
return per_stream_config[stream].get("volume_db", base_volume_db)
return base_volume_db
あとは play_sound() 内で max_voices_per_stream や base_volume_db の代わりに、_get_max_voices_for(stream) や _get_base_volume_for(stream) を参照するように書き換えれば、
「爆発だけは同時発音2つまで」「UIクリック音は小さめ」など、より細かい調整ができるようになります。
こんな感じで、AudioPoolコンポーネントをベースに、自分のゲームに合わせた「音の設計」をどんどん合成していきましょう。
