Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

將遊戲流程與音效及音樂同步

前言

在任何應用程式或遊戲中,聲音與音樂播放都會有些微延遲。對大多數遊戲來說,這種延遲通常小到可以忽略。每次呼叫 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)

但長期來看,聲音硬體的時鐘與系統時鐘永遠無法完全同步,因此計時資訊會隨時間逐漸產生誤差。

對於一首歌只有幾分鐘長的節奏遊戲來說,這種作法已經很夠用(也是推薦的作法)。但如果你的遊戲會連續播放很長時間,最終還是會發生音畫不同步的狀況,這時就需要採用不同的同步方法。

使用聲音硬體時鐘進行同步

看似可以利用 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()

若要進一步提升精度,還可以減去延遲資訊(即混音後到實際聽到聲音所需的時間):

var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix() - AudioServer.get_output_latency()

由於多執行緒機制,這個結果可能會有些微跳動。只要檢查該值有無小於前一畫格(如果有就忽略),即可。雖然這種方法比前述方式精度略低,但能支援任意長度的歌曲,或將其他音效等與音樂同步。

以下是採用這種方式的範例程式碼:

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)