如何在游戏工程中添加可订阅节拍事件的音频系统

作者:Foursay
2023-06-09
12 11 4

前言

开发节奏游戏或是与节拍相关的游戏时,我们需要向游戏工程中添加一个可扩展和易于订阅的音频系统,以此实现在每个节拍上订阅游戏事件,并触发对应的游戏反馈(如音效和画面表现)。近期备受欢迎的《完美音浪》(HiFi-Rush)便采用了这种音频系统,使游戏的音画元素随着音乐律动,营造出非凡的游戏体验。

本文将介绍如何向游戏工程中添加可订阅节拍事件的音频系统,并探讨实现该功能必需的元素。具体讨论两种不同的实现方式:一种是利用引擎(Unity)特性自行搭建音频系统,另一种是利用引擎(Unreal)和中间件(Wwise)提供的音频系统实现订阅节拍事件。

为方便查阅和扩展阅读,后续章节分别附有对应的官方文档页链接,具体细节不再赘述。

需求

构建一个最基本的音频系统,我们需要以下必要元素:

  • 音乐播放器:音乐播放器不仅能播放音乐,还需要包含实时的音乐信息,以帮助我们在游戏运行时计算节拍位置。在所有可用的音乐信息中,以下二者必不可少:
    • 播放位置(songPosition):代表音乐当前已播放的时长(类似音乐的进度条)。播放位置是整个音频系统的核心信息,它不一定与真实时间完全对应,但会被作为标准参考时间,帮助我们推断节拍位置、不同事件的输入时间等关键时间信息。需要格外注意的是,播放位置必须从音乐的实时播放信息(例如使用 Unity 的AudioSettings)中获取,而不能使用额外的计时器(例如 Unity 中的协程或者Unity.Time)计算,因为后者很难在音乐变速时精确计算播放位置。
    • 播放速度(BPM):播放速度一般直接用BPM(每分钟的节拍数量)表示,我们需要通过它计算节拍的时长和位置。
  • 节拍事件:代表在音乐节拍上被准确触发的游戏事件,我们将借此事件触发所有订阅者对应的回调函数,以实现我们想要的反馈效果。节拍事件的参数通常包含节拍的类型信息,以方便回调函数判断节拍事件的具体类型。在下文的实现中,我们将以OnBeat为示例事件,此事件会在音乐的每一拍上被触发。

Unity 实现

如果想在 Unity 中自行实现可订阅节拍事件的音频系统,我们可以用AudioSource作为音乐播放器,它的songPosition可以借助AudioSettings.dspTime计算。dspTime是 Unity 的音频系统通过计算采样(sample)的播放数量得出的时间,因此可以适应AudioSource的速度变化(即 pitch 的变化),给出最精确的songPosition信息。

官方文档页—Unity: AudioSettings.dspTime

以下是一个获取每一拍事件的简单实现,大致思路如下:

  1. 在每一帧,通过AudioSettings.dspTime计算当前的songPosition
  2. 通过songPosition计算当前播放位置已经过的节拍数量(currentBeatPosition)。其中,secPerBeat完全通过BPM计算(等于1f / (BPM * pitchScale / 60f)),它表示在当前速度下,每经过一拍需要消耗的时间。
  3. 理想状态下,当currentBeatPosition 是整数时,就代表音乐播放到了一个准确的节拍上。因此,我们可以在currentBeatPosition 跨越整数值的时候触发预设的节拍事件。
float dspSongStartTime; //开始播放的时间偏移
float songPosition;
float currentBeatPosition;
float BPM; 
float secPerBeat; 
float pitchScale;
AudioSource musicSource; //装载音乐的 AudioSource
UnityEvent OnBeat;

//在一个 Monobehaviour 脚本内
void Update()
{
    songPosition = (float)(AudioSettings.dspTime - dspSongStartTime); //核心:获取当前播放位置
    currentBeatPosition = songPosition / secPerBeat;
    musicSource.pitch = pitchScale; //更新音乐速度
    
    if (currentBeat >= lastBeat) {
        ++lastBeat;
        OnBeat.Invoke() //音乐事件
    }
}
与此同时,我们可以围绕 songPosi

与此同时,我们可以围绕songPosition去做很多扩展实现。例如,如果想知道某个外部事件(如玩家输入事件)是否在节拍上,就可以提前计算下一拍对应的songPosition,与输入时的songPosition做比较,得出结果。
下方的示例中,lastBeat可以记录已经过的节拍数量。在每一拍时更新lastBeat,就可以源源不断地在每一拍上触发OnBeat事件:

int lastBeat; //用来记录上一个经过的 beat 的计数器
float fRange; //误差范围

