9.2 BGM、淡入淡出与音量总线
9.1 的音频池解决了「很多音效同时响」的问题,但只解决了一半。真实游戏里同时存在性质完全不同的声音:BGM 是长音、循环、只有一条;音效是短音、可叠加、可能几十条。如果只有一个音量旋钮,玩家想「关掉音乐但保留音效」就做不到。
于是需要音量总线(bus / mix bus):把声音按用途分组,每组有自己的增益,最后再乘上一个全局增益。这不是游戏发明的概念,调音台几十年前就这么干了——游戏音频系统只是把它搬进了代码。
与站内
game/顶层「引擎概念综述」的分工:那批文章讲概念,本套书则亲手把mini2d的混音总线写出来并真跑测试。Cocos Creator 3.8 的对照放在本节末。
9.2.1 三级总线:master / bgm / sfx
先定义总线。我们只设三级,因为再多的层级在 2D 小游戏里只会增加心智负担:
| 总线 | 管什么 | 典型音量 | 谁改它 |
|---|---|---|---|
master | 总开关,乘在最后 | 1.0 | 设置面板总滑杆 |
bgm | 背景音乐 | 0.6 | 音乐滑杆 |
sfx | 音效 | 0.9 | 音效滑杆 |
一个声音的最终音量由它的总线增益乘上主增益,再乘上这条声音自己的音量:
// mini2d/audio/mix-bus.ts
export type BusName = 'master' | 'bgm' | 'sfx';
export class MixBus {
private readonly gains: Record<BusName, number> = {
master: 1,
bgm: 0.6,
sfx: 0.9,
};
setGain(bus: BusName, value: number): void {
this.gains[bus] = Math.max(0, Math.min(1, value));
}
getGain(bus: BusName): number {
return this.gains[bus];
}
/** 某条声音的最终增益:总线 × 主控 × 该声音自身音量 */
resolve(bus: BusName, ownVolume: number): number {
return this.gains[bus] * this.gains.master * ownVolume;
}
}
9.2.2 gain 相乘:为什么是乘法不是加法
这是整节最容易被写错的一处。音量为什么相乘?
因为音量是振幅的线性缩放系数,而「各段独立衰减」在物理上就是相乘关系。信号先经过 BGM 总线衰减到 60%,再经过主控衰减到 80%,最终就是 0.6 × 0.8 = 0.48。如果写成加法 0.6 + 0.8,结果 1.4 直接溢出——这显然不对。
用乘法还有一个重要好处:任意一级归零,整条链路就静音。主控拉到 0,无论 BGM、音效各自的增益是多少,resolve 都返回 0。这让「一键静音」的实现变得极其干净:
// 一键静音:只动 master,不动用户对 BGM/SFX 的偏好
bus.setGain('master', muted ? 0 : 1);
对比一下:如果静音时去把每条正在播的声音逐个设为 0,取消静音时又得逐个还原,还得记住它们原本的音量——乘法总线把这些状态全部省掉了。保留用户偏好、只切总闸,是这套设计的核心价值。
一个常见误区:把音量当成分贝(dB)。游戏里的音量滑杆通常是线性 0~1,直接乘即可;只有当你要做对数听感曲线(人耳对响度的感知是非线性的)时,才需要先
Math.pow(v, 2)之类的映射。本节的滑杆保持线性,够用且直观。
9.2.3 BGM 播放器与交叉淡入淡出
BGM 和音效走不同的通道。音效用 9.1 的池子,BGM 则是一个单例播放器,同一时刻只有一条:
// mini2d/audio/bgm-player.ts
import { MixBus } from './mix-bus';
export class BgmPlayer {
private readonly a: HTMLAudioElement;
private readonly b: HTMLAudioElement;
private front: HTMLAudioElement; // 当前发声的那条
private current = '';
private fadeTimer = 0;
constructor(private readonly bus: MixBus) {
this.a = new Audio();
this.b = new Audio();
this.a.loop = true;
this.b.loop = true;
this.front = this.a;
}
get playing(): string {
return this.current;
}
}
准备两个 HTMLAudioElement,是因为交叉淡入淡出需要两条声音同时存在:旧的正在淡出,新的正在淡入。只用一个元素做不到这一点。
切换的核心是每帧(或用定时器)朝目标音量逼近。这里用一个不依赖引擎循环的定时器版本,mini2d 的其它模块也能复用:
/** 切歌:新曲淡入,旧曲淡出,durationMs 内完成 */
play(name: string, durationMs = 800): void {
if (this.current === name) return;
const outgoing = this.front;
const incoming = this.front === this.a ? this.b : this.a;
this.front = incoming;
this.current = name;
incoming.src = this.bus.resolve('bgm', 1) === 0 ? incoming.src : `/bgm/${name}.ogg`;
incoming.currentTime = 0;
incoming.volume = 0;
void incoming.play();
this.crossfade(outgoing, incoming, durationMs);
}
crossfade 把两条元素的音量按同一条时间轴反向推进:
private crossfade(out: HTMLAudioElement, into: HTMLAudioElement, ms: number): void {
const target = this.bus.getGain('bgm') * this.bus.getGain('master');
const start = performance.now();
clearInterval(this.fadeTimer);
this.fadeTimer = setInterval(() => {
const t = Math.min(1, (performance.now() - start) / ms);
into.volume = target * t; // 淡入
out.volume = target * (1 - t); // 淡出
if (t >= 1) {
out.pause();
out.currentTime = 0;
clearInterval(this.fadeTimer);
}
}, 16) as unknown as number;
}
两条曲线 t 与 1 - t 之和恒为 1,所以过渡期间的总响度基本恒定——这就是「交叉」淡入淡出的意义,听感上不会有明显的音量凹陷。如果只淡入不淡出,两首歌会重叠着一起响,非常吵。
用线性曲线已经够顺耳。若追求更自然,可把
t换成t * t(二次曲线,先慢后快);但注意淡出和淡入要用互补的曲线,否则总响度会在中段抖动。
9.2.4 静音与偏好持久化
玩家的设置必须跨会话保留。localStorage 是浏览器原生、同步、够用的选择:
// mini2d/audio/settings.ts
import { MixBus } from './mix-bus';
const KEY = 'mini2d.audio';
export interface AudioSettings {
master: number;
bgm: number;
sfx: number;
muted: boolean;
}
export function loadSettings(bus: MixBus): AudioSettings {
const raw = localStorage.getItem(KEY);
const def: AudioSettings = { master: 1, bgm: 0.6, sfx: 0.9, muted: false };
if (!raw) return def;
try {
const saved = JSON.parse(raw) as Partial<AudioSettings>;
return { ...def, ...saved };
} catch {
return def; // 损坏的 JSON 直接回退默认值,绝不让设置拖垮启动
}
}
export function saveSettings(s: AudioSettings): void {
localStorage.setItem(KEY, JSON.stringify(s));
}
这里有两个必须处理的健壮性问题:
try/catch包裹JSON.parse:存储内容可能被外部工具写坏,或者旧版本格式不兼容。解析失败时回退默认值,游戏照常能玩。- 用
{ ...def, ...saved }合并而非直接取saved:新版本新增了字段(比如以后加了voice总线),老玩家的存储里没有该字段,合并能自动补上默认值。
把静音开关和总线接起来,设置面板的三件事就齐了:
function applySettings(bus: MixBus, s: AudioSettings): void {
bus.setGain('master', s.muted ? 0 : s.master);
bus.setGain('bgm', s.bgm);
bus.setGain('sfx', s.sfx);
saveSettings(s);
}
注意存储里保存的是 s.master 而不是 s.muted ? 0 : s.master:静音是独立于滑杆状态的一位布尔值。玩家静音前把主音量拖到 0.8,取消静音后仍然是 0.8,这个体验才对。
9.2.5 在《方块冲刺》里落地
主菜单、关卡内、结算界面各有自己的 BGM,切场景时自动交叉淡入:
// game/audio-manager.ts
import { MixBus } from '../mini2d/audio/mix-bus';
import { BgmPlayer } from '../mini2d/audio/bgm-player';
export class AudioManager {
readonly bus = new MixBus();
private readonly bgm: BgmPlayer;
constructor() {
this.bgm = new BgmPlayer(this.bus);
}
enterMenu(): void { this.bgm.play('menu_theme'); }
enterLevel(): void { this.bgm.play('level_run'); }
enterBoss(): void { this.bgm.play('boss_theme', 400); } // Boss 切换更快
}
音效侧则复用 9.1 的 AudioSystem,只是把最终音量改成走总线:
function playSfx(name: string, own = 1): void {
const v = manager.bus.resolve('sfx', own);
audio.play(name, v);
}
至此,《方块冲刺》的混音链是:单条音效 → sfx 总线 → master → 输出,BGM 走 bgm 总线 → master → 输出。三条滑杆加一个静音开关,全部行为都能被这套乘法关系精确描述。
9.2.6 Cocos 对照:volume 与全局音量
Cocos Creator 3.8 的 AudioSource.volume 对应「单条声音的音量」,而总线需要自己搭。最直接的做法是用一个全局单例管理各级增益,播放时把最终值写进 AudioSource:
import { _decorator, Component, AudioSource } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AudioBus')
export class AudioBus extends Component {
static inst: AudioBus;
master = 1;
bgm = 0.6;
sfx = 0.9;
@property(AudioSource)
bgmSource: AudioSource | null = null;
onLoad(): void { AudioBus.inst = this; }
resolve(bus: 'bgm' | 'sfx', own: number): number {
return (bus === 'bgm' ? this.bgm : this.sfx) * this.master * own;
}
setMaster(v: number): void {
this.master = v;
if (this.bgmSource) this.bgmSource.volume = this.resolve('bgm', 1);
}
}
对照要点:
- Cocos 没有内建「总线」概念,
AudioSource.volume是扁平的,所以三级相乘的关系要么写在全局单例里(如上),要么用父子节点的volume手动累乘。我们在mini2d里把MixBus.resolve单独抽出来,正是为了把这条关系显式化。 - 淡入淡出在 Cocos 里可以用
tween(this.bgmSource).to(0.8, { volume: 0 }),比手写定时器更贴合引擎的调度器;我们的crossfade就是它的手写版。 - 持久化方面,Cocos 提供
sys.localStorage,接口与浏览器原生localStorage一致,loadSettings/saveSettings的代码可以原样搬过去。
下一节要处理的是最容易被忽视、也最容易上线翻车的一环:移动端与浏览器的自动播放限制。为什么第一次进游戏「没有声音」,为什么切到后台再回来 BGM 就没了——都将在 9.3 给出可落地的解法。
阅读导航:上一节:9.1 音频播放与音效池 · 下一节:9.3 移动端音频限制与解锁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。