《游戏引擎实现原理》5.1 混音图与音频总线

把「音源 → 总线 → 主输出」建模成一张有向图:节点带线性增益,边是数据流,渲染一个量子时按拓扑序累加、逐节点乘增益。先用纯 TS 真跑分贝换算、等功率声像、混音求和与总线路由,再把同一张图放进 headless Chromium 的 OfflineAudioContext 复算,逐样本比对 Web Audio 与 L1 的输出。

5.1 混音图与音频总线

第 4 章我们把物理接进了主循环,但《微光岛》还是一片死寂。从这一章起补上音频子系统。先划边界:音频引擎不负责「音频怎么好听」——那是内容与混音师的事;它负责把多个音源按增益与路由求和成一路输出,并让这件事每帧都廉价、可复现。

这一节只做混音图:把「音源 → 总线 → 主输出」建模成一张有向图,节点带线性增益,边是数据流。DSP 数学(分贝换算、声像、求和、路由)全部抽成纯 TS 在 Node 里真跑,再用 headless Chromium 的 OfflineAudioContext 复算同一张图,逐样本比对。

本章与站内既有内容的分工

音频原理站内已经写透,本章不重推,只做「在 helio 里从零实现 + 每节跑出真实产物」:

既有文章它负责什么本节只做什么
/audio-mixing-mastering/混音/母带的听感与工程流程只做可跑的总线路由与求和
/audio-web-audio-api/Web Audio 的节点模型与 API 全貌把图落成可渲染的 DSP,不依赖浏览器 API
/audio-dsp-filters/滤波与频域处理不碰滤波,只做增益、求和、路由

为什么音频也要一张图

引擎里的声音需求很朴素:几十个音效、一条 BGM、偶尔的环境音。天真的做法是「每个声音自己乘音量再相加」,但这样两件事会失控——分组控制(把音效全调小、BGM 不变)和复用(一个音源同时进干声与混响两条链)。

混音图用一句话解决:让信号流过节点,节点只做两件事——乘自己的增益、把结果送给下游。于是「音效总线」只是一个增益节点,「静音音效」只是把它设成 0,「音源 fan-out 到干湿两路」只是连两条边。这与卷二渲染图(frame graph)是同一个思路:先把拓扑定下来,再按拓扑序求值。

  • 节点:source 携带一个量子长度的 PCM;bus 没有自身信号,只累加上游。
  • 边:src → dst,允许 fan-out(一个输出接多个下游)。
  • 求值:按拓扑序(生产者先于消费者),每个节点先把收到的输入求和,乘上增益,再推给下游。

可运行产物:一张可渲染的混音图(L1)

下面是完整的 src/subsys/audio/mix-graph.ts。它含分贝换算、等功率声像、软限幅三个纯函数,以及 MixGraph 本身:connect 会拒绝成环,render 按 Kahn 拓扑序累加并逐节点乘增益。

// file: src/subsys/audio/mix-graph.ts
// tier: L1
// verify: npx vitest run src/subsys/audio/mix-graph.test.ts
//
// helio 子系统 5.1:混音图与音频总线。音源 → 总线 → 主输出建模成有向图,
// 节点有线性增益,渲染一个量子时按拓扑序累加、逐节点乘增益。纯 DSP,Node 26 直跑。

/** 分贝 → 线性增益:0 dB = 1.0,-6 dB ≈ 0.5012。 */
export function dbToGain(db: number): number { return 10 ** (db / 20); }
/** 线性增益 → 分贝;0 与负值没有有限分贝。 */
export function gainToDb(gain: number): number {
  return gain > 0 ? 20 * Math.log10(gain) : Number.NEGATIVE_INFINITY;
}
/** 等功率声像:pan∈[-1,1] → [左, 右],中心处各为 √2/2,平方和恒为 1。 */
export function equalPowerPan(pan: number): [number, number] {
  const a = ((Math.max(-1, Math.min(1, pan)) + 1) / 2) * (Math.PI / 2);
  return [Math.cos(a), Math.sin(a)];
}
/** tanh 软限幅:|x|→∞ 时趋近 ±1,小信号近似线性。 */
export function softClip(x: number): number { return Math.tanh(x); }

interface MixNode {
  readonly kind: 'source' | 'bus';
  gain: number;
  muted: boolean;
  samples: Float32Array | null; // 仅 source 有:一个量子长度的单声道 PCM
  readonly outputs: number[]; // 数据流方向:输出流向这些下游节点(fan-out)
}

/** 一张混音图。节点用整数句柄寻址;source 携带 PCM,bus 只做求和与增益。 */
export class MixGraph {
  #nodes: MixNode[] = [];
  #master = -1;

