Unity MyFramework 用法说明(二十二):使用 AudioManager 统一管理背景音乐与 2D/3D 音效

0 阅读5分钟

游戏中的按钮点击、角色受击、技能释放和背景音乐,都需要加载并播放音频。如果每个业务对象自己创建 AudioSource、加载 AudioClip 和管理销毁,容易出现重复加载、音量规则不统一以及大量临时节点。

MyFramework 使用 AudioManager 统一管理音频资源,并通过可复用的 AudioHelper 播放临时音效。

项目地址:

github.com/ZHOURUIH/My…

一、AudioManager 负责什么

AudioManager 主要管理:

音频路径和音效ID的映射
AudioClip 的加载与卸载
背景音乐播放对象
2D和3D音效播放
音乐与音效的全局音量
临时 AudioHelper 的复用
同时播放的音效数量

业务层通常不需要自己创建 AudioSource,只需要调用:

AT.MUSIC(...);
AT.SOUND_2D(...);
AT.SOUND_3D(...);

二、注册音效

音频文件路径相对于:

Assets/GameResources

并且需要带文件后缀。

例如:

public static class SOUND_DEFINE
{
	public const int MAIN_BGM = 1;
	public const int BUTTON_CLICK = 2;
	public const int MONSTER_HIT = 3;
}

注册对应的文件:

mAudioManager.registeSoundDefine(
	SOUND_DEFINE.MAIN_BGM,
	"Audio/Music/Main.mp3");

mAudioManager.registeSoundDefine(
	SOUND_DEFINE.BUTTON_CLICK,
	"Audio/UI/ButtonClick.wav");

mAudioManager.registeSoundDefine(
	SOUND_DEFINE.MONSTER_HIT,
	"Audio/Battle/MonsterHit.wav");

注册后,业务代码只需要使用音效 ID,不需要关心具体文件路径。

实际项目中通常会把音效配置放进 Excel,再统一注册:

using static FrameBaseHotFix;
using static GBR;

public class AudioRegister : FrameSystem
{
	public override void init()
	{
		base.init();

		if (mExcelSound == null)
		{
			return;
		}

		foreach (EDSound data in mExcelSound.queryAll())
		{
			mAudioManager.registeSoundDefine(
				data.mID,
				data.mFileName);
		}
	}
}

这样修改音频文件时,只需要调整表格配置。

三、内部如何保存音频信息

AudioManager 内部保存了两个主要字典:

protected Dictionary<string, AudioInfo> mAudioList = new();
protected Dictionary<int, string> mSoundDefineMap = new();

它们分别负责:

mAudioList
音频路径 → AudioInfo

mSoundDefineMap
音效ID → 音频路径

AudioInfo 中保存实际资源和加载状态:

public class AudioInfo : ClassObject
{
	public ResourceRef<AudioClip> mClip;
	public AudioClip mRawClip;
	public string mAudioName;
	public bool mIsLocal;
	public LOAD_STATE mState;

	public AudioClip getClip()
	{
		if (mClip != null)
		{
			return mClip.getResource();
		}
		return mRawClip;
	}
}

本地音频通过 ResourceRef<AudioClip> 持有,可以交给 ResourceManager 正确卸载。

通过网络地址加载的音频则直接保存 AudioClip,销毁时使用 Unity 接口释放。

四、播放背景音乐

播放默认循环的背景音乐:

AT.MUSIC(SOUND_DEFINE.MAIN_BGM);

指定是否循环:

AT.MUSIC(
	SOUND_DEFINE.MAIN_BGM,
	true);

指定本次播放音量:

AT.MUSIC(
	SOUND_DEFINE.MAIN_BGM,
	true,
	0.8f);

也可以直接通过路径播放:

AT.MUSIC(
	"Audio/Music/Main.mp3",
	true,
	0.8f);

停止当前背景音乐:

AT.MUSIC();

AudioManager 内部只维护一个背景音乐播放对象:

protected AudioHelper mMusicHelper;

初始化资源系统后,它会创建一个长期存在的 AudioHelper,并将其设置为 2D 音频:

mMusicHelper = getOneUnusedHelper(-1.0f);
mMusicHelper.setSpatialBlend(0.0f);

因此同一时间只维护一条全局背景音乐播放通道。

五、播放 2D 音效

UI 点击、系统提示和奖励弹窗等音效与场景位置无关,适合使用 2D 音效:

AT.SOUND_2D(
	SOUND_DEFINE.BUTTON_CLICK);

设置单次播放音量:

AT.SOUND_2D(
	SOUND_DEFINE.BUTTON_CLICK,
	0.7f);

也可以通过路径播放:

AT.SOUND_2D(
	"Audio/UI/ButtonClick.wav",
	0.7f);

例如按钮点击:

protected void onConfirmClick()
{
	AT.SOUND_2D(
		SOUND_DEFINE.BUTTON_CLICK);

	confirmOperation();
}

播放时,框架会获取一个临时 AudioHelper,并设置:

helper.setSpatialBlend(0.0f);

所以音效不会受到摄像机和场景距离影响。

