同步游戏音频及音乐

前言

在任何应用程序或游戏中, 声音和音乐播放都会有轻微的延迟. 对于游戏, 这种延迟往往小到可以忽略不计. 在调用任意play()函数后, 声音效果将在几毫秒后出现. 对于音乐来说这并不重要, 因为在大多数游戏中它不会产生互动.

不过, 对于一些游戏(主要是节奏游戏), 可能会需要让玩家的操作与歌曲中发生的事情同步(通常与BPM同步). 因此, 得到一个具体播放位置的更精确的定时信息就很有用了.

极其精准地定位播放时间是非常困难的. 因为在音频回放过程中有很多因素在起作用:

  • 音频以块(不连续)的形式混合在一起, 具体取决于所使用的音频缓冲区的大小(在项目设置中检查延迟).

  • 混合的音频块不会立即播放.

  • 图形应用程序接口延迟显示两到三帧.

  • 当在电视上播放时, 由于图像处理可能会增加一些延迟.

最常见的减少延迟的方法是缩小音频缓冲区(同样是通过编辑项目设置中的延迟设置). 问题是, 当延迟很小时, 声音混合将占用大量的CPU. 这就增加了跳音的风险(由于混合回调丢失, 导致声音出现裂缝).

这是一种常见的折衷方案, 因此Godot附带了合理的默认值. 一般这些默认值不需要更改.

归根结底, 问题并不在于这一点点的延迟, 而是同步游戏的画面和声音. 从Godot 3.2开始, 加入了一些辅助工具, 帮助获取更精确的播放时间.

使用系统时钟同步

如前所述, 如果你调用 AudioStreamPlayer.play() , 声音不会立即开始播放, 而是在音频线程处理下一个块时开始.

这个延迟是无法避免的, 但是可以通过调用 AudioServer.get_time_to_next_mix() 来估算.

输出延迟(混音后的情况)可以通过调用 AudioServer.get_output_latency() 来估算.

把这两样加起来, 就可以几乎准确地猜到 _process() 中的音效或音乐什么时候开始在扬声器中播放:

GDScriptC#

  1. var time_begin
  2. var time_delay
  3. func _ready():
  4. time_begin = Time.get_ticks_usec()
  5. time_delay = AudioServer.get_time_to_next_mix() + AudioServer.get_output_latency()
  6. $Player.play()
  7. func _process(delta):
  8. # Obtain from ticks.
  9. var time = (Time.get_ticks_usec() - time_begin) / 1000000.0
  10. # Compensate for latency.
  11. time -= time_delay
  12. # May be below 0 (did not begin yet).
  13. time = max(0, time)
  14. print("Time is: ", time)
  1. private double _timeBegin;
  2. private double _timeDelay;
  3. public override void _Ready()
  4. {
  5. _timeBegin = Time.GetTicksUsec();
  6. _timeDelay = AudioServer.GetTimeToNextMix() + AudioServer.GetOutputLatency();
  7. GetNode<AudioStreamPlayer>("Player").Play();
  8. }
  9. public override void _Process(float _delta)
  10. {
  11. double time = (Time.GetTicksUsec() - _timeBegin) / 1000000.0d;
  12. time = Math.Max(0.0d, time - _timeDelay);
  13. GD.Print(string.Format("Time is: {0}", time));
  14. }

然而, 从长远来看, 由于声音硬件时钟从未与系统时钟完全同步, 计时信息会逐渐偏移.

在节奏游戏中, 一首歌持续时长仅有几分钟, 这种方法就很适合(也是推荐的方法). 而对于一款播放时间更长的游戏来说, 游戏最终将失去同步, 因此需要一种不同的方法.

使用声音硬件时钟同步

虽然可以使用 AudioStreamPlayer.get_playback_position() 来获取歌曲的当前位置, 但实际并没有那么实用. 这个值(每逢音频回调混合一块声音时)将以块为单位递增, 导致多次调用可能返回相同的值. 除此之外, 由于前面提到的原因, 该值也将与扬声器失去同步.

为了补偿 “chunked”(分块)输出, 有个函数能有所帮助: AudioServer.get_time_since_last_mix().

将这个函数的返回值与 get_playback_position() 相加可以提高精度:

GDScriptC#

  1. var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix()
  1. double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix();

为了提高精度, 减去延迟信息(音频从混合后到被听见花费的时间):

GDScriptC#

  1. var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix() - AudioServer.get_output_latency()
  1. double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix() - AudioServer.GetOutputLatency();

由于多线程的工作方式, 结果可能会有点轻微抖动. 只需检查该值是否小于前一帧中的值(如果小于, 则将其丢弃). 这个方法也不如之前的精确, 但它适用于任何长度的歌曲, 或者将任何东西(例如音效)与音乐同步.

下面是使用这种方法之前相同的代码:

GDScriptC#

  1. func _ready():
  2. $Player.play()
  3. func _process(delta):
  4. var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix()
  5. # Compensate for output latency.
  6. time -= AudioServer.get_output_latency()
  7. print("Time is: ", time)
  1. public override void _Ready()
  2. {
  3. GetNode<AudioStreamPlayer>("Player").Play();
  4. }
  5. public override void _Process(float _delta)
  6. {
  7. double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix();
  8. // Compensate for output latency.
  9. time -= AudioServer.GetOutputLatency();
  10. GD.Print(string.Format("Time is: {0}", time));
  11. }