void Update(){
    // 使用 songPosition 得到输入位置
    if(isInput) inputPosition = songPosition;
    
    // 使用 secPerBeat 计算下一拍位置
    nextBeatPosition = secPerBeat * (lastBeat + 1);
    
    // 比较二者,更新计数器
    if(Mathf.Abs(inputPosition - nextBeatPosition) <= fRange){ 
        ++lastBeat;
        OnBeat.Invoke();
    }
}

同理,可以用类似的方法得到更精准的节拍位置:

// 计算 currentBeat 之后
if(Mathf.Abs(currentBeatPosition - lastBeat) <= fRange){
    ++lastBeat;
    OnBeat.Invoke() 
}

如果我们想更新音乐的速度,可以直接更改AudioSource.pitch。但需要注意,每一次播放速度的更新,意味着几乎所有的动态音乐信息都会被影响。因此,我们必须同步更新当前的songPositionsecPerbeatdspSongStartTime,以保证节奏计算的准确。

void UpdateClockSetting()
{
    //获取新的播放位置
    songPosition = (float)(AudioSettings.dspTime - dspSongStartTime);
    currentBeat = songPosition / secPerBeat;
    
    //更新 secPerBeat
    secPerBeat = 1f / (BPM * musicSource.pitch / 60f);
    
    //调整 songPosition 的偏移
    var newSongPosition = secPerBeat * currentBeat;
    dspSongStartTime += songPosition - newSongPosition;
}

总的来说,使用dspTime构建的songPosition足够可靠,只要我们保证所有的时间信息都通过songPosition计算,就可以保证节拍事件的准确程度。《节奏医生》(Rhythm Doctor)和《冰与火之舞》(A Dance of Fire and Ice)的主创哈菲兹·阿兹曼(Hafiz Azman)曾发布过一篇开发日志,详细介绍了在 Unity 中制作类似音频系统需要注意的种种细节。站内已有搬运与翻译:《节奏游戏开发指南 #0:如何拆除原子弹》(Rhythm Doctor Dev Note),有兴趣可进一步参考。

Unreal 实现

我们可以使用类似思路在 Unreal 中实现类似效果。不过,Unreal 4.21 版本新推出的音频系统 Quartz,可提供高精度事件的同步与管理,它允许用户捕捉并同步音频事件,我们可以直接通过 Quartz 系统完成对节拍事件的订阅。

以下是一个 Quartz 的简单实现,大致步骤如下:

  1. 在音频系统蓝图中,通过 Quartz Subsystem 创建一个新的 Clock 并储存其返回值句柄(Handle),之后所有的订阅操作都将通过此句柄完成。创建 Clock 时,同时创建一个 Settings Time Signture,并在其中分配好拍号,然后通过调用 Set Beats Per Minute 来确定 BPM。
  2. 调用节点 Subscribe to Quantization Event,使用 Quartz 内置的事件系统去订阅节奏事件,设置订阅的节拍类型。
  3. 通过上个节点的 On Quantizition Event 引角确定回调事件。在回调事件中,我们可以对 Quantization Type 进行枚举,定位需要订阅的事件,并在这之后事件。
  4. 调用 StartClock(这一步很容易被忘记),OnBeat事件就会在每一拍被触发。

官方文档页—Unreal: Quartz

以下是蓝图的实例:

Wwise 实现

除利用引擎内置的音频系统,我们还可以使用已经集成了互动音乐系统(Interactive Music)的中间件 Wwise 实现所需。若对 Wwise 感到陌生,可以把它看作是一个事件包装器。Wwise 可以处理和封装音频文件,游戏引擎能调用这些事件来播放相应的音频。开发者只需要关注中间件暴露给引擎的音频事件,而将具体的事件实现交给音频工作者,在中间件中处理,可以更好地完成游戏的音频部分。下文将以在 Unity 中使用 Wwise 为例,展示如何利用 Wwise 完成节拍事件的订阅。由于篇幅限制,本部分不会涉及 Wwise 内部的操作(如有兴趣,可以参考 Wwise 官方文档Wwise Unity Ingregration),唯一需要提醒的是,我们要在 Project Settings 中手动禁用项目的 Unity Audio 选项,才能保证 Wwise 正常工作。

以下是通过 Wwise 订阅节拍事件的一个简单实现。步骤如下:

  1. 通过AkSoundEngine.PostEvent触发音乐的播放。在调用时,我们需要指定音乐事件对应的 GameObject。
  2. 为了在播放音频时正常读取播放位置,需要在PostEventin_uFlags参数的位置使用AkCallbackType.AK_EnableGetMusicPlayPositionAkCallbackType.AK_EnableGetSourcePlayPosition。需要特别注意的是,PostEvent只接受uint的输入参数,因此需要手动将AkCallbackType转换为unit类型(它们在 Wwise 中的实现是enum)。
  3. 与此同时,在in_uFlags处指定需要订阅的节奏类型。提供一种相对便捷的思路:在PostEvent时使用AkCallbackType.AK_MusicSyncAll,它可以捕捉所有 Wwise 的音乐事件。我们可以在之后的虚体实现中筛选出想要的音乐事件。
  4. 指定回调函数OnMusicEvent,它的签名由 Wwise 提供。其中,最需要关注的参数是in_typein_infoin_type向我们提供了回调对应的节拍事件类型;而in_info提供了音乐的播放信息,其中包含了与Unity.AudioSettings.dspTime类似的iCurrentPosition属性,它可以直接作为我们的songPosition使用。
OnMusicEvent(object in_cookie, AkCallbackType in_type, AkCallbackInfo in_info)
  1. OnMusicEvent的实现内,判断in_type的类型,并触发对应的事件回调。下面这种做法可以让我们在音乐每一拍上触发事件回调。

官方文档页—Wwise API: PostEvent

官方文档页—Wwise API: AkCallbackType

using AK.Wwise
Event music; 
uint musicPlayingId; 

void Start(){    
musicPlayingId = AkSoundEngine.PostEvent(
    music.Id,
    gameObject,
    (uint)AkCallbackType.AK_EnableGetMusicPlayPosition | 
    (uint)AkCallbackType.AK_MusicSyncAll,
    OnMusicEvent,
    null);
}

private void OnMusicEvent(object in_cookie, AkCallbackType in_type, AkCallbackInfo in_info){
    if(in_info is AkMusicSyncCallbackInfo)
    {
        if (in_type is AkCallbackType.AK_MusicSyncBeat)
        {
            OnBeat.Invoke();
        }
}

如果想在具体的音乐事件中获得播放信息,就需要获取in_info的内容。此时,我们必须将in_info转化为AkMusicSyncCallbackInfo,才能得到更具体的音乐播放信息。此部分可以参考AkCallbackInfo的继承结构。

官方文档页—Wwise API: AkCallbackInfo

另一方面,如果我们想在游戏的任何时刻都获取音乐的播放信息,就需要通过PostEvent时返回的AkPlayingId去获取 Music Segment 的信息。具体做法如下:

void Update(){
    AkSegmentInfo segmentInfo = new AkSegmentInfo();
    var result = AkSoundEngine.GetPlayingSegmentInfo(musicPlayingId, segmentInfo, true);
    if(result == AKRESULT.AK_Success){ //返回成功
        var currentPosition = (float)segmentInfo.iCurrentPosition / 1000f;
    }
}

这种做法有个缺点:在 Music Segment 循环之后,iCurrentPosition同样会返回到初始值,而非反映音乐的实际播放时间。一种简单的检测音乐循环的做法是保存上一帧的iCurrentPosition值,并检测是否存在突变。如果突变(例如从 59.8 突变到 0.1),则可以认定音乐发生循环,由此更新正确的songPosition值。
如果想将音乐变速,可以在 Wwise 中定义一个与 Music Segment 的 Playback Speed 线性对应的 RTPC,然后像处理AudioSource.pitch一样处理 RPTC 参数。除此之外,由于可以在任何时刻得到songPosition,其他类似的拓展实现也都与第一节中使用dsp.Time的方法类似。

以下是一个通过 Wwise 实现的示例,它可以捕捉用户的输入事件,并与标准节奏做比较。

总结

以上是几种在游戏工程中添加可订阅节拍事件音频系统的方法。核心思路在于,如果没有现成的节拍音频系统,我们需要确保有一个足够稳健的参考时间(本文中的songPosition),并由此计算所有节拍的时间信息。

所有方法均为个人尝试,欢迎交流!



封面:自制 
*本文内容系作者独立观点,不代表 indienova 立场。未经授权允许,请勿转载。

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. Dluck 2023-06-09

    太牛了

  2. mnikn 2023-06-09

    谢谢分享,接下来做音游只差音乐了

  3. 像素罐头FF1 2023-06-11

    不明觉厉

  4. Monad 2023-06-14

    感谢!刚好在制作类似功能,调试了一下,非常有用~
    ————————
    另外:
    有一个设计上的疑问,例如下面的代码:当玩家有误差地触发了节拍之后,对当前节拍进行了更新(lastBeat++):这个是必要的,还是设计上以玩家按下去的符合要求的节拍的时间节点更新当前已经敲过的节拍(lastBeat),会有更好的体验?
    ________________
    更新一下,刚刚发现其实lastBeat只是用于检测 改变它本身并不会影响后续的节奏 ~没问题了!

    最近由 Monad 修改于:2023-06-14 15:49:35

您需要登录或者注册后才能发表评论

登录/注册