音频与音效管理
3.1 BGM 与音效是两套策略
我的音频文件分两个目录,规则完全不同。
BGM 是长音乐,一首循环,从进游戏一直放到退出。我的做法是:启动场景加载完成后立刻加载 BGM 的 AudioClip,加载完就播,循环。整局游戏不卸载也不替换,Skyline Courier 只有一张地图,配了三种环境音(白天、黄昏、暴雨),切换场景时用 crossfade 式的音量渐变,而不是硬切。
音效是短音频,几百毫秒,同时播放数量多。撞墙、拾取、送达、点击 UI,各一份。这些我一开始也是启动就全加载,结果启动慢了两秒。后来改成按需加载:第一次用到某个音效时才 load,load 完缓存起来,第二次以后就是同步取用。
两种策略的差别写在一张表里,我每次加新音频都对着它填:
| 维度 | BGM | 音效 |
|---|---|---|
| 加载时机 | 启动场景加载完立即加载 | 第一次触发时加载,之后走缓存 |
| 播放方式 | clip + play() + loop |
playOneShot(clip, volumeScale) |
| 同时数量 | 1 到 2 个 | 最多 8 个,走对象池 |
| 是否需要 ENDED 事件 | 需要(切歌用) | 不需要,playOneShot 不可控 |
| 格式 | .mp3 |
.mp3,1 秒以下改 .m4a |
| 存放位置 | audio bundle |
audio bundle |
这个思路在 3.x 里有现成 API 支持。resources.load 走的是回调,而 bundle.get 可以从缓存里同步取已经加载好的资源。所以我做了个小工具:第一次 play 时走异步加载并记下 clip,之后直接同步拿。
AudioClip 资源本身也有 play 等接口,但官方文档建议尽量用 AudioSource 组件完成工作流,因为 AudioClip 的播放控制能力弱得多,不能设置循环、不能调音量。
3.2 AudioSource 的常用属性
AudioSource 组件的属性很少,我列一下我实际用到的。
clip 是要播放的音频资源,默认空。loop 是否循环播放,BGM 必须开。playOnAwake 是否在组件激活时自动播放。volume 音量,范围 0 到 1。
方法上,play() 开始播放,pause() 暂停,stop() 停止,playOneShot(clip, volumeScale) 以指定音量倍数播放一次。
这里有个 3.x 的重要变化:官方文档明确写了,Cocos Creator 3.x 移除了 v2.x 中的 audioEngine API,统一使用 AudioSource 组件播放音频。2.x 里 cc.audioEngine.playEffect 那套写法在 3.x 完全不能用,我早期的一份旧脚本就是栽在这儿,报错是 Cannot read property 'playEffect' of undefined。
playOneShot 是一次性播放操作,播放后的音效无法暂停或停止播放,也无法监听播放结束的事件回调。这三条限制决定了它的用途:短促的、一次性的、不需要精确结束时机的声音。
AudioSource 从 v3.3.0 开始支持事件监听,用 AudioSource.EventType.STARTED 和 ENDED:
import { _decorator, Component, AudioSource } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AudioDemo')
export class AudioDemo extends Component {
@property(AudioSource)
audioSource: AudioSource = null!;
onEnable() {
this.audioSource.node.on(AudioSource.EventType.STARTED, this.onStarted, this);
this.audioSource.node.on(AudioSource.EventType.ENDED, this.onEnded, this);
}
onDisable() {
this.audioSource.node.off(AudioSource.EventType.STARTED, this.onStarted, this);
this.audioSource.node.off(AudioSource.EventType.ENDED, this.onEnded, this);
}
onStarted() { /* TODO */ }
onEnded() { /* TODO */ }
}
事件是通过 audioSource.node 注册的,不是通过组件本身。这一点我第一次写错,监听了一分钟没反应。
3.3 音效播放的对象池思路与卡顿排查
这里要分清两件事:AudioSource 组件本身不需要池化,需要池化的是"播放节点"。
我的做法是维护一个固定数量的 AudioSource 节点,放在一个常驻节点下。播放音效时从池子里取一个空闲的,设置好 clip 和 volume,调 playOneShot,播完在 ENDED 事件里归还。
为什么要这么做?因为 playOneShot 的语义是"一次性播放"。官方文档写得很清楚:播放后的音效无法暂停或停止播放,也无法监听播放结束的事件回调。这意味着你没法精确控制一个音效的生命周期,只能靠音源的数量来管理。
具体到我的场景:玩家贴着墙飞,一秒触发六次撞墙音。如果全用同一个 AudioSource,六次 playOneShot 会挤在同一个音源上,听起来是"咔咔咔咔咔"一串,而不是六次独立的碰撞。
但这是引擎行为,改不了。所以我的解法是:池子里放 8 个 AudioSource,同一个音效在这些 source 之间轮换,用不同 source 播放就能听到重叠。
import { _decorator, Component, AudioClip, AudioSource, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('SfxPlayer')
export class SfxPlayer extends Component {
@property(AudioClip)
crashClip: AudioClip = null!;
private _pool: AudioSource[] = [];
private _cursor = 0;
onLoad() {
const root = new Node('__sfx__');
this.node.addChild(root);
for (let i = 0; i < 8; i++) {
this._pool.push(root.addComponent(AudioSource));
}
}
playCrash() {
const src = this._pool[this._cursor];
this._cursor = (this._cursor + 1) % this._pool.length;
src.playOneShot(this.crashClip, 1);
}
}
8 个 source 是我试出来的。4 个在密集触发时会明显重叠不足,16 个在低端机上开始有负担,8 个够用。
音效卡顿有两种来源,我分开排查。
第一种是资源加载卡顿。resources.load 是异步的,回调在下一帧左右。如果每次播放都调 load,即使资源已经在缓存里,这一层异步也会带来延迟。我的解法是自己维护一个 Map<string, AudioClip>,命中缓存就同步 playOneShot,不命中才走 load。
第二种是节点和组件创建卡顿。我第一版的音效播放器每次 play 都 new AudioSource() 加到场景里,跑起来帧率肉眼可见地掉,一次 addComponent 加上 Node 实例化,在低端机上能吃掉好几毫秒。改成对象池之后这个开销消失了。
第三种我原本没想到:是同帧内大量播放。我做过一次压力测试,同一帧触发 20 次音效,虽然 playOneShot 本身开销不大,但 20 个 AudioSource 同时激活、20 个解码请求同时发出,在微信环境里会有明显延迟。后来加了个简单的节流:同一音效在 50 毫秒内只播一次。
顺便提一下 volume 的平台差异。官方兼容性说明里写着,Web 平台音频资源有两种加载模式:Web Audio 和 DOM Audio。Creator 默认用 Web Audio,检测到浏览器不支持就切到 DOM Audio。用 DOM Audio 时,在 iOS 的浏览器上不支持调整音量大小,所有 volume 相关属性都不会生效。
我用 playOneShot 时踩到过一个反直觉的点:最终播放音量是 audioSource.volume * volumeScale,两个值是相乘的。所以要压低某一个音效,得动 volumeScale,而不是去改那个 source 的 volume(改了会影响同 source 上的其他音效)。
3.4 微信小游戏下的自动播放限制与格式选择
这是微信平台上最需要小心的一块。
官方文档写得很清楚:Web 平台的音频播放需要遵守最新的 Audio Play Policy,即使 AudioSource 组件设置了 playOnAwake,也需要等第一次用户点击事件发生后才会播放。AudioSource API 文档里也提到,在 Web 平台 Auto Play Policy 禁止首次自动播放音频,因为需要发生用户交互之后才能播放音频,并给了两种实现方式:在 TOUCH_END 或者 MOUSE_UP 的事件回调里播放音频,或者直接播放音频、引擎会在下一次发生用户交互时自动播放。
微信小游戏的音频接口是自己实现的,不完全等同于浏览器环境,但自动播放限制依然存在。我的处理办法是在 loading 界面上放一个"开始送货"按钮,点击之后才初始化音频系统。BGM 从这里开始播,第一音效也从这里触发。这样既绕过了限制,也顺便解决了一个体验问题:玩家看到 loading 转圈的时候本来就不该有声音。
关于格式,Creator 3.8 支持导入 .ogg、.mp3、.wav、.mp4、.m4a。官方文档在 .ogg 那一条给了警告:iOS 不支持该格式。.wav 未压缩,占用比较大。.mp4 和 .m4a 是音频版本的 MP4。
我的选择是:音效统一用 .mp3,短到 1 秒以下的我另存一份 .m4a(同码率下体积更小),BGM 用 .mp3。不用 .ogg 是因为 iOS 不支持,不敢赌。不用 .wav 是因为体积。
体积上我做过一次对比。一份 3 秒的音效,.wav 大约 264 KB,.mp3 大约 24 KB,差 11 倍。我第一版把 40 个音效全做成 wav,主包直接多了 6 MB。
还有一点是资源打包位置。音频资源不在 resources 目录也不在 bundle 里的话,就不会被动态加载,只能靠场景里的 AudioSource 组件引用,这会把它们拽进主包。我的音效是运行时按需播的,所以全部放在 audio bundle 里,运行时用 bundle.load 拿。
3.5 静音与用户设置的配合
我做了一个 AudioMgr 单例,挂在一个常驻节点上,参考官方文档给的示例思路。官方在《音频播放管理器示例》里就是这么建议的,因为 3.x 移除了 audioEngine,开发者仍然需要一个能随时调用的管理器。
单例里持有几个 AudioSource:一个专管 BGM,一个做音效池的母节点。同时暴露几个方法:playBgm(clip)、stopBgm()、setBgmVolume(v)、playSfx(clip, volume)、setMuted(bool)。
静音的处理分两层。BGM 直接把 volume 设成 0;音效因为走的是 playOneShot(clip, volumeScale),最终音量是 audioSource.volume * volumeScale,所以我在音效池那个母节点的 AudioSource 上把 volume 归零,所有 playOneShot 自动被压掉,不需要在每个调用点判断。
import { _decorator, Component, AudioClip, AudioSource } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AudioMgr')
export class AudioMgr extends Component {
@property(AudioSource)
bgm: AudioSource = null!;
private _muted = false;
private _bgmVolume = 0.6;
setMuted(v: boolean) {
this._muted = v;
this.bgm.volume = v ? 0 : this._bgmVolume;
}
playBgm(clip: AudioClip) {
this.bgm.stop();
this.bgm.clip = clip;
this.bgm.loop = true;
this.bgm.volume = this._muted ? 0 : this._bgmVolume;
this.bgm.play();
}
}
用户设置这块我的处理是:把静音状态存到 sys.localStorage,进游戏时读出来立即应用。设置面板有两个滑块,BGM 音量和音效音量。音效音量我存在 AudioMgr 的字段里,播放时乘上去。
有个坑值得记:用户在小游戏里切到后台再回来,音频可能被系统挂起,回来之后不播了。我加了个 visibilitychange 监听,切回前台时如果 BGM 状态不对就重新 play()。这个行为各平台差异挺大,我没法保证所有机型都覆盖到,但至少比不管强。