  /** 建一个总线节点(无自身信号,只累加上游)。 */
  createBus(gainDb = 0): number {
    this.#nodes.push({ kind: 'bus', gain: dbToGain(gainDb), muted: false, samples: null, outputs: [] });
    return this.#nodes.length - 1;
  }
  /** 建一个音源节点,绑定一个量子长度的单声道 PCM(不足补零)。 */
  createSource(samples: Float32Array, gainDb = 0): number {
    this.#nodes.push({ kind: 'source', gain: dbToGain(gainDb), muted: false, samples, outputs: [] });
    return this.#nodes.length - 1;
  }
  /** 把 src 的输出接到 dst 的输入;自环或成环抛错。 */
  connect(src: number, dst: number): void {
    if (src === dst) throw new Error('connect: self-loop');
    if (!this.#nodes[src] || !this.#nodes[dst]) throw new Error('connect: bad node');
    if (this.#reaches(dst, src)) throw new Error(`connect: would create a cycle ${src}->${dst}`);
    if (!this.#nodes[src]!.outputs.includes(dst)) this.#nodes[src]!.outputs.push(dst);
  }
  setGainDb(id: number, db: number): void { this.#nodes[id]!.gain = dbToGain(db); }
  setMuted(id: number, muted: boolean): void { this.#nodes[id]!.muted = muted; }
  setMaster(id: number): void { this.#master = id; }

  /** 拓扑序:生产者一定排在消费者之前(Kahn)。 */
  topologicalOrder(): number[] {
    const n = this.#nodes.length;
    const indeg = new Int32Array(n);
    for (const node of this.#nodes) for (const o of node.outputs) indeg[o]!++;
    const queue: number[] = [];
    for (let i = 0; i < n; i++) if (indeg[i] === 0) queue.push(i);
    const order: number[] = [];
    for (let h = 0; h < queue.length; h++) {
      const u = queue[h]!;
      order.push(u);
      for (const v of this.#nodes[u]!.outputs) if (--indeg[v]! === 0) queue.push(v);
    }
    if (order.length !== n) throw new Error('render: graph has a cycle');
    return order;
  }
  /** 渲染一个量子:返回 master 节点的输出(frames 个采样)。 */
  render(frames: number): Float32Array {
    if (this.#master < 0) throw new Error('render: no master set');
    const n = this.#nodes.length;
    const acc: Float32Array[] = Array.from({ length: n }, () => new Float32Array(frames));
    const out: Float32Array[] = Array.from({ length: n }, () => new Float32Array(frames));
    for (const id of this.topologicalOrder()) {
      const node = this.#nodes[id]!;
      const a = acc[id]!;
      if (node.kind === 'source' && node.samples) {
        for (let i = 0; i < frames; i++) a[i] += i < node.samples.length ? node.samples[i]! : 0;
      }
      const g = node.muted ? 0 : node.gain;
      const o = out[id]!;
      for (let i = 0; i < frames; i++) o[i] = a[i]! * g;
      for (const dst of node.outputs) {
        const b = acc[dst]!;
        for (let i = 0; i < frames; i++) b[i] += o[i]!;
      }
    }
    return out[this.#master]!;
  }
  #reaches(from: number, target: number): boolean {
    const seen = new Uint8Array(this.#nodes.length);
    const stack = [from];
    while (stack.length) {
      const u = stack.pop()!;
      if (u === target) return true;
      if (seen[u]) continue;
      seen[u] = 1;
      for (const v of this.#nodes[u]!.outputs) stack.push(v);
    }
    return false;
  }
}

// ---- 直接运行:两个音源经两条 -6 dB 总线汇入 master(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
  const g = new MixGraph();
  const master = g.createBus();
  const sfx = g.createBus(-6);
  const music = g.createBus(-6);
  const hit = g.createSource(Float32Array.from([0.5, 0.5]));
  const bgm = g.createSource(Float32Array.from([0.25, 0.25]));
  g.connect(hit, sfx); g.connect(bgm, music);
  g.connect(sfx, master); g.connect(music, master);
  g.setMaster(master);
  console.log('order', g.topologicalOrder().join(','));
  console.log('out', Array.from(g.render(2)).map((v) => v.toFixed(6)).join(', '));
}

在 /tmp/eb-subsys/ch5 里 node src/subsys/audio/mix-graph.ts(本机实测):

order 3,4,1,2,0
out 0.375890, 0.375890

拓扑序把两个音源(3、4)排在最前,两条总线(1、2)居中,master(0)最后。输出 0.375890 正好是 (0.5 + 0.25) × 10^(-6/20) = 0.75 × 0.501187——两个音源各自的 -6 dB 总线增益叠加后求和,与手算一致。vitest 里另有 11 条断言覆盖分贝 roundtrip、等功率声像平方和为 1、静音节点恒为 0、fan-out 求和、成环保护。

可运行产物:在真实 Web Audio 里复算同一张图(L2)

L1 证明了「我们的求和是对的」,但没证明「我们的求和等于浏览器会做的事」。src/subsys/audio/mix-graph-l2.ts(L2)在 headless Chromium 里用 OfflineAudioContext 复刻同一张图——两个 AudioBufferSourceNode 经两个 GainNode 汇入 master——离线渲染后回读 Float32Array,与 L1 逐样本比对:

// file: src/subsys/audio/mix-graph-l2.ts
// tier: L2
// verify: node run-l2-local.mjs src/subsys/audio/mix-graph-l2.ts
//
// 5.1 的 L2 验收:把与 L1 完全相同的「两音源 → 两总线 → master」图,
// 在真实 Web Audio 里用 OfflineAudioContext 离线渲染一遍,再回读 Float32
// 采样,与 L1 MixGraph.render() 的输出逐样本比对。需 headless Chromium。

import { MixGraph, dbToGain } from './mix-graph.ts';

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }

const FRAMES = 128;
const RATE = 48000;

/** 在 OfflineAudioContext 里复刻 L1 的混音图并离线渲染,返回单声道采样。 */
async function renderWebAudio(): Promise<Float32Array> {
  const ctx = new OfflineAudioContext(1, FRAMES, RATE);
  const master = ctx.createGain();
  const sfx = ctx.createGain();
  const music = ctx.createGain();
  master.gain.value = dbToGain(0);
  sfx.gain.value = dbToGain(-6);
  music.gain.value = dbToGain(-6);
  const mk = (level: number): AudioBufferSourceNode => {
    const buf = ctx.createBuffer(1, FRAMES, RATE);
    const ch = buf.getChannelData(0);
    for (let i = 0; i < FRAMES; i++) ch[i] = level;
    const src = ctx.createBufferSource();
    src.buffer = buf;
    return src;
  };
  const hit = mk(0.5);
  const bgm = mk(0.25);
  hit.connect(sfx); bgm.connect(music);
  sfx.connect(master); music.connect(master);
  master.connect(ctx.destination);
  hit.start(); bgm.start();
  const rendered = await ctx.startRendering();
  return rendered.getChannelData(0).slice();
}

/** L1 侧的同一张图:常量音源经 -6 dB 总线汇入 master。 */
function renderL1(): Float32Array {
  const g = new MixGraph();
  const master = g.createBus();
  const sfx = g.createBus(-6);
  const music = g.createBus(-6);
  const a = g.createSource(Float32Array.from({ length: FRAMES }, () => 0.5));
  const b = g.createSource(Float32Array.from({ length: FRAMES }, () => 0.25));
  g.connect(a, sfx); g.connect(b, music);
  g.connect(sfx, master); g.connect(music, master);
  g.setMaster(master);
  return g.render(FRAMES);
}

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };

  const wa = await renderWebAudio();
  const l1 = renderL1();
  const expect = (0.5 + 0.25) * dbToGain(-6);

  push('OfflineAudioContext 可用', typeof OfflineAudioContext === 'function', 'ctor ok');
  push('Web Audio 输出非静音', wa.some((v) => v !== 0), `first=${wa[0]!.toFixed(6)}`);
  push('Web Audio 首样本等于解析值', Math.abs(wa[0]! - expect) <= 1e-6,
    `wa=${wa[0]!.toFixed(8)} expect=${expect.toFixed(8)}`);

  let maxErr = 0;
  for (let i = 0; i < FRAMES; i++) maxErr = Math.max(maxErr, Math.abs(wa[i]! - l1[i]!));
  push('Web Audio 与 L1 逐样本一致', maxErr <= 1e-6, `maxErr=${maxErr.toExponential(2)}`);

  return { checks, data: { frames: FRAMES, rate: RATE, expect, webAudio0: wa[0], l1_0: l1[0], maxErr } };
}

本机实跑(headless Chromium,node run-l2-local.mjs src/subsys/audio/mix-graph-l2.ts):

PASS  OfflineAudioContext 可用  ctor ok
PASS  Web Audio 输出非静音  first=0.375890
PASS  Web Audio 首样本等于解析值  wa=0.37589040 expect=0.37589043
PASS  Web Audio 与 L1 逐样本一致  maxErr=0.00e+0

注意 maxErr = 0.00e+0:Web Audio 的增益求和与我们的 L1 实现逐样本完全相同(两者都在 f32 上运算,量化误差没有出现)。这说明混音图的数学层可以脱离浏览器开发与测试,浏览器只是它的一个后端。这条 L2 用 OfflineAudioContext 离线渲染,不需要真实音频设备,所以在无头环境里可复现。

小结

混音图的价值不在 DSP,而在结构:把「怎么分组、怎么复用」变成一张图的形状,把「音量」变成节点上的一个标量。求值是纯数据的拓扑遍历,与卷二渲染图同构。

但 render 里每个 source 只绑了一个量子的 PCM,而且我们还没管「同时能响几个声音、谁该被抢」。下一节处理 voice 池、优先级抢占与流式 BGM。

阅读导航:上一节:回到目录 · 下一节:5.2 音效池、BGM 与流式 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径