使用 Web Audio API 开发游戏音频

Boris Smus
Boris Smus

简介

音频是多媒体体验如此引人入胜的重要因素。如果您曾尝试过关闭音频观看电影,可能已经注意到了这一点。

游戏也不例外!我最爱的视频游戏记忆是音乐和音效现在,在玩了最喜欢的游戏近 20 年后,我仍然无法忘记 Koji Kondo 的《塞尔达传说》曲目Matt Uelmen 的氛围感十足的《暗黑破坏神》曲目。这些音效同样引人注目,例如《魔兽世界》中马上可识别的点击响应,以及任天堂经典游戏中的样曲。

游戏音频会带来一些有趣的挑战。为了创作出逼真的游戏音乐,设计师需要根据玩家所处的可能不可预测的游戏状态进行调整。在实践中,游戏的某些部分可以持续一段未知的时间,声音可以与环境互动并以复杂方式混音,例如房间效果和相对声音定位。最后,系统可以同时播放大量音效,所有这些音效都需要一起听起来很棒,并且在渲染时不会降低性能。

网页上的游戏音频

对于简单的游戏,使用 <audio> 标记可能就足够了。不过,许多浏览器的实现方式不佳,导致音频出现故障和延迟时间较长。这可能是暂时性的问题,因为供应商正在努力改进他们各自的实现。如需大致了解 <audio> 代码的状态,请访问 areweplayingyet.org,其中提供了一个不错的测试套件。

不过,深入研究 <audio> 标记规范后,我们发现很多事情根本无法通过它完成,这并不奇怪,因为它是为媒体播放而设计的。一些限制包括:

  • 无法对声音信号应用过滤器
  • 无法访问原始 PCM 数据
  • 不考虑声源和听众的位置和方向
  • 没有精细的时间控制。

在本文的其余部分中,我将以使用 Web Audio API 编写的游戏音频为背景,深入探讨其中的一些主题。如需简要了解此 API,请参阅使用入门教程

背景音乐

游戏通常会循环播放背景音乐。

如果循环短且可预测,可能会非常烦人。如果玩家卡在某个区域或关卡中,而背景中会持续播放同一选段,不妨考虑逐渐淡出该曲目,以免玩家感到更加沮丧。另一种策略是,根据游戏情境,让不同强度的混音逐渐淡化为另一种混音。

例如,如果玩家位于有史诗级首领战斗的区域,您可以准备多种混音,从氛围感、预示到激烈等情感范围各异。音乐合成软件通常允许您根据选定的曲目组合导出多首时长相同的混音曲目。这样,您就可以获得一些内部一致性,并避免在从一个轨道淡出到另一个轨道时出现突兀的转换。

Garageband

然后,您可以使用 Web Audio API 通过 XHR 导入所有这些示例(Web Audio API 入门文章中对此进行了详细介绍),例如使用 BufferLoader 类。加载音效需要时间,因此游戏中使用的资源应在网页加载时、关卡开始时加载,或者在玩家玩游戏时逐步加载。

接下来,您需要为每个节点创建一个源,为每个源创建一个增益节点,并连接图表。

完成后,您可以同时循环播放所有这些来源,并且由于它们的长度都相同,Web Audio API 将保证它们保持同步。随着角色距离最终 Boss 战斗越来越近或越来越远,游戏可以使用如下所示的增益量算法改变链中各个节点的增益值:

// Assume gains is an array of AudioGainNode, normVal is the intensity
// between 0 and 1.
var value = normVal - (gains.length - 1);
// First reset gains on all nodes.
for (var i = 0; i < gains.length; i++) {
    gains[i].gain.value = 0;
}
// Decide which two nodes we are currently between, and do an equal
// power crossfade between them.
var leftNode = Math.floor(value);
// Normalize the value between 0 and 1.
var x = value - leftNode;
var gain1 = Math.cos(x - 0.5*Math.PI);
var gain2 = Math.cos((1.0 - x) - 0.5*Math.PI);
// Set the two gains accordingly.
gains[leftNode].gain.value = gain1;
// Check to make sure that there's a right node.
if (leftNode < gains.length - 1) {
    // If there is, adjust its gain.
    gains[leftNode + 1].gain.value = gain2;
}

