使用 Web Audio API 開發遊戲音訊

Boris Smus
Boris Smus

簡介

音訊是打造引人入勝的多媒體體驗的關鍵。如果你曾經試著在關閉音效的情況下觀看電影,可能會發現這一點。

遊戲也不例外!我最喜歡的電玩回憶就是音樂和音效。如今,在許多情況下,我玩過最愛的遊戲後,幾乎 20 年後,我仍無法忘記 Koji Kondo 的《薩爾達傳說》曲目Matt Uelmen 的《Diablo》氛圍配樂。同樣的吸引力也適用於音效,例如《魔獸爭霸》中一聽就認得的單位點擊回應,以及任天堂經典遊戲的音效樣本。

遊戲音效會帶來一些有趣的挑戰。為了製作令人信服的遊戲音樂,設計師必須調整玩家可能處於的不可預測遊戲狀態。實際上,遊戲的部分內容可能會持續一段未知的時間,聲音可以與環境互動,並以複雜的方式混合,例如房間效果和相對聲音位置。最後,系統可能會同時播放大量音效,而所有音效都必須同時播放且聽起來悅耳,且不會導致效能受限。

網路上的遊戲音訊

如果是簡單的遊戲,使用 <audio> 標記可能就足夠了。不過,許多瀏覽器的實作方式不佳,導致音訊出現異常和延遲。供應商正努力改善各自的實作方式,因此這應該是暫時性問題。如要一窺 <audio> 標記的狀態,請前往 areweplayingyet.org 查看實用的測試套件。

不過,深入瞭解 <audio> 標記規格後,您會發現許多事情根本無法透過 <audio> 標記完成,這一點毫不令人意外,因為 <audio> 標記是專為媒體播放而設計。部分限制如下:

  • 無法將篩選器套用至音訊信號
  • 無法存取原始 PCM 資料
  • 不考量來源和事件監聽器的位置和方向
  • 沒有精細的時間安排。

在本文的其餘部分,我將深入探討這些主題,並以使用 Web Audio API 編寫的遊戲音效為例。如要簡要瞭解這個 API,請參閱入門教學課程

背景音樂

遊戲通常會循環播放背景音樂。

如果迴圈短且可預測,使用者可能會覺得很煩人。如果玩家卡在某個區域或關卡,而同樣的樣本會持續在背景播放,那麼不妨考慮讓音軌逐漸淡出,以免玩家感到更加挫折。另一種策略是根據遊戲情境,混合不同強度的音效,並逐漸交錯轉換。

舉例來說,如果玩家處於有壯烈 Boss 戰的區域,您可能會使用幾種混音,以營造從氛圍、預示到激烈的各種情緒。音樂合成軟體通常會讓您根據某個樂曲,挑選要用於匯出的曲目組合,以便匯出多個長度相同的混音版本。這樣一來,您就能確保內部一致性,並避免在從一個音軌淡入另一個音軌時,產生不自然的轉場效果。

GarageBand

接著,您可以使用 Web Audio API,透過 XHR 使用 BufferLoader 類別之類的工具匯入所有這些樣本 (這項作業在 Web Audio API 入門文章中已有詳細說明)。載入音效需要時間,因此遊戲中使用的素材資源應在網頁載入、關卡開始時,或在玩家遊玩時逐漸載入。

接下來,您將為每個節點建立來源,並為每個來源建立增益節點,然後連結圖表。

完成這項操作後,您就可以在迴圈中同時播放所有這些來源,而且由於長度都相同,Web Audio API 會保證它們保持對齊。隨著角色離最終頭目戰越近或越遠,遊戲可以使用以下增益量演算法,為鏈結中各個相應節點變更增益值:

// 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 整合,請參閱這篇短文

音效

遊戲通常會根據使用者輸入內容或遊戲狀態變更,播放音效。不過,就像背景音樂一樣,音效也會很快變得令人厭煩。為避免這種情況,通常會提供一組類似但不同的音效。這可能會從輕微的腳步樣本變化,到劇烈的變化,如在 Warcraft 系列中點選單位時所見。

遊戲中的音效另一個重要特徵,是可以同時播放多個音效。假設你正在進行槍戰,有多名演員開著機關槍。每部機槍每秒會發射多次,導致同時播放數十個音效。同時播放多個精確時間的來源聲音,是 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 音訊模型還可視需要納入 Doppler 位移的速度。這個範例會更詳細地顯示多普勒效應

如要進一步瞭解這個主題,請參閱這份詳細教學課程,瞭解如何 [混合位置音訊和 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,且所有 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。在這種情況下,其他計量實作項目可以在 requestAnimationFrame 決定的轉譯時間,對音訊圖表中的 getByteFrequencyData 進行輪詢。RealtimeAnalyserNode這種方法效率較高,但會遺漏大部分的訊號 (包括可能遭到裁剪的部分),因為轉譯作業最多每秒執行 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);

如要進一步瞭解動態壓縮,請參閱 這篇 Wikipedia 文章,其中提供許多實用資訊。

總而言之,請仔細聆聽是否有削波現象,並透過插入主增益節點來避免這種現象。接著,使用動態壓縮器節點來加強整個混音。音訊圖表可能會如下所示:

最終結果

結論

以上就是我認為使用 Web Audio API 開發遊戲音訊時最重要的部分。透過這些技巧,您可以在瀏覽器中打造真正引人入勝的音訊體驗。在結束說明前,讓我提供一個瀏覽器專用的提示:如果分頁使用 page visibility API 進入背景,請務必暫停音訊,否則可能會讓使用者感到不耐煩。

如要進一步瞭解 Web Audio,請參閱更深入的入門文章。如果有任何問題,請查看Web Audio 常見問題,看看是否已獲得解答。最後,如果您還有其他問題,請在 Stack Overflow 上使用 web-audio 標記提問。

在結束本篇文章之前,讓我為您介紹一些在現今遊戲中使用 Web Audio API 的絕佳用途: