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 同步)。为此,拥有一个更精确的播放位置时间信息非常有用。
实现非常低的播放时间精度非常困难。这是因为音频播放过程中有许多因素在起作用:
音频以块(非连续)的形式混合,块取决于所用音频缓冲区的大小(检查项目设置中的延迟)。
混合的音频块不会立即播放。
图形应用程序接口延迟显示两到三帧。
在电视上播放时,由于图像处理,可能会增加一些延迟。
最常见的减少延迟的方法是缩小音频缓冲区(同样,通过编辑项目设置中的延迟设置)。问题是,当延迟很小时,声音混合将占用更多的 CPU。这会增加跳音的风险(由于混合回调丢失而导致声音破裂)。
这是一种常见的折衷方案,因此 Godot 附带了合理而无需更改的默认值。
The problem, in the end, is not this slight delay but synchronizing graphics and audio for games that require it. Some helpers are available to obtain more precise playback timing.
使用系统时钟同步
如前所述,如果调用 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() 来获取歌曲的当前位置听起来很理想,但这样做并没有那么有用。该值将分块(每次音频回调混合一个声音块时)递增,因此多次调用可能返回相同的值。除此之外,由于前面提到的原因,该值也会与扬声器不同步。
为了补偿“分块”(chunked)输出,有个函数可能会有所帮助: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));
}