在上述方法中,两个音频源同时播放,我们使用等功率曲线在它们之间淡入淡出(如简介中所述)。

目前,许多游戏开发者都为其背景音乐使用 <audio> 标记,因为它非常适合在线播放内容。现在,您可以将 <audio> 标记中的内容引入 Web Audio 上下文。

由于 <audio> 标记可与在线内容搭配使用,因此此方法非常有用,您可以立即播放背景音乐,而无需等待其全部下载。通过将数据流引入 Web Audio API,您可以操控或分析数据流。以下示例会对通过 <audio> 标记播放的音乐应用低通滤波:

var audioElement = document.querySelector('audio');
var mediaSourceNode = context.createMediaElementSource(audioElement);
// Create the filter
var filter = context.createBiquadFilter();
// Create the audio graph.
mediaSourceNode.connect(filter);
filter.connect(context.destination);

如需更详细地了解如何将 <audio> 标记与 Web Audio API 集成,请参阅这篇短文

音效

游戏通常会播放音效来响应用户输入或游戏状态的变化。不过,与背景音乐一样,音效很快就会让人感到厌烦。为避免这种情况,通常最好准备一组类似但不同的音效来播放。这可能从轻微的脚步声音样本变化到剧烈的变化,如在《魔兽世界》系列游戏中,点击单位时所产生的变化。

在游戏中,音效的另一个主要特点是可以同时存在许多音效。假设您正在一场枪战中,有多名演员在射击机关枪。每挺机枪每秒会发射多次,导致同时播放数十个音效。同时播放来自多个精确计时源的声音是 Web Audio API 真正的优势之一。

以下示例通过创建多个播放时间错开的声源,从多个单独的子弹声采样中创建了机枪弹药。

var time = context.currentTime;
for (var i = 0; i < rounds; i++) {
    var source = this.makeSource(this.buffers[M4A1]);
    source.noteOn(time + i - interval);
}

现在,如果游戏中的所有机枪都完全这样响,那就太无聊了。当然,声音会因与目标的距离和相对位置而有所不同(稍后会对此进行详细介绍),但即使这样还不够。幸运的是,Web Audio API 提供了两种方法来轻松调整上述示例:

  1. 子弹发射时间之间存在细微的变化
  2. 通过更改每个选段的播放速率(同时更改音调),以更好地模拟现实世界的随机性。

如需查看这些技术在实际应用中的更贴近真实的示例,请查看桌球演示,其中使用随机采样并调整 playbackRate 以获得更有趣的撞球声音。

3D 位置音效

游戏通常以具有某些几何特性的 2D 或 3D 世界为背景。如果是这种情况,立体声定位音频可以显著提升沉浸式体验。幸运的是,Web Audio API 内置了硬件加速的定位音频功能,这些功能非常简单易用。顺便提一下,您应该确保自己有立体声扬声器(最好是耳机),这样才能听懂以下示例。

在上面的示例中,画布中间有一个监听器(人形图标),鼠标会影响来源(扬声器图标)的位置。上面是一个使用 AudioPannerNode 实现此类效果的简单示例。上述示例的基本思想是通过设置音频源的位置来响应鼠标移动,如下所示:

PositionSample.prototype.changePosition = function(position) {
    // Position coordinates are in normalized canvas coordinates
    // with -0.5 < x, y < 0.5
    if (position) {
    if (!this.isPlaying) {
        this.play();
    }
    var mul = 2;
    var x = position.x / this.size.width;
    var y = -position.y / this.size.height;
    this.panner.setPosition(x - mul, y - mul, -0.5);
    } else {
    this.stop();
    }
};