六、播放 3D 音效

攻击、爆炸、怪物受击等声音通常来自场景中的指定位置:

AT.SOUND_3D(
	monster.getPosition(),
	SOUND_DEFINE.MONSTER_HIT);

指定音量:

AT.SOUND_3D(
	monster.getPosition(),
	SOUND_DEFINE.MONSTER_HIT,
	0.8f);

内部会把播放对象移动到指定位置,并设置为完全 3D:

AudioHelper helper =
	mAudioManager.getAudioHelper(0.0f);

helper.setSpatialBlend(1.0f);
helper.setPosition(pos);

最终的衰减范围、空间混合和其他 3D 参数,仍然由 AudioSource 的配置决定。

七、AudioHelper 为什么需要复用

如果每次播放音效都创建一个 GameObject:

创建GameObject
添加AudioSource
播放音效
等待结束
销毁GameObject

短时间内大量战斗音效会频繁创建和销毁节点。

AudioManager 使用两个集合管理辅助对象:

protected SafeHashSet<AudioHelper> mHelperList = new();
protected Queue<AudioHelper> mUnusedList = new();

获取播放对象时,优先从未使用队列中取出:

if (mUnusedList.Count > 0)
{
	helper = mUnusedList.Dequeue();
}
else
{
	CLASS(out helper);
	helper.setName("AudioHelper");
	helper.init();
}

音效播放结束后,不销毁对象,而是重新放回队列:

获取 AudioHelper
    ↓
设置 AudioSource 并播放
    ↓
音效时间结束
    ↓
关闭 AudioHelper
    ↓
放回未使用队列

下一次播放音效时继续复用。

八、如何判断音效已经结束

非循环音效播放后,框架会记录剩余时间:

helper.mRemainTime =
	mAudioManager.getAudioLength(soundName);

AudioManager.update() 中持续更新:

using var a =
	new SafeHashSetReader<AudioHelper>(mHelperList);

foreach (AudioHelper item in a.mReadList)
{
	if (item != mMusicHelper &&
		tickTimerOnce(
			ref item.mRemainTime,
			elapsedTime))
	{
		unusedHelper(item);
	}
}

倒计时结束后,辅助对象自动回到缓存队列。

这里使用 SafeHashSet,是因为遍历播放对象时,可能同时有对象被回收。

九、限制同时存在的音效数量

战斗中可能在一瞬间播放大量音效。可以设置辅助对象数量上限:

mAudioManager.setMaxAudioCount(32);

当播放对象数量达到上限后,框架会找到剩余时间最短的音效,将其提前回收,再播放新的音效。

设置为:

mAudioManager.setMaxAudioCount(0);

表示不限制数量。

这个上限适合根据项目规模和目标设备进行调整,不能设置得过小,否则重要音效可能被过早替换。

十、设置音乐和音效音量

设置全局音效音量:

mAudioManager.setSoundVolume(0.8f);

设置全局背景音乐音量:

mAudioManager.setMusicVolume(0.5f);

这两个值会在后续播放时乘到本次播放音量上:

最终音量 = 本次播放音量 × 全局分类音量

修改当前正在播放的背景音乐音量,可以使用:

AT.MUSIC_VOLUME(0.5f);

平滑淡出:

AT.MUSIC_VOLUME(
	1.0f,
	0.0f,
	0.5f);

平滑淡入:

AT.MUSIC_VOLUME(
	0.0f,
	1.0f,
	0.5f);

它会接入前面介绍的关键帧系统,不需要自己在 update() 中修改 AudioSource.volume

十一、预加载和卸载音频

注册音频并不会立即加载全部资源。

首次播放时,AudioManager 会自动加载对应的 AudioClip

需要提前加载时:

mAudioManager.loadAudio(
	SOUND_DEFINE.BUTTON_CLICK);

异步加载:

mAudioManager.loadAudioAsync(
	SOUND_DEFINE.BUTTON_CLICK,
	(info) =>
	{
		if (info != null)
		{
			log("音效加载完成");
		}
	});

预加载全部已注册音频:

mAudioManager.loadAllAsync();

不再使用时卸载:

mAudioManager.unload(
	SOUND_DEFINE.MAIN_BGM);

或者通过路径卸载:

mAudioManager.unload(
	"Audio/Music/Main.mp3");

卸载只是释放音频资源和重置加载状态,注册的 ID 与路径映射仍然保留,之后可以再次加载。

十二、总结

MyFramework 的音频使用流程可以概括为:

1. 注册音效ID与资源路径
2. 使用 AT.MUSIC 播放背景音乐
3. 使用 AT.SOUND_2D 播放界面音效
4. 使用 AT.SOUND_3D 播放场景音效
5. AudioManager 统一加载和卸载 AudioClip
6. AudioHelper 自动缓存并复用播放节点
7. 通过音量和数量上限控制整体表现

业务层不需要反复创建 AudioSource,也不需要为每个短音效编写销毁计时。

AudioManager 负责资源和播放对象的生命周期,AT 提供统一的播放入口,让背景音乐、界面音效和场景音效都使用同一套规则。