遊戲音訊的爆音除錯:四輪修復的踩坑記

Crystal Hangul 的試玩回饋裡,「爆音」這個詞出現了三次。第一次是進戰鬥的時候,第二次是殺第一隻敵人的時候,第三次是戰鬥結束跳結算畫面的時候。三個爆音的原因各不相同,我修了四輪才全部解決。

第一輪:進戰鬥爆音

玩家從選關畫面進入戰鬥,畫面切換的瞬間聽到一聲短促的 click。每次進關都會發生。

原因是 AVAudioSession 和 AVAudioEngine 的第一次啟動。iOS 的音訊硬體在 session 被啟用的那一刻會做一次配置,這個配置過程會產生一個聽得到的 click 聲。原本的 code 在進入戰鬥時才啟動音訊引擎,所以每次進關都是一次「第一次啟動」。

我把啟動時機移到 App 啟動畫面的背後。App 一開的時候就呼叫 prewarm(),把 session 啟用、引擎跑起來、所有音效 buffer 全部預載。之後進戰鬥、回選單、再進戰鬥,音訊引擎全程在跑,不會再有硬體重配置的 click。

這一輪修完之後,進戰鬥的 click 消失了。但試玩回饋馬上回報了下一個爆音。

第二輪:首殺爆音和多聲部硬剪

第一隻敵人被打死的時候,會聽到一聲很刺耳的爆音。之後的敵人碎裂就正常了,只有第一隻有問題。

這個爆音有兩個幫兇。

第一個是主執行緒卡頓。敵人死亡的時候,渲染層要生成碎片的 3D mesh(十幾顆不規則碎晶)。第一隻敵人死的那一幀,這些 MeshResource 全部現場計算,卡住了主執行緒。音訊引擎的 render callback 被延遲,音訊 buffer 出現空洞,DAC 就爆了。

我把碎片 mesh 改成 static cache:App 啟動的時候就預先算好 16 顆碎片形狀,戰鬥中直接從 cache 拿。碎片的 seed 是固定的,每次長一樣,沒有理由每次都重算。

第二個幫兇是多聲部疊加。敵人碎裂的那一刻,碎裂音效、強射 whoosh、字塊完成和弦、BGM 可能同時在響。所有聲部的波形加在一起,峰值超過 0dBFS,DAC 硬剪成方波。硬剪的聲音就是爆音。

我在主混音輸出後面接了 Apple 內建的 Peak Limiter。它會在峰值快超標的時候自動壓縮振幅,避免硬剪。主混音的輸出音量也留了 headroom 給引擎外的 TTS 韓文發音,兩邊加起來也不會爆。

同一輪還處理了 RealityKit 的暖機問題。RealityKit 的 ARView 在建立的時候會啟動一個進程級的內部音訊引擎(CoreRE)。這個啟動也會產生 click,跟 AVAudioEngine 的啟動是兩回事。我在 App 啟動時先建一個隱形的 ARView 讓 CoreRE 暖起來,進戰鬥時 RealityKit 就不會再做第一次啟動了。

插曲:暖機 ARView 害畫面全黑

上一輪加的隱形暖機 ARView 引起了一個新問題:進戰鬥之後畫面全黑。美術背板、敵人、塔全部消失,只剩 SwiftUI 的字塊盤浮在黑色背景上。

原因是 RealityKit 不允許兩個 ARView 同時活著。暖機 ARView 和戰鬥 ARView 並存的時候,渲染管線衝突,戰鬥 ARView 的 scene 整個沒有輸出。

我改成交棒制:進戰鬥前先銷毀暖機 ARView,再建戰鬥 ARView。回選單時(等視圖樹拆完之後延遲一個 runloop)再把暖機 ARView 建回來。任何時刻都恰好只有一個 ARView 存在,CoreRE 全程不經歷「最後一個 ARView 消失」的關機重啟,爆音對策的效果保留。

第三輪:結算爆音

最後一波打完跳結算畫面的時候,勝利音效又爆了一聲。這個爆音在 Peak Limiter 加了之後仍然存在,因為原因跟振幅無關。

原本的一次性音效播放用固定的 cursor 輪替:第一個音效用 player 0,第二個用 player 1,以此類推。問題是戰鬥結束那一刻是池子最忙的時候。最後一波的連殺碎裂音效尾音很長(五到六秒),十個 player 可能大部分都還在響。勝利 stinger 輪到一個還在響的 player,.stop() 在波形中間截斷,產生一個不連續的 click。Peak Limiter 壓的是振幅,救不了波形不連續。

我改成追蹤每個 player 的尾音預計結束時刻(用 buffer 長度除以 sample rate 算出來的)。選 player 的時候優先挑尾音已經衰減完的,.stop() 的時候波形已經是零,沒有不連續的問題。全部都在忙的時候才挑剩餘尾音最短的,被截斷的聽感降到最低。

四種爆音,四個不同的原因

回頭看,四輪修的爆音各有不同的根源:

  • 進戰鬥 click:AVAudioSession 硬體重配置。解法是把啟動時機移到 App 啟動。
  • 首殺爆音:主執行緒被 MeshResource 生成卡住,音訊 buffer 空洞。解法是 mesh 預算 + static cache。
  • 多聲部硬剪:多音源疊加超過 0dBFS。解法是 Peak Limiter。
  • 結算 click:player 輪替打斷尚未衰減的波形。解法是追蹤尾音結束時刻,優先挑閒置的 player。

加上 RealityKit ARView 的暖機問題(CoreRE 內部音訊引擎和 AVAudioEngine 是兩套,要分開暖)以及暖機 ARView 引起的畫面全黑(任何時刻只能有一個 ARView),整個除錯過程跨了音訊和渲染兩個領域。最難的部分是辨認爆音的來源:同一個「爆了一聲」的症狀,背後的原因可能是振幅、可能是波形不連續、可能是主執行緒卡頓、可能是音訊硬體配置。每一種都要不同的修法。


水晶韓語塔防是一個用 RealityKit 做的 iOS 塔防遊戲,拼韓文句子建塔,打敗水晶怪物。App Store 下載。這篇是「費曼的實習生」系列,記錄遊戲開發中遇到的技術問題和除錯過程。