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 提供了合理的默认值,无需更改。

说到底,问题不在于这种轻微的延迟,而在于为有需要的游戏同步图形和音频。有一些辅助工具可以帮助获得更精确的播放时间。

使用系统时钟同步

如前所述,如果调用 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() 来获取歌曲当前位置听起来很理想,但这样做并没有那么有用。该值以块为单位递增(每次音频回调混合好一个音频块时),因此许多次调用都可能返回相同的值。除此之外,由于前面提到的原因,该值也会与扬声器不同步。

为了补偿“分块”输出,有一个函数可以提供帮助: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)