路线阶段:Unity 入门实战第 8 章。
本章目标:把“零散播放音效”升级为统一音频系统,确保战斗反馈清楚且不会拖垮性能。
学习目标
完成本章后,你应该能做到:
- 设计 BGM/SFX/Voice 三类音频总线并统一控制音量。
- 通过事件驱动播放音效,避免业务脚本直接
AudioSource.Play。 - 管理 2D 与 3D 音效播放策略,兼顾沉浸感与可辨识度。
- 控制并发与复用,避免高频战斗导致音频爆炸。
背景问题
初期项目常见写法:
audioSource.clip = hitClip;
audioSource.Play();
问题:
- 所有脚本都能直接播音,资源与时机不可控。
- 同类事件重复触发,音效堆叠刺耳。
- 场景切换后 BGM 与 SFX 状态错乱。
- WebGL 下并发过高会出现明显卡顿与延迟。
音频架构
分四层:
AudioEvent:标准化播放请求。AudioRouter:事件到具体音轨的路由。AudioBus:分组音量与静音控制。AudioPool:音源对象池与并发限制。
事件模型
public enum AudioCategory
{
Bgm = 0,
Sfx = 1,
Voice = 2
}
public struct AudioEvent
{
public string Key;
public AudioCategory Category;
public Vector3 Position;
public bool Is3D;
public float Volume;
public float Pitch;
}
音频配置表
[Serializable]
public sealed class AudioClipConfig
{
public string Key;
public AudioCategory Category;
public AudioClip Clip;
public bool IsLoop;
public bool Is3D;
public float BaseVolume = 1f;
public float MinPitch = 1f;
public float MaxPitch = 1f;
public int MaxConcurrent = 4;
}
AudioBus
public sealed class AudioBus
{
public float Master = 1f;
public float Bgm = 1f;
public float Sfx = 1f;
public float Voice = 1f;
public float Resolve(AudioCategory category, float volume)
{
var categoryVolume = 1f;
if (category == AudioCategory.Bgm) categoryVolume = Bgm;
else if (category == AudioCategory.Sfx) categoryVolume = Sfx;
else if (category == AudioCategory.Voice) categoryVolume = Voice;
return Mathf.Clamp01(Master * categoryVolume * volume);
}
}
音频池与播放
public sealed class AudioSourcePool
{
private readonly Queue<AudioSource> _idle = new Queue<AudioSource>(32);
private readonly List<AudioSource> _active = new List<AudioSource>(64);
private readonly Transform _root;
public AudioSourcePool(Transform root, int initial)
{
_root = root;
for (var i = 0; i < initial; i++)
{
_idle.Enqueue(CreateSource());
}
}
public AudioSource Get()
{
var src = _idle.Count > 0 ? _idle.Dequeue() : CreateSource();
_active.Add(src);
return src;
}
public void TickRecycle()
{
for (var i = _active.Count - 1; i >= 0; i--)
{
var src = _active[i];
if (src.isPlaying)
{
continue;
}
src.clip = null;
src.transform.position = Vector3.zero;
src.spatialBlend = 0f;
src.loop = false;
_active.RemoveAt(i);
_idle.Enqueue(src);
}
}
private AudioSource CreateSource()
{
var go = new GameObject("AudioSourceRuntime");
go.transform.SetParent(_root, false);
return go.AddComponent<AudioSource>();
}
}
AudioRouter
public sealed class AudioRouter : IUpdatable
{
public int Order { get { return 320; } }
private readonly Dictionary<string, AudioClipConfig> _map;
private readonly Dictionary<string, int> _concurrent;
private readonly AudioBus _bus;
private readonly AudioSourcePool _pool;
public AudioRouter(List<AudioClipConfig> configs, AudioBus bus, AudioSourcePool pool)
{
_map = new Dictionary<string, AudioClipConfig>(configs.Count);
_concurrent = new Dictionary<string, int>(configs.Count);
_bus = bus;
_pool = pool;
for (var i = 0; i < configs.Count; i++)
{
_map[configs[i].Key] = configs[i];
_concurrent[configs[i].Key] = 0;
}
}
public void Tick(float dt, float unscaledDt)
{
_pool.TickRecycle();
}
public void Play(AudioEvent e)
{
AudioClipConfig cfg;
if (!_map.TryGetValue(e.Key, out cfg))
{
FoundationLog.Warn("Audio", "config_missing key=" + e.Key);
return;
}
var count = _concurrent[e.Key];
if (count >= cfg.MaxConcurrent)
{
return;
}
var src = _pool.Get();
src.clip = cfg.Clip;
src.loop = cfg.IsLoop;
src.spatialBlend = (cfg.Is3D || e.Is3D) ? 1f : 0f;
src.transform.position = e.Position;
src.volume = _bus.Resolve(cfg.Category, cfg.BaseVolume * (e.Volume <= 0f ? 1f : e.Volume));
var pitchMin = cfg.MinPitch;
var pitchMax = cfg.MaxPitch;
if (pitchMin > pitchMax)
{
var temp = pitchMin;
pitchMin = pitchMax;
pitchMax = temp;
}
src.pitch = UnityEngine.Random.Range(pitchMin, pitchMax);
src.Play();
_concurrent[e.Key] = count + 1;
if (!cfg.IsLoop)
{
var key = e.Key;
CoroutineRunner.Instance.Delay(src.clip.length, delegate
{
_concurrent[key] = Mathf.Max(0, _concurrent[key] - 1);
});
}
}
}
业务事件接入
建议统一映射:
SkillCastAccepted->sfx_skill_castDamageResolved->sfx_hit_light/sfx_hit_heavyWaveStarted->sfx_wave_startBattleCompleted->sfx_victory+ BGM 切换
public sealed class CombatAudioPresenter : IDisposable
{
private readonly EventSubscriptionGroup _subs;
private readonly AudioRouter _router;
public CombatAudioPresenter(EventBus eventBus, AudioRouter router)
{
_router = router;
_subs = new EventSubscriptionGroup(eventBus);
}
public void Bind()
{
_subs.Bind("SkillCastAccepted", OnSkillCast);
_subs.Bind("DamageResolved", OnDamage);
_subs.Bind("WaveStarted", OnWaveStarted);
_subs.Bind("BattleCompleted", OnBattleCompleted);
}
public void Dispose()
{
_subs.OnDisable();
_subs.OnDestroy();
}
private void OnSkillCast(object payload)
{
_router.Play(new AudioEvent { Key = "sfx_skill_cast", Category = AudioCategory.Sfx, Volume = 1f });
}
private void OnDamage(object payload)
{
_router.Play(new AudioEvent { Key = "sfx_hit_light", Category = AudioCategory.Sfx, Volume = 0.9f });
}
private void OnWaveStarted(object payload)
{
_router.Play(new AudioEvent { Key = "sfx_wave_start", Category = AudioCategory.Sfx, Volume = 1f });
}
private void OnBattleCompleted(object payload)
{
_router.Play(new AudioEvent { Key = "sfx_victory", Category = AudioCategory.Sfx, Volume = 1f });
}
}
BGM 状态机
public enum BgmState
{
Menu = 0,
Battle = 1,
Result = 2
}
public sealed class BgmController
{
private readonly AudioRouter _router;
private BgmState _state;
public BgmController(AudioRouter router)
{
_router = router;
_state = BgmState.Menu;
}
public void Change(BgmState next)
{
if (_state == next)
{
return;
}
_state = next;
if (next == BgmState.Menu)
_router.Play(new AudioEvent { Key = "bgm_menu", Category = AudioCategory.Bgm, Volume = 1f });
else if (next == BgmState.Battle)
_router.Play(new AudioEvent { Key = "bgm_battle", Category = AudioCategory.Bgm, Volume = 1f });
else if (next == BgmState.Result)
_router.Play(new AudioEvent { Key = "bgm_result", Category = AudioCategory.Bgm, Volume = 1f });
}
}
WebGL 注意点
- 预加载核心音频,避免首帧播放延迟。
- 控制并发上限,避免低端设备音频线程压力过高。
- 长音频使用压缩流式策略,短音效使用内存常驻。
验收清单
- 技能释放、命中、波次开始都能触发对应音效。
- BGM 在菜单/战斗/结算间切换稳定,无重叠播放。
- 音量设置可实时生效并持久化。
- 长时间战斗下无明显音频堆叠与性能抖动。
常见坑
坑 1:每个对象挂一个 AudioSource 随便播
会快速失控,难以统一调优。必须通过集中路由。
坑 2:忽略并发限制
连击场景会爆音。给每个音效键配置 MaxConcurrent。
坑 3:BGM 切换时不停止旧轨道
会出现多轨叠加。BGM 需要独立管理通道。
本月作业
实现“动态战斗音乐”:
- 敌人数量超过阈值自动切换高强度层。
- Boss 出场时叠加单独主题段。
- 回放模式复现同一时刻音乐切换节点。
下一章进入 Unity 入门实战 09:资源加载与预热(WebGL 首包与运行时加载优化)。