使用 Web Audio API 开发游戏音频

Boris Smus
Boris Smus

简介

音频是让多媒体体验如此引人入胜的重要因素。如果您曾尝试观看静音播放的电影,您或许已经注意到了这一点。

游戏也不例外!在视频游戏中,我最珍贵的回忆是音乐和音效现在,在许多情况下,在玩过我最爱的游戏近 20 年后,我仍想不出近藤幸的 Zelda 乐曲以及 Matt Uelmen 富有情调的暗黑音乐配乐。同样的吸引力也适用于音效,例如《魔兽世界》中可立即识别的单位点击响应,以及任天堂经典游戏的样本。

游戏音频会带来一些有趣的挑战。为了创作令人信服的游戏音乐,设计师需要适应玩家所处的潜在不可预测的游戏状态。在实践中,游戏的某些部分可能会持续未知时长,声音可以与环境交互并以复杂的方式进行混合,例如房间效果和相对声音定位。最后,可以同时播放大量声音,所有这些声音都需要搭配使用时播放效果出色并能进行渲染,而不会降低性能。

网页版游戏音频

对于简单的游戏,使用 <audio> 标记可能就足够了。不过,许多浏览器都提供糟糕的实现方式,从而导致音频干扰和高延迟。这可能是暂时性的问题,因为供应商正在努力改进各自的实现。有关 <audio> 标记的状态,请参阅 areweplayingyet.org 上提供的一个实用的测试套件。

不过,深入了解 <audio> 标记规范后,您会发现很多功能是完全无法用的,这并不奇怪,因为它是专为媒体播放而设计的。以下是一些限制:

  • 无法对声音信号应用过滤条件
  • 无法访问原始 PCM 数据
  • 没有来源和监听器的位置和方向概念
  • 没有精确的时间。

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

背景音乐

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

如果循环非常短且可预测,就会非常讨厌。如果玩家卡在一个区域或关卡中,并且同一样本在后台持续播放,建议可以逐渐淡出曲目,以免出现更多失望。另一种策略是采用不同强度的混合组合,根据游戏的上下文逐渐淡入淡出。

例如,如果您的玩家处于正在进行史诗级 boss 战的区域,那么您可能会看到多种情绪组合,从氛围感到预告到紧张刺激,不一而足。借助音乐合成软件,您通常可以选择要用于导出的曲目集,从而根据乐曲导出多个(长度相同)的混音。这样,您可以保持一定的内部一致性,并避免在轨道之间淡入淡出时产生不协调的过渡。

车库带

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

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

执行此操作后,您可以循环播放所有这些音频来源,由于它们的长度相同,因此 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> 标记中的内容导入到网络音频上下文中。

此方法很有用,因为 <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();
    }
};

关于网络音频处理空间化的注意事项:

  • 默认情况下,监听器位于原点 (0, 0, 0)。
  • 网络音频位置 API 没有单位,因此我引入了一个倍增器来改善演示中的声音。
  • 网络音频使用 y 向上的笛卡尔坐标(与大多数计算机图形系统相反)。因此,我要替换上面的代码段中的 y 轴

高级:音锥

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

位置模型

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

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

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

虽然我的示例是 2D 模型,但该模型可以轻松泛化到第三个维度。如需查看 3D 空间化的声音示例,请参阅此位置示例。除了位置之外,网络音频声音模型还可以选择包含多普勒偏移的速度。此示例更详细地展示了多普勒效应

如需详细了解此主题,请参阅关于 [混合定位音频和 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 规范页面上的房间效果演示以及此示例,了解如何控制出色的 Jazz 标准的干(原始)和湿(通过卷积处理)混合。

最后的倒计时

您已构建了一个游戏,配置了位置音频,现在您的图表中有大量 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 上调整增益,您可以将混音降到防止剪辑的级别。然而,在实践中,由于游戏中的声音可能取决于各种因素,因此可能很难确定主增益值来防止所有状态都被截断。一般来说,您应该调整增益来预测最坏的情况,但这更像是一门艺术,而不是一门科学。

加一点糖

压缩器常用于音乐和游戏制作,以平滑处理信号并控制整体信号中的峰值。此功能可通过 DynamicsCompressorNode 在网络音频环境中使用,您可以将其插入到音频图中,以发出更响亮、更丰富、更饱满的声音,也有助于进行剪辑。直接引用规范,该节点

通常来说,使用动态压缩是一个不错的选择,尤其是在游戏环境中,如前所述,您并不知道确切的播放声音和播放时间。DinahMoe 实验室的 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 开发游戏音频方面最重要的方面。借助这些技术,您可以直接在浏览器中打造真正极具吸引力的音频体验。在结束之前,我想与您分享一条特定于浏览器的提示:如果您的标签页使用页面可见性 API 转至后台,请务必暂停声音,否则可能会给用户带来糟糕的体验。

如需详细了解网络音频,请参阅介绍性更强的入门文章;如果您有任何疑问,请查看网络音频常见问题解答中是否已有解答。最后,如果您有其他问题,请使用 web-audio 标签在 Stack Overflow 上提问。

在结束之前,我先为大家介绍今天在实际游戏中使用 Web Audio API 的一些很棒的应用: