技术博客

音乐节奏游戏的延迟校准:输出延迟从哪来、怎么测

第 129 / 149 篇 · 2026-09-11 · godot · 音频系统节奏游戏实战

做节奏游戏( rhythm game )的人都会遇到同一个灵魂拷问:为什么音符和音乐对不上?你按对了拍子,判定却是 Miss;把判定窗口放宽到 ±100ms 勉强能玩,手感还是像踩棉花。问题几乎从来不在你的判定代码,而在音频输出延迟——声音离开扬声器的时间比你想的晚得多。这篇讲这个延迟的构成和测量方法。

延迟从哪来:四个环节累加

从"调用 play()"到"鼓点从耳机出来",信号穿过四个环节(八-4 讲过前两个,这里加上输入侧):

  1. 引擎 mix buffer:默认 512 帧 @ 48kHz ≈ 10.7ms(audio/driver/mix_rate 与 buffer size 可调);
  2. OS 音频会话:CoreAudio 默认 2–8ms,WASAPI 共享模式可到 20ms+;
  3. 硬件输出队列:蓝牙耳机的命门,SBC/AAC 编码 + 收发缓冲轻松 100–200ms,有线耳机 1–5ms;
  4. 输入侧:按键从硬件到 Input.is_action_just_pressed 的轮询延迟,一帧 16.7ms(60fps)+ OS 输入队列。

游戏内的"对拍"有两个方向:视觉提示(音符下落)要对准将来的发声时刻,玩家按键的判定要对准音频已经播放到的位置。前者取决于输出延迟(知道声音何时出来才能画对位置),后者取决于音频时钟的读取精度。总误差预算是判定窗口的一半——一个 ±40ms 的 Good 窗口,要求音频管线本身的误差 <20ms,否则系统误差把窗口吃光。

节奏游戏的时间线:显示要提前,判定要回溯 真实时间轴(音频时钟为准) play() 调用 t=0 声音实际到达耳机 t = 输出延迟(10–50ms+) 显示必须提前这段 测量:慢动作录像法 手机 240fps 录像同时拍到屏幕(白闪)和耳机出声(音爆) 两事件帧差 / 240 = 真实输出延迟,测三次取中位数 蓝牙耳机会测出 100ms+ 的恐怖数字 —— 节奏游戏拒绝蓝牙,写进设置提示 预算分配:判定窗口 ±40ms,系统误差占 <20ms 才有人权

测量:慢动作录像法

校准的前提是知道真实延迟是多少。最便宜可靠的办法是手机慢动作录像:

  1. 游戏里写一个测试场景:按空格 → 屏幕瞬间全白 + 播放一段白噪声 burst(频谱陡、便于听辨);
  2. 手机开 240fps 慢动作录像,镜头同时拍到屏幕和耳机出声孔(耳机音量开到能触发手机麦克风削波);
  3. 逐帧看回放:画面变白的那一帧和音频削波的那一帧之间隔了 $n$ 帧,延迟就是 $n / 240$ 秒。

有线耳机实测通常在 12–30ms(取决于 mix buffer 和 OS);蓝牙耳机 120–250ms 不等,且随信号质量漂移——节奏游戏应该直接检测蓝牙设备并提示玩家换有线。这个方法也能反过来测输入延迟:手指触屏的瞬间(画面上的触点反馈)到 Input 事件生效的帧差。

引擎侧的对拍实现

Godot 没有暴露精确的音频硬件时钟(不像 Web Audio 的 AudioContext.currentTime),但有一个实用近似:playback 的内部帧位置 + 缓冲深度估算。AudioStreamPlayer.get_playback_position() 返回的是"数据已交付到混音器的位置",加上 mix buffer 和 OS 缓冲的深度(测出来的那个延迟),就是"听者实际听到的位置"。

判定逻辑应该用这个修正后的时间,而不是 Time.get_ticks_msec()——后者是墙上的钟,和音频流可能漂移(混音器以设备时钟为基准 resample,墙上钟以 CPU 晶振为基准)。具体做法:

# 每帧更新一次"听者正在听的时刻"
var audio_clock := 0.0
func _process(_dt):
    audio_clock = player.get_playback_position() + measured_output_latency

func judge(input_time_ms: int) -> String:
    # input_time_ms 来自 InputEvent 的时间戳(4.x 有)
    var diff := input_time_ms / 1000.0 - audio_clock
    return "PERFECT" if absf(diff) < 0.025 else ("GOOD" if absf(diff) < 0.070 else "MISS")

InputEvent 的 time 属性(4.x 有,Unix 时间戳)比"事件到达的那一帧"精确——渲染帧 16.7ms 的量化误差被绕过。注意 measured_output_latency 是测出来的常数,每次启动游戏重测或存进配置文件。

视觉侧的提前量

音符的下落动画要对准"听者听到拍子的时刻"再往前推一个视觉反应提前量。人的视觉-运动协调和听觉-运动协调不同步:音符画到判定线之前 $\tau_{\text{visual}}$ 处时按下,主观上才觉得是"对上拍"。这个量因人而异( offset 校准是音游的标配设置项——让玩家听节拍点屏幕,统计点击时刻与节拍时刻的均值差),但它和输出延迟是两个独立量:输出延迟是系统误差(所有人一样),视觉 offset 是个体误差(每人不同)。好的音游设置里两个都要让玩家能调。

实在不行:换平台 API

如果引擎栈的延迟实在压不下来(比如目标平台是 Web 导出,浏览器音频栈的延迟天生 20–50ms 且不可控),最后手段是把判定也搬到音频时钟域:用 JavaScript 的 Web Audio API 直接处理底层音频,Godot 只做渲染。这是 Godot 音游的生态现状——4.x 能做,但要自己动手校准的部分比 Unity(有现成的 AudioSettings.dspTime)和原生引擎多。

本文基于 Godot 4.2–4.4;get_playback_position() 的语义和 InputEvent 时间戳以主线源码为准,输出延迟测量方法与平台无关。