7.音效模块
7.1 知识点
音效模块的作用
在游戏开发中,我们经常需要播放各种各样的音乐和音效:
- 背景音乐:营造游戏氛围,增强沉浸感
- 打击音效:攻击、命中时的音效反馈
- UI音效:按钮点击、界面切换的音效
- 特效音效:技能释放、爆炸等特效的音效
- 环境音效:脚步声、环境氛围音等
这些音效会在不同的模块中被调用:技能释放、怪物受伤、角色受伤、副本通关、奖励发放等。
核心问题:
- 不同模块中播放音效时产生大量冗余代码
- 音效管理分散,整体结构杂乱
- 缺乏统一的音效管理机制
解决方案:
通过独立实现一个音效管理模块,专门用于管理和控制游戏中的所有音乐相关功能,提供给外部统一使用。
音效模块的基本原理
设计理念:
- 单例管理:使用单例模式统一管理音乐和音效
- 资源加载:通过资源管理器异步加载音频资源
- 生命周期管理:自动检测音效播放完毕并清理资源
- 音量控制:独立控制背景音乐和音效的音量
- 播放状态管理:支持播放、暂停、停止等操作
工作流程:
- 背景音乐:创建独立的AudioSource组件,使用DontDestroyOnLoad保证场景切换时不销毁
- 音效管理:使用List记录所有正在播放的音效组件
- 自动清理:通过FixedUpdate检测音效播放完毕,自动移除和销毁
- 音量控制:统一管理背景音乐和音效的音量大小
背景音乐实现
主要功能
背景音乐部分需要实现以下功能:
- 播放背景音乐:根据音乐名称异步加载并播放
- 停止背景音乐:停止当前播放的背景音乐
- 暂停背景音乐:暂停当前播放的背景音乐
- 设置背景音乐音量:动态调整背景音乐的音量大小
源码实现:MusicMgr.cs (背景音乐功能)
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// 音乐音效管理器(初步版本 - 仅支持背景音乐)
/// </summary>
public class MusicMgr : Singleton<MusicMgr>
{
//背景音乐播放组件
private AudioSource bkMusic = null;
//背景音乐大小
private float bkMusicValue = 0.1f;
private MusicMgr()
{
}
/// <summary>
/// 播放背景音乐
/// </summary>
/// <param name="name">音乐名称</param>
public void PlayBKMusic(string name)
{
//动态创建播放背景音乐的组件 并且 不会过场景移除
//保证背景音乐在过场景时也能播放
if (bkMusic == null)
{
GameObject obj = new GameObject();
obj.name = "BKMusic";
GameObject.DontDestroyOnLoad(obj);
bkMusic = obj.AddComponent<AudioSource>();
}
//根据传入的背景音乐名字 来播放背景音乐
ABResMgr.Instance.LoadResAsync<AudioClip>("music", name, (clip) =>
{
bkMusic.clip = clip;
bkMusic.loop = true;
bkMusic.volume = bkMusicValue;
bkMusic.Play();
});
}
/// <summary>
/// 停止背景音乐
/// </summary>
public void StopBKMusic()
{
if (bkMusic == null)
return;
bkMusic.Stop();
}
/// <summary>
/// 暂停背景音乐
/// </summary>
public void PauseBKMusic()
{
if (bkMusic == null)
return;
bkMusic.Pause();
}
/// <summary>
/// 设置背景音乐大小
/// </summary>
/// <param name="v">音量值(0-1)</param>
public void ChangeBKMusicValue(float v)
{
bkMusicValue = v;
if (bkMusic == null)
return;
bkMusic.volume = bkMusicValue;
}
}
音效基础实现
主要功能
音效部分需要实现以下功能:
- 播放音效:根据音效名称异步加载并播放
- 自动移除播放完成的音效:通过FixedUpdate检测音效播放完毕
- 停止指定音效:手动停止正在播放的音效
- 设置音效音量:动态调整所有音效的音量大小
- 暂停或继续播放所有音效:统一控制所有音效的播放状态
音效和背景音乐的不同
音效和背景音乐不同,需要特别考虑:
- 音效数量多:需要管理多个同时播放的音效
- 播放状态检测:需要检测音效是否播放完毕
- 容器管理:使用容器记录所有正在播放的音效组件
音效类型说明
音效分为循环和非循环两种:
- 非循环音效:需要自动检测播放完毕并清理
- 循环音效:需要外部手动管理停止
源码实现:MusicMgr.cs (支持音效管理)
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// 音乐音效管理器(支持音效管理 - 存在频繁创建删除问题)
/// </summary>
public class MusicMgr : Singleton<MusicMgr>
{
//背景音乐播放组件
private AudioSource bkMusic = null;
//背景音乐大小
private float bkMusicValue = 0.1f;
//用于音效组件依附的对象
private GameObject soundObj = null;
//管理正在播放的音效
private List<AudioSource> soundList = new List<AudioSource>();
//音效音量大小
private float soundValue = 0.1f;
//音效是否在播放
private bool soundIsPlay = true;
private MusicMgr()
{
MonoMgr.Instance.AddFixedUpdateListener(Update);
}
/// <summary>
/// 音效管理更新
/// 检测音效是否播放完毕并自动清理
/// </summary>
private void Update()
{
if (!soundIsPlay)
return;
//不停的遍历容器 检测有没有音效播放完毕 播放完了 就移除销毁它
//为了避免边遍历边移除出问题 我们采用逆向遍历
for (int i = soundList.Count - 1; i >= 0; --i)
{
if (!soundList[i].isPlaying)
{
//音效播放完毕,销毁音效组件
GameObject.Destroy(soundList[i]);
soundList.RemoveAt(i);
}
}
}
//播放背景音乐相关方法保持不变...
/// <summary>
/// 播放音效
/// </summary>
/// <param name="name">音效名字</param>
/// <param name="isLoop">是否循环</param>
/// <param name="isSync">是否同步加载</param>
/// <param name="callBack">加载结束后的回调</param>
public void PlaySound(string name, bool isLoop = false, bool isSync = false, UnityAction<AudioSource> callBack = null)
{
if (soundObj == null)
{
//音效依附的对象 一般过场景音效都需要停止 所以我们可以不处理它过场景不移除
soundObj = new GameObject("soundObj");
}
//加载音效资源 进行播放
ABResMgr.Instance.LoadResAsync<AudioClip>("sound", name, (clip) =>
{
AudioSource source = soundObj.AddComponent<AudioSource>();
source.clip = clip;
source.loop = isLoop;
source.volume = soundValue;
source.Play();
//存储容器 用于记录 方便之后判断是否停止
soundList.Add(source);
//传递给外部使用
callBack?.Invoke(source);
}, isSync);
}
/// <summary>
/// 停止播放音效
/// </summary>
/// <param name="source">音效组件对象</param>
public void StopSound(AudioSource source)
{
if (soundList.Contains(source))
{
//停止播放
source.Stop();
//从容器中移除
soundList.Remove(source);
//从依附对象上移除
GameObject.Destroy(source);
}
}
/// <summary>
/// 改变音效大小
/// </summary>
/// <param name="v">音量值(0-1)</param>
public void ChangeSoundValue(float v)
{
soundValue = v;
for (int i = 0; i < soundList.Count; i++)
{
soundList[i].volume = v;
}
}
/// <summary>
/// 继续播放或者暂停所有音效
/// </summary>
/// <param name="isPlay">是否是继续播放 true为播放 false为暂停</param>
public void PlayOrPauseSound(bool isPlay)
{
if (isPlay)
{
soundIsPlay = true;
for (int i = 0; i < soundList.Count; i++)
soundList[i].Play();
}
else
{
soundIsPlay = false;
for (int i = 0; i < soundList.Count; i++)
soundList[i].Pause();
}
}
//播放背景音乐相关方法保持不变...
}
音效模块优化
之前存在什么问题
通过上面的实现,我们解决了音效管理的基本功能,但发现了一个性能问题:音效组件会频繁的创建和删除,产生大量的内存垃圾,并且频繁创建对象也会带来性能消耗。
问题分析:
- 内存垃圾:频繁创建和销毁AudioSource组件产生大量垃圾
- 性能开销:每次播放音效都需要AddComponent操作
- GC压力:大量临时对象增加垃圾回收压力
- 过场景:过场景需要清除所有音效的方法,应该提供方法
如何解决频繁创建删除问题
解决方案:
利用对象池(缓存池)对音效组件进行复用,避免频繁的创建和删除。
优化后的源码实现:MusicMgr.cs (使用对象池优化)
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// 音乐音效管理器(使用对象池优化)
/// </summary>
public class MusicMgr : Singleton<MusicMgr>
{
//背景音乐播放组件
private AudioSource bkMusic = null;
//背景音乐大小
private float bkMusicValue = 0.1f;
//管理正在播放的音效
private List<AudioSource> soundList = new List<AudioSource>();
//音效音量大小
private float soundValue = 0.1f;
//音效是否在播放
private bool soundIsPlay = true;
private MusicMgr()
{
MonoMgr.Instance.AddFixedUpdateListener(Update);
}
/// <summary>
/// 音效管理更新
/// 检测音效是否播放完毕并自动归还到对象池
/// </summary>
private void Update()
{
if (!soundIsPlay)
return;
//不停的遍历容器 检测有没有音效播放完毕 播放完了 就移除销毁它
//为了避免边遍历边移除出问题 我们采用逆向遍历
for (int i = soundList.Count - 1; i >= 0; --i)
{
if (!soundList[i].isPlaying)
{
//音效播放完毕了 不再使用了 我们将这个音效切片置空
soundList[i].clip = null;
//归还到对象池
PoolMgr.Instance.ReturnGameObj(soundList[i].gameObject);
soundList.RemoveAt(i);
}
}
}
//播放背景音乐相关方法保持不变...
/// <summary>
/// 播放音效
/// </summary>
/// <param name="name">音效名字</param>
/// <param name="isLoop">是否循环</param>
/// <param name="isSync">是否同步加载</param>
/// <param name="callBack">加载结束后的回调</param>
public void PlaySound(string name, bool isLoop = false, bool isSync = false, UnityAction<AudioSource> callBack = null)
{
//加载音效资源 进行播放
ABResMgr.Instance.LoadResAsync<AudioClip>("sound", name, (clip) =>
{
//从缓存池中取出音效对象得到对应组件
AudioSource source = PoolMgr.Instance.GetGameObj("Sound/soundObj").GetComponent<AudioSource>();
//如果取出来的音效是之前正在使用的 我们先停止它
source.Stop();
source.clip = clip;
source.loop = isLoop;
source.volume = soundValue;
source.Play();
//存储容器 用于记录 方便之后判断是否停止
//由于从缓存池中取出对象 有可能取出一个之前正在使用的(超上限时)
//所以我们需要判断 容器中没有记录再去记录 不要重复去添加即可
if (!soundList.Contains(source))
soundList.Add(source);
//传递给外部使用
callBack?.Invoke(source);
}, isSync);
}
/// <summary>
/// 停止播放音效
/// </summary>
/// <param name="source">音效组件对象</param>
public void StopSound(AudioSource source)
{
if (soundList.Contains(source))
{
//停止播放
source.Stop();
//从容器中移除
soundList.Remove(source);
//不用了 清空切片 避免占用
source.clip = null;
//放入缓存池
PoolMgr.Instance.ReturnGameObj(source.gameObject);
}
}
//改变音效大小和暂停方法保持不变...
3D音效支持
优化思路:
由于我们使用了对象池,因此AudioSource依附的是一个个独立的游戏对象。3D音效主要考虑:
- 位置跟随:音效需要依附在对象上并跟随对象移动
- 3D参数设置:AudioSource的3D空间音频参数设置
实现方式:
//获取音效组件和它依附的对象
AudioSource source = MusicMgr.Instance.PlaySound("explosion", false, false, (audio) =>
{
//设置音效的父对象和位置,音效便可以跟随对象移动
audio.transform.SetParent(targetObject.transform);
audio.transform.localPosition = Vector3.zero;
//设置3D音效相关参数
audio.spatialBlend = 1.0f; //3D音效
audio.rolloffMode = AudioRolloffMode.Logarithmic;
audio.minDistance = 1f;
audio.maxDistance = 50f;
});
提供清除所有音效的方法
目前过场景时,音效相关对象会被自动的删除
但是音效管理器中我们的容器还占着引用,我们应该提供方法清空容器
/// <summary>
/// 清空音效相关记录 过场景时在清空缓存池之前去调用它
/// 重要的事情说三遍!!!
/// 过场景时在清空缓存池之前去调用它
/// 过场景时在清空缓存池之前去调用它
/// 过场景时在清空缓存池之前去调用它
/// </summary>
public void ClearSound()
{
for (int i = 0; i < soundList.Count; i++)
{
soundList[i].Stop();
soundList[i].clip = null;
PoolMgr.Instance.ReturnGameObj(soundList[i].gameObject);
}
//清空音效列表
soundList.Clear();
}
重要提示: 在场景切换时,必须在清空缓存池之前调用ClearSound()方法清空音效列表,避免引用已被销毁的对象。
7.2 知识点代码
MusicMgr.cs(音效管理器)
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// 音乐音效管理器
/// 提供背景音乐和音效的播放、停止、暂停等功能
/// 使用对象池优化音效组件的创建和销毁
/// </summary>
public class MusicMgr : Singleton<MusicMgr>
{
#region 背景音乐相关字段
/// <summary>
/// 背景音乐播放组件
/// </summary>
private AudioSource bkMusic = null;
/// <summary>
/// 背景音乐大小
/// </summary>
private float bkMusicValue = 0.1f;
#endregion
#region 音效相关字段
/// <summary>
/// 管理正在播放的音效
/// </summary>
private List<AudioSource> soundList = new List<AudioSource>();
/// <summary>
/// 音效音量大小
/// </summary>
private float soundValue = 0.1f;
/// <summary>
/// 音效是否在播放
/// </summary>
private bool soundIsPlay = true;
#endregion
#region 构造函数
private MusicMgr()
{
MonoMgr.Instance.AddFixedUpdateListener(Update);
}
#endregion
#region Unity更新函数
/// <summary>
/// 音效管理更新
/// 检测音效是否播放完毕并自动归还到对象池
/// </summary>
private void Update()
{
if (!soundIsPlay)
return;
//不停的遍历容器 检测有没有音效播放完毕 播放完了 就移除销毁它
//为了避免边遍历边移除出问题 我们采用逆向遍历
for (int i = soundList.Count - 1; i >= 0; --i)
{
if (!soundList[i].isPlaying)
{
//音效播放完毕了 不再使用了 我们将这个音效切片置空
soundList[i].clip = null;
//归还到对象池
PoolMgr.Instance.ReturnGameObj(soundList[i].gameObject);
soundList.RemoveAt(i);
}
}
}
#endregion
#region 背景音乐相关方法
/// <summary>
/// 播放背景音乐
/// </summary>
/// <param name="name">音乐名称</param>
public void PlayBKMusic(string name)
{
//动态创建播放背景音乐的组件 并且 不会过场景移除
//保证背景音乐在过场景时也能播放
if (bkMusic == null)
{
GameObject obj = new GameObject();
obj.name = "BKMusic";
GameObject.DontDestroyOnLoad(obj);
bkMusic = obj.AddComponent<AudioSource>();
}
//根据传入的背景音乐名字 来播放背景音乐
ABResMgr.Instance.LoadResAsync<AudioClip>("music", name, (clip) =>
{
bkMusic.clip = clip;
bkMusic.loop = true;
bkMusic.volume = bkMusicValue;
bkMusic.Play();
});
}
/// <summary>
/// 停止背景音乐
/// </summary>
public void StopBKMusic()
{
if (bkMusic == null)
return;
bkMusic.Stop();
}
/// <summary>
/// 暂停背景音乐
/// </summary>
public void PauseBKMusic()
{
if (bkMusic == null)
return;
bkMusic.Pause();
}
/// <summary>
/// 设置背景音乐大小
/// </summary>
/// <param name="v">音量值(0-1)</param>
public void ChangeBKMusicValue(float v)
{
bkMusicValue = v;
if (bkMusic == null)
return;
bkMusic.volume = bkMusicValue;
}
#endregion
#region 音效相关方法
/// <summary>
/// 播放音效
/// </summary>
/// <param name="name">音效名字</param>
/// <param name="isLoop">是否循环</param>
/// <param name="isSync">是否同步加载</param>
/// <param name="callBack">加载结束后的回调</param>
public void PlaySound(string name, bool isLoop = false, bool isSync = false, UnityAction<AudioSource> callBack = null)
{
//加载音效资源 进行播放
ABResMgr.Instance.LoadResAsync<AudioClip>("sound", name, (clip) =>
{
//从缓存池中取出音效对象得到对应组件
AudioSource source = PoolMgr.Instance.GetGameObj("Sound/soundObj").GetComponent<AudioSource>();
//如果取出来的音效是之前正在使用的 我们先停止它
source.Stop();
source.clip = clip;
source.loop = isLoop;
source.volume = soundValue;
source.Play();
//存储容器 用于记录 方便之后判断是否停止
//由于从缓存池中取出对象 有可能取出一个之前正在使用的(超上限时)
//所以我们需要判断 容器中没有记录再去记录 不要重复去添加即可
if (!soundList.Contains(source))
soundList.Add(source);
//传递给外部使用
callBack?.Invoke(source);
}, isSync);
}
/// <summary>
/// 停止播放音效
/// </summary>
/// <param name="source">音效组件对象</param>
public void StopSound(AudioSource source)
{
if (soundList.Contains(source))
{
//停止播放
source.Stop();
//从容器中移除
soundList.Remove(source);
//不用了 清空切片 避免占用
source.clip = null;
//放入缓存池
PoolMgr.Instance.ReturnGameObj(source.gameObject);
}
}
/// <summary>
/// 改变音效大小
/// </summary>
/// <param name="v">音量值(0-1)</param>
public void ChangeSoundValue(float v)
{
soundValue = v;
for (int i = 0; i < soundList.Count; i++)
{
soundList[i].volume = v;
}
}
/// <summary>
/// 继续播放或者暂停所有音效
/// </summary>
/// <param name="isPlay">是否是继续播放 true为播放 false为暂停</param>
public void PlayOrPauseSound(bool isPlay)
{
if (isPlay)
{
soundIsPlay = true;
for (int i = 0; i < soundList.Count; i++)
soundList[i].Play();
}
else
{
soundIsPlay = false;
for (int i = 0; i < soundList.Count; i++)
soundList[i].Pause();
}
}
/// <summary>
/// 清空音效相关记录 过场景时在清空缓存池之前去调用它
/// 重要的事情说三遍!!!
/// 过场景时在清空缓存池之前去调用它
/// 过场景时在清空缓存池之前去调用它
/// 过场景时在清空缓存池之前去调用它
/// </summary>
public void ClearSound()
{
for (int i = 0; i < soundList.Count; i++)
{
soundList[i].Stop();
soundList[i].clip = null;
PoolMgr.Instance.ReturnGameObj(soundList[i].gameObject);
}
//清空音效列表
soundList.Clear();
}
#endregion
}
MusicManagerUsageTest.cs(音效管理器使用测试)
using UnityEngine;
/// <summary>
/// 音效管理器使用测试
/// 演示音效管理器的完整功能
/// </summary>
public class MusicManagerUsageTest : MonoBehaviour
{
AudioSource testSoundSource;
void Start()
{
Debug.Log("音效管理器使用测试:开始");
// 1. 测试背景音乐
TestBackgroundMusic();
// 2. 测试音效播放
Invoke("TestSoundEffects", 2f);
// 3. 测试音效控制
Invoke("TestSoundControl", 5f);
}
/// <summary>
/// 测试背景音乐功能
/// </summary>
private void TestBackgroundMusic()
{
Debug.Log("测试背景音乐");
// 播放背景音乐
MusicMgr.Instance.PlayBKMusic("bgm1");
// 输出:背景音乐开始播放
// 延迟测试暂停和恢复
Invoke("PauseAndResumeBGM", 3f);
// 延迟测试音量调整
Invoke("AdjustBGMVolume", 6f);
}
private void PauseAndResumeBGM()
{
Debug.Log("暂停背景音乐");
MusicMgr.Instance.PauseBKMusic();
// 输出:背景音乐已暂停
Invoke("ResumeBGM", 2f);
}
private void ResumeBGM()
{
Debug.Log("恢复背景音乐");
MusicMgr.Instance.PlayBKMusic("bgm1");
// 输出:背景音乐恢复播放
}
private void AdjustBGMVolume()
{
Debug.Log("调整背景音乐音量:0.5");
MusicMgr.Instance.ChangeBKMusicValue(0.5f);
// 输出:背景音乐音量已设置为0.5
}
/// <summary>
/// 测试音效播放功能
/// </summary>
private void TestSoundEffects()
{
Debug.Log("测试音效播放");
// 播放一次性的音效
MusicMgr.Instance.PlaySound("explosion", false, false, (source) =>
{
Debug.Log("爆炸音效播放完成");
// 输出:爆炸音效播放完成
});
// 播放循环音效
MusicMgr.Instance.PlaySound("footstep", true, false, (source) =>
{
Debug.Log("脚步声开始循环播放");
testSoundSource = source;
// 输出:脚步声开始循环播放
});
// 播放3D音效
Invoke("Play3DSound", 1f);
}
private void Play3DSound()
{
Debug.Log("播放3D音效");
MusicMgr.Instance.PlaySound("3d_sound", false, false, (source) =>
{
// 设置3D音效参数
source.spatialBlend = 1.0f;
source.rolloffMode = AudioRolloffMode.Logarithmic;
source.minDistance = 1f;
source.maxDistance = 50f;
// 设置音效位置(跟随某个对象)
source.transform.position = new Vector3(5, 0, 5);
Debug.Log("3D音效已设置并开始播放");
// 输出:3D音效已设置并开始播放
});
}
/// <summary>
/// 测试音效控制功能
/// </summary>
private void TestSoundControl()
{
Debug.Log("测试音效控制");
// 暂停所有音效
MusicMgr.Instance.PlayOrPauseSound(false);
Debug.Log("所有音效已暂停");
// 输出:所有音效已暂停
Invoke("ResumeAllSounds", 2f);
Invoke("StopTestSound", 4f);
Invoke("TestVolumeControl", 6f);
}
private void ResumeAllSounds()
{
Debug.Log("恢复所有音效");
MusicMgr.Instance.PlayOrPauseSound(true);
// 输出:所有音效已恢复播放
}
private void StopTestSound()
{
if (testSoundSource != null)
{
Debug.Log("停止循环音效");
MusicMgr.Instance.StopSound(testSoundSource);
// 输出:循环音效已停止
}
}
private void TestVolumeControl()
{
Debug.Log("调整音效音量:0.3");
MusicMgr.Instance.ChangeSoundValue(0.3f);
// 输出:所有音效音量已设置为0.3
}
void OnDestroy()
{
// 场景切换前清空音效
MusicMgr.Instance.ClearSound();
Debug.Log("场景切换:已清空所有音效");
}
}
SceneTransitionTest.cs(场景切换测试)
using UnityEngine;
/// <summary>
/// 场景切换测试
/// 演示场景切换时如何正确处理音效
/// </summary>
public class SceneTransitionTest : MonoBehaviour
{
void Start()
{
Debug.Log("场景切换测试:开始");
// 播放一些音效
for (int i = 0; i < 5; i++)
{
MusicMgr.Instance.PlaySound($"sound{i}", false);
}
Debug.Log("已播放5个音效");
// 延迟切换场景
Invoke("SwitchScene", 3f);
}
private void SwitchScene()
{
Debug.Log("准备切换场景");
// 【重要】必须在清空缓存池之前清空音效列表
MusicMgr.Instance.ClearSound();
Debug.Log("已清空音效列表");
// 然后可以清空缓存池
// PoolMgr.Instance.ClearPool();
Debug.Log("场景切换完成");
}
}
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com