有关 Web Audio 处理空间化方面的须知事项:

  • 监听器默认位于原点 (0, 0, 0)。
  • Web Audio 位置 API 没有单位,因此我引入了乘数,以使演示更好听。
  • Web Audio 使用 y 轴向上的笛卡尔坐标(与大多数计算机图形系统相反)。因此,我在上面的代码段中交换了 y 轴

高级:声音锥

位置模型非常强大且非常先进,主要基于 OpenAL。如需了解详情,请参阅上面链接的规范的第 3 节和第 4 节。

位置模型

有一个 AudioListener 附加到 Web Audio API 上下文,可通过位置和方向在空间中进行配置。每个源都可以通过 AudioPannerNode 传递,该节点会对输入音频进行空间化处理。平移节点具有位置和方向,以及距离和方向模型。

距离模型根据与声源的邻近度指定增益量,而方向模型可通过指定内锥和外锥来配置,以确定当监听器在内锥内、内外锥之间或外锥外时,确定增益量(通常为负)。

var panner = context.createPanner();
panner.coneOuterGain = 0.5;
panner.coneOuterAngle = 180;
panner.coneInnerAngle = 0;

虽然我的示例是 2D 形式的,但这个模型很容易泛化到第三个维度。如需查看 3D 空间化声音的示例,请参阅此位置信息示例。除了位置之外,Web Audio 声音模型还可以选择性地包含用于多普勒效应的速度。以下示例更详细地展示了多普勒效应

如需详细了解此主题,请参阅有关 [混合位置音频和 WebGL][webgl] 的详细教程。

房间特效和滤镜

实际上,声音的听觉方式在很大程度上取决于听到声音的房间。与宽敞的开放式大厅相比,同一扇吱吱作响的门在地下室的声音会完全不同。制作水准精良的游戏会想要模仿这些效果,因为为每个环境创建一组单独的示例成本过高,并且会导致更多资源和更多游戏数据。

粗略地说,用于表示原始声音与实际声音之间差异的音频术语是脉冲响应。这些脉冲响应可能会被费力地记录下来,为方便起见,实际上有一些网站托管了许多预先录制的脉冲响应文件(存储为音频)。

如需详细了解如何根据给定环境创建脉冲响应,请参阅 Web Audio API 规范的卷积部分中的“录制设置”部分。

对我们而言,更重要的是,Web Audio API 提供了一种使用 ConvolverNode 轻松将这些脉冲响应应用于声音的方法。

// Make a source node for the sample.
var source = context.createBufferSource();
source.buffer = this.buffer;
// Make a convolver node for the impulse response.
var convolver = context.createConvolver();
convolver.buffer = this.impulseResponseBuffer;
// Connect the graph.
source.connect(convolver);
convolver.connect(context.destination);

另请参阅 Web Audio API 规范页面上的房间效果演示,以及此示例,您可以通过该示例控制一首优秀爵士标准曲目的干音(原始)和湿音(通过混响器处理)混音。

倒计时已接近尾声

这样,您构建了一款游戏,配置了定位音频,现在您的图中有大量 AudioNode,所有这些节点都会同时播放。太棒了,但还有一件事需要考虑:

由于多个声音只是堆叠在一起,没有经过标准化处理,因此您可能会发现自己超出了音箱的性能阈值。就像图片超出画布边界一样,如果波形超出其最大阈值,声音也会被剪裁,从而产生明显的失真。波形如下所示:

截短

下面是一个实际运作的剪辑示例。波形看起来不正常:

剪裁

请务必聆听上述那种刺耳的失真音效,或者相反,聆听过于柔和的混音,以免听众不得不调高音量。如果您遇到这种情况,就真的需要解决它!

检测剪裁

