前言

虽然现在稍微像样点的游戏项目都接上了像 Wwise 这类音频引擎,大概率用不上这类声音模块,不过 GF 的声音模块还是非常值得没有游戏音效管理经验的同学学习,声音模块也属于 GF 中较为轻量的一个模块,本文将简单讲解。

先抛出几个问题:

  1. 对于同一类型的音效,我希望在设置面板上做统一的音量大小调整,例如游戏设置中常有的 BGM、UI、队友语音、场景音效等音量调节,但开发过程中不断有新的音效加入,如何做同一类型音效的音量的统一管理?
  2. 像 RTS 这类单位非常多,攻击频率也很高的游戏,如果每次攻击 / 受击都播放音效,那游戏整体声音将会十分混乱,如何在框架层面控制同个类型的声音的最大同时播放数量?
  3. 在问题 2 的基础上,如果我们只播放有限数量的音效,如何加入优先级控制,实现在已达到播放上限时,继续尝试播放音效,如果新播放的音效优先级比当前正在播放的音效要高,那么就顶替掉正在播放的音效?

结构

SoundAgent

SoundAgent 是声音代理,在 Unity 中我们一般使用 AudioSource 来播放声音,在 GF 的声音管理下,我们不再自行创建 AudioSource 来播放声音,而是使用 SoundAgent 来播放。SoundAgent 有一个 ISoundAgentHelper 接口的字段,这个接口在 UGF 层上有具体实现类,实现这个接口的类是一个挂载了 AudioSource 组件的 Mono 类,它持有了自身 GameObject 上的 AudioSource 的引用,并在 ISoundAgentHelper 接口的方法实现中去调用 AudioSource 的接口。

当然,游戏业务不会直接访问 SoundAgent,而是通过 SoundManager 直接播放声音,而 SoundManager 会调用 SoundGroup 的接口,然后由 SoundGroup 取得 SoundAgent 去播放声音。

SoundGroup

/// <summary>
/// 播放声音。
/// </summary>
/// <param name="serialId">声音的序列编号。</param>
/// <param name="soundAsset">声音资源。</param>
/// <param name="playSoundParams">播放声音参数。</param>
/// <param name="errorCode">错误码。</param>
/// <returns>用于播放的声音代理。</returns>
public ISoundAgent PlaySound(int serialId, object soundAsset, PlaySoundParams playSoundParams, out PlaySoundErrorCode? errorCode)
{
    errorCode = null;
    SoundAgent candidateAgent = null;
    foreach (SoundAgent soundAgent in m_SoundAgents)
    {
        if (!soundAgent.IsPlaying)
        {
            candidateAgent = soundAgent;
            break;
        }
 
        if (soundAgent.Priority < playSoundParams.Priority)
        {
            if (candidateAgent == null || soundAgent.Priority < candidateAgent.Priority)
            {
                candidateAgent = soundAgent;
            }
        }
        else if (!m_AvoidBeingReplacedBySamePriority && soundAgent.Priority == playSoundParams.Priority)
        {
            if (candidateAgent == null || soundAgent.SetSoundAssetTime < candidateAgent.SetSoundAssetTime)
            {
                candidateAgent = soundAgent;
            }
        }
    }
 
    if (candidateAgent == null)
    {
        errorCode = PlaySoundErrorCode.IgnoredDueToLowPriority;
        return null;
    }
 
    if (!candidateAgent.SetSoundAsset(soundAsset))
    {
        errorCode = PlaySoundErrorCode.SetSoundAssetFailure;
        return null;
    }
 
    candidateAgent.SerialId = serialId;
    candidateAgent.Time = playSoundParams.Time;
    candidateAgent.MuteInSoundGroup = playSoundParams.MuteInSoundGroup;
    candidateAgent.Loop = playSoundParams.Loop;
    candidateAgent.Priority = playSoundParams.Priority;
    candidateAgent.VolumeInSoundGroup = playSoundParams.VolumeInSoundGroup;
    candidateAgent.Pitch = playSoundParams.Pitch;
    candidateAgent.PanStereo = playSoundParams.PanStereo;
    candidateAgent.SpatialBlend = playSoundParams.SpatialBlend;
    candidateAgent.MaxDistance = playSoundParams.MaxDistance;
    candidateAgent.DopplerLevel = playSoundParams.DopplerLevel;
    candidateAgent.Play(playSoundParams.FadeInSeconds);
    return candidateAgent;
}

