《Cocos 游戏开发入门》9.2 BGM、淡入淡出与音量总线

上一节解决了音效的并发问题,但一条 BGM 加几十条音效,音量该怎么统一控制?本节给 mini2d 搭出主音量/BGM/音效三级总线,讲清 gain 相乘的数学,实现 BGM 交叉淡入淡出与切歌,并给出静音状态与用户偏好的 localStorage 持久化;末节对照 Cocos Creator 3.8。

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));
}

这里有两个必须处理的健壮性问题:

  1. try/catch 包裹 JSON.parse:存储内容可能被外部工具写坏,或者旧版本格式不兼容。解析失败时回退默认值,游戏照常能玩。
  2. 用 { ...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 移动端音频限制与解锁 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画