从技术层面来看,当任一声道中的信号值超出有效范围(即 -1 到 1 之间)时,就会发生剪裁。检测到这种情况后,最好提供视觉反馈来表明这种情况。为了可靠地执行此操作,请将 JavaScriptAudioNode 添加到图表中。音频图表的设置如下所示:

// Assume entire sound output is being piped through the mix node.
var meter = context.createJavaScriptNode(2048, 1, 1);
meter.onaudioprocess = processAudio;
mix.connect(meter);
meter.connect(context.destination);

您可以在以下 processAudio 处理脚本中检测到剪裁:

function processAudio(e) {
    var buffer = e.inputBuffer.getChannelData(0);

    var isClipping = false;
    // Iterate through buffer to check if any of the |values| exceeds 1.
    for (var i = 0; i < buffer.length; i++) {
    var absValue = Math.abs(buffer[i]);
    if (absValue >= 1) {
        isClipping = true;
        break;
    }
    }
}

一般来说,出于性能方面的原因,请注意不要过度使用 JavaScriptAudioNode。在这种情况下,衡量功能的替代实现可以在渲染时轮询音频图中的 RealtimeAnalyserNode 以获取 getByteFrequencyData,具体取决于 requestAnimationFrame。这种方法更高效,但会错过大部分信号(包括可能出现剪辑的地方),因为渲染最多每秒发生 60 次,而音频信号的变化速度要快得多。

由于剪辑检测非常重要,因此我们未来可能会看到内置的 MeterNode Web Audio API 节点。

防止剪裁

通过调整主 AudioGainNode 的增益,您可以将混音调低到可防止剪裁的水平。然而,在实践中,由于游戏中播放的声音可能取决于各种各样的因素,因此很难确定阻止所有状态剪裁的主增益值。一般来说,您应调整增益以预测最糟糕的情况,但这更像是一门艺术,而不是一门科学。

加点糖

压缩器通常用于音乐和游戏制作,用于平滑信号并控制整体信号中的峰值。在 Web Audio 世界中,您可以通过 DynamicsCompressorNode 实现此功能。DynamicsCompressorNode 可插入音频图表中,以提供更响亮、更丰富、更饱满的声音,同时还能帮助防止音频剪辑。直接引用规范,此节点

通常,使用动态压缩功能是一个好主意,尤其是在游戏环境中,正如前面所述,您无法确切知道会播放什么声音以及何时播放。DinahMoe Labs 的 Plink 就是一个很好的例子,因为播放的声音完全取决于您和其他参与者。在大多数情况下,压缩器都很有用,但在某些罕见情况下,您需要处理经过精心母带制作且已调音至“恰到好处”的曲目。

若要实现此操作,只需在音频图中添加 DynamicsCompressorNode(通常作为目的地之前的最后一个节点):

// Assume the output is all going through the mix node.
var compressor = context.createDynamicsCompressor();
mix.connect(compressor);
compressor.connect(context.destination);

如需详细了解动态压缩,请参阅这篇维基百科文章,其中提供了非常实用的信息。

总而言之,请仔细聆听是否有削波,并通过插入主增益节点来防止削波。然后使用动态压缩器节点压缩整个组合。您的音频图表可能如下所示:

最终结果

总结

以上就是我认为使用 Web Audio API 进行游戏音频开发时最重要的方面。借助这些方法,您可以直接在浏览器中打造真正引人入胜的音频体验。在结束本课之前,我要向您提供一个特定于浏览器的提示:如果您的标签页使用 page visibility API 进入后台,请务必暂停声音,否则可能会给用户带来令人沮丧的体验。

如需详细了解 Web Audio,请参阅更具入门性质的入门文章。如果您有疑问,请查看 Web Audio 常见问题解答,看看问题是否已在其中得到解答。最后,如果您还有其他问题,请使用 web-audio 标记在 Stack Overflow 上提问。

在结束本课之前,我来介绍一下 Web Audio API 在现今真实游戏中的一些出色用法: