Work in progress
The content of this page was not yet updated for Godot
4.4
and may be outdated. If you know how to improve this page or you can confirm
that it's up to date, feel free to open a pull request.
將遊戲流程與音效及音樂同步
前言
在任何應用程式或遊戲中,聲音與音樂播放都會有些微延遲。對大多數遊戲來說,這種延遲通常小到可以忽略。每次呼叫 play() 函式後,音效會在幾毫秒後才發出。對於音樂而言,這通常不是問題,因為在多數遊戲中音樂不會與遊戲流程直接互動。
但對某些遊戲(主要是節奏遊戲)來說,可能需要讓玩家的操作與歌曲內發生的事件同步(通常要對齊 BPM 節奏)。這時,取得更精確的播放位置時間資訊就非常重要。
要達到極低的播放時序誤差其實很困難,因為音訊播放過程中有很多因素會影響同步:
音訊是以區塊(非連續)進行混音的,這取決於所設定的音訊緩衝區大小(可於專案設定檢查延遲)。
混音後的音訊區塊不會立即播放。
圖形 API 通常會延遲兩到三個畫格才顯示。
在電視上播放時,影像處理流程可能會再增加些許延遲。
最常見的減少延遲方式是縮小音訊緩衝區(同樣是在專案設定中調整延遲參數)。但如果延遲設得太小,混音時會消耗更多 CPU 資源,這樣反而容易導致音訊破音(因混音回呼來不及處理而跳音)。
這是一種常見的取捨,因此 Godot 預設值已經相當合理,通常無需調整。
其實問題不在於這些微小的延遲,而是在於對於需要音畫同步的遊戲,如何讓遊戲畫面與音訊真正同步。Godot 提供一些輔助方法,可以取得更精確的播放時序資訊。
使用系統時鐘進行同步
如前所述,如果你呼叫 AudioStreamPlayer.play(),聲音並不會立即播放,而是等到音訊執行緒處理到下一個音訊區塊時才開始。
這個延遲無法避免,但可以透過呼叫 AudioServer.get_time_to_next_mix() 來預估。
輸出延遲(即混音之後到實際發聲的延遲)也能透過呼叫 AudioServer.get_output_latency() 來預估。
將這兩個值加總後,就能在 _process() 內大致推算出音效或音樂何時會於喇叭開始播放:
var time_begin
var time_delay
func _ready():
time_begin = Time.get_ticks_usec()
time_delay = AudioServer.get_time_to_next_mix() + AudioServer.get_output_latency()
$Player.play()
func _process(delta):
# Obtain from ticks.
var time = (Time.get_ticks_usec() - time_begin) / 1000000.0
# Compensate for latency.
time -= time_delay
# May be below 0 (did not begin yet).
time = max(0, time)
print("Time is: ", time)
private double _timeBegin;
private double _timeDelay;
public override void _Ready()
{
_timeBegin = Time.GetTicksUsec();
_timeDelay = AudioServer.GetTimeToNextMix() + AudioServer.GetOutputLatency();
GetNode<AudioStreamPlayer>("Player").Play();
}
public override void _Process(double delta)
{
double time = (Time.GetTicksUsec() - _timeBegin) / 1000000.0d;
time = Math.Max(0.0d, time - _timeDelay);
GD.Print(string.Format("Time is: {0}", time));
}
但長期來看,聲音硬體的時鐘與系統時鐘永遠無法完全同步,因此計時資訊會隨時間逐漸產生誤差。
對於一首歌只有幾分鐘長的節奏遊戲來說,這種作法已經很夠用(也是推薦的作法)。但如果你的遊戲會連續播放很長時間,最終還是會發生音畫不同步的狀況,這時就需要採用不同的同步方法。
使用聲音硬體時鐘進行同步
看似可以利用 AudioStreamPlayer.get_playback_position() 取得歌曲目前播放位置,但實際上並不完全可行。這個值會以區塊為單位遞增(每當音訊回呼處理一塊音訊時),因此多次查詢可能會拿到同樣的結果。此外,這個值也可能因前述原因而與實際喇叭播放時間不同步。
為補償這種「區塊化」輸出,Godot 提供一個輔助函式 AudioServer.get_time_since_last_mix() 。
將這個函式的回傳值加到 get_playback_position() 上可以提升時序精度:
var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix()
double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix();
若要進一步提升精度,還可以減去延遲資訊(即混音後到實際聽到聲音所需的時間):
var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix() - AudioServer.get_output_latency()
double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix() - AudioServer.GetOutputLatency();
由於多執行緒機制,這個結果可能會有些微跳動。只要檢查該值有無小於前一畫格(如果有就忽略),即可。雖然這種方法比前述方式精度略低,但能支援任意長度的歌曲,或將其他音效等與音樂同步。
以下是採用這種方式的範例程式碼:
func _ready():
$Player.play()
func _process(delta):
var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix()
# Compensate for output latency.
time -= AudioServer.get_output_latency()
print("Time is: ", time)
public override void _Ready()
{
GetNode<AudioStreamPlayer>("Player").Play();
}
public override void _Process(double delta)
{
double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix();
// Compensate for output latency.
time -= AudioServer.GetOutputLatency();
GD.Print(string.Format("Time is: {0}", time));
}