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.
Checking the stable version of the documentation...
将游戏玩法与音频和音乐同步
介绍
在任何应用程序或游戏中,声音和音乐播放都会有轻微的延迟。对于游戏来说,这种延迟往往小到可以忽略不计。音效会在调用任何 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)
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() 来获取歌曲当前位置听起来很理想,但这样做并没有那么有用。该值以块为单位递增(每次音频回调混合好一个音频块时),因此许多次调用都可能返回相同的值。除此之外,由于前面提到的原因,该值也会与扬声器不同步。
为了补偿“分块”输出,有一个函数可以提供帮助: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));
}