SoundGroup 是本文前言中抛出的 3 个问题的解决方案的核心实现,上面是 SoundGroup 中 PlaySound 的实现代码。

问题 1

每个声音播放的时候都会指定一个 SoundGroup,SoundGroup 有 Mute、Volume 两个方法来控制这个 SoundGroup 下每个 Agent 的静音设置和音量系数。所以我们只需要把不同类型的声音分到不到同组,我们就可以统一控制每个组的整体音量。

问题 2

SoundGroup 内部以 List 的形式来储存多个 SoundAgent,每次播放声音都会取出一个 SoundAgent(把 agent 的 IsPlaying 标记置为 true),待播放完毕时,才会放回去(把 agent 的 IsPlaying 标记置为 false),如果该 SoundGroup 中所有的 SoundAgent 都在播放中,那么这次播放就有可能会失败,这就解决了问题 2 中,同一类型(同一个 SoundGroup)限制最大同时播放数量的问题。

上面是指有可能播放失败,是因为如果当 SoundGroup 中所有的 SoundAgent 都在播放中时,还会比较优先级,这个就是问题 3 要探讨的内容。

问题 3

当播放声音时,SoundGroup 会遍历内部的 SoundAgent,若当前迭代中的 SoundAgent 没在播放状态中,则直接使用该 SoundAgent 来播放,如果处于播放状态,则会对比优先级等内容:

  1. 若新播放的音效的优先级比该 SoundAgent 当前播放的音效的优先级要低,则跳过,检测下一个 SoundAgent。
  2. 若新播放的音效的优先级比该 SoundAgent 当前播放的音效的优先级要高,则把这个 SoundAgent 作为候选的 Agent,后续有可能会用这个 SoundAgent 来播放新的音效而取代这个 SoundAgent 的当前音效,具体详见第三点。
  3. 若新播放的音效的优先级比该 SoundAgent 当前播放的音效的优先级要相等,这种情况 GF 提供了 m_AvoidBeingReplacedBySamePriority 字段,意味避免同优先级取代。
  • 当这个字段为 true 时,那相同优先级的新音效将无法取代正在播放的音效,只能检测下一个 SoundAgent。
  • 当这个字段为 false 时,若当前候选 SoundAgent 是空或者当前候选 SoundAgent 的开始播放时间点晚于当前遍历的这个 SoundAgent 的开始播放时间,则把候选 SoundAgent 更新为当前遍历的 SoundAgent,也就是若允许同优先级取代时,会取播放时间最早的 SoundAgent 来作为最后用来播放新音效的 SoundAgent。

注意上述流程若遍历中检测到没有在播放中的 SoundAgent 时,会直接作为最终播放 SoundAgent,中断遍历流程,而检测到 SoundAgent 正在播放音效时,就算作为候选 SoundAgent 也不会直接中断遍历流程,而是逐一对比取正在播放的 SoundAgent 中播放时间最早的一个作为最终播放 SoundAgent。

PlaySoundParams

播放参数,对于同一个声音资源,每次播放可以通过传入不同的播放参数,以达到不同的播放效果,参数包括有音量、优先级、静音、播放开始时间、以及一系列音效(Sound Effect)设置等。

SoundManager

外部访问声音模块的入口。

  • 对外提供 HasSoundGroup、GetSoundGroup、GetAllSoundGroups、AddSoundGroup、AddSoundAgentHelper 等对 SoundGroup 进行查询、操作等接口。
  • 对外提供 PlaySound、StopSound、StopAllLoadedSounds、PauseSound、ResumeSound 等控制声音播放的接口。

关于 SoundManager 中的资源管理

SoundManager 的资源加载卸载与 GF 的 UI 模块大同小异(不同的是由于音效资源不需要多个实例,所以 SoundManager 内部不需要对象池来维护),可以参考本系列的 UI 解析文章,本文不再赘述,本文开头的 UML 图中也对此部分进行了简化。

最后

GameFramework 解析 系列目录:GameFramework 解析:开篇

个人原创,未经授权,谢绝转载!