GodotWebGLWebAssemblyGame DevelopmentOptimization

不用重寫遊戲:把 Godot 4 匯出到 WebGL 的 5 個踩坑筆記

··閱讀約 7 分鐘

在獨立遊戲開發裡,最吸引人的承諾之一就是「跨平台」:同一套 GDScript 與場景樹,可以在 Steam 發行桌面版,也能一鍵匯出給朋友在手機瀏覽器裡直接點開試玩。

在 Godot 3 的時代,Web 匯出雖然陽春但還算穩定。到了 Godot 4,底層渲染管線換上了 Vulkan 與 WebGL 2,並預設採用多執行緒(Multi-Threading)架構。效能上限拉高了,但瀏覽器端的相容性門檻也跟著暴增。

我第一次把 Godot 4 專案丟上 Cloudflare Pages 時,它甚至連載入條都沒跑出來,控制台直接噴出一排紅色錯誤。以下是我花了一週時間逐一排查、最終讓它在手機與桌機都能秒開的五個關鍵筆記。

坑一:SharedArrayBuffer 與跨域隔離(COOP/COEP)

這是升級 Godot 4 後 100% 的開發者都會碰到的第一堵牆。畫面整片全灰,Console 留下一行:`Uncaught ReferenceError: SharedArrayBuffer is not defined`。

因為 Spectre CPU 漏洞,現代瀏覽器規定只有在「跨域隔離(Cross-Origin Isolation)」的安全環境下,才允許網頁使用 `SharedArrayBuffer`。這意味著你的主機伺服器必須在 HTTP 回應標頭中強制加入兩行設定:

http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

如果你使用 Cloudflare Pages,只需在專案根目錄的 `public/_headers` 加入這兩行。但真正的代價在於:**一旦開啟 COEP,你網頁裡所有外部圖片、CDN 字體、第三方 iframe 如果沒有提供 CORS 標頭,會通通被瀏覽器直接阻擋。**

如果你的遊戲需要嵌入既有網站或社群平台(如 itch.io 嵌入),往往無法自由控制頂層 Headers。這時唯一的解法是在 Godot 4.3+ 匯出設定中,將 `Threading` 從 `Multi-threaded` 改回 `Single-threaded`(雖然會失去背景執行緒載入,但免去了跨域標頭的硬性綁架)。

坑二:WASM 檔案過肥——從 35MB 壓到 8MB 的瘦身術

Godot 官方提供的標準 Web Export Template 是一個「全能包」:裡面包含了完整的 3D 渲染器、物理引擎、導航網格、甚至是相機模組。

結果就是產出的 `.wasm` 檔案動輒 30MB 到 40MB。玩家在 4G 網路下打開網頁要等 15 秒以上,跳出率超過七成。

要讓網頁秒開,必須自己用 SCons 編譯一份專為你專案裁剪的自訂模板(Custom Export Template):

bash
# 編譯 2D 專用的極簡 WebAssembly 模板
scons platform=javascript target=template_release \
    disable_3d=yes \
    optimize=size \
    module_bullet_enabled=no \
    module_websocket_enabled=no \
    module_upnp_enabled=no \
    module_mbedtls_enabled=no

光是加上 `disable_3d=yes` 與 `optimize=size`,就能把 `.wasm` 體積砍掉將近一半。再搭配 Cloudflare 或 Nginx 的 Brotli 壓縮(`.wasm.br`),最終傳輸大小可以壓到 7MB~8MB,載入時間縮短為原本的四分之一。

坑三:iOS Mobile Safari 的記憶體崩潰(OOM)

在桌機 Chrome 上跑得飛快的遊戲,一拿到 iPhone 上測試,載入到 90% 往往直接整頁重整(WebProcess crashed)。

Mobile Safari 對單一分頁的 WebAssembly 記憶體與 GPU VRAM 配置有極為嚴格的限制。Godot 4 的 WebGL 2 後端在初始化時如果向系統要求過大的連續記憶體空間,iOS 系統層會直接毫不留情地殺死程序。

檢查項目預設設定Web 推薦優化值
VRAM 貼圖壓縮未壓縮 / PNG 內嵌全面轉為 VRAM 格式 (Basis Universal)
MSAA 抗鋸齒4x MSAA關閉或改用 2x(大幅降低 Framebuffer 記憶體)
Audio Buffer 大小4096 frames調降至 1024 或 2048 以減少音訊佇列佔用
動態陰影解析度2048px降至 512px 或改用烘焙假陰影
iOS Safari 相容性最佳化清單

坑四:AudioContext 靜音死鎖與首次點擊喚醒

為了防止網頁在未經許可時發出刺耳聲音,現代所有主流瀏覽器都內建了 Autoplay Policy:`AudioContext` 在使用者產生實體互動(點擊、觸碰)之前,會強制處於 `suspended` 狀態。

如果你的遊戲在載入完成時立刻在 `_ready()` 裡呼叫 `AudioStreamPlayer.play()`,音訊引擎會嘗試向一個暫停的 context 送出封包。在部分瀏覽器裡,這甚至會導致後續的整個聲音管線永久死鎖,哪怕使用者稍後點了畫面也再發不出聲音。

正確的做法是:在 HTML 容器層做一個透明的「點擊以開始」Overlay,並在 JavaScript 端綁定原生事件喚醒音訊:

javascript
// 在進入 Godot 主迴圈前確保 AudioContext 被解鎖
const unlockAudio = () => {
    if (window.AudioContext || window.webkitAudioContext) {
        const audioCtx = Engine.getAudioContext?.();
        if (audioCtx && audioCtx.state === 'suspended') {
            audioCtx.resume().then(() => {
                console.log('AudioContext successfully unlocked');
            });
        }
    }
    window.removeEventListener('pointerdown', unlockAudio);
};
window.addEventListener('pointerdown', unlockAudio);

坑五:漸進式載入(Progressive Loading)與玩家留存

Godot 預設產出的 `index.html` 只有一條粗糙的水平進度條。更糟糕的是,當進度條跑到 100% 時,其實只是「檔案下載完成」;瀏覽器還需要花 1~3 秒去編譯 WebAssembly 與初始化 WebGL Shader。

在這 3 秒的停頓中,畫面靜止不動,玩家會以為網頁當掉了而直接關掉分頁。

解法是自訂 Export HTML Shell,將載入流程明確拆分成階段性回饋:

1. **下載階段 (0% ~ 80%)**:顯示資源包下載百分比與下載速度。

2. **編譯階段 (80% ~ 95%)**:文字提示「正在編譯 WebAssembly 引擎...」。

3. **著色器初始化 (95% ~ 100%)**:文字提示「正在準備場景...」,並在畫面出現首幀時才淡出 Loading 遮罩。

結尾:Web 不是次等平台,而是一個獨立的執行環境

很多獨立開發者把 Web 匯出當成「桌面遊戲的廉價贈品」,結果上線後換來一堆閃退與卡頓的負評。

但當你真正處理好 COOP 標頭、把 WASM 裁剪到 8MB 以內、避開 Safari 記憶體陷阱後,瀏覽器會變成傳播力最強的展示櫥窗——不需要下載 2GB 的安裝包,只要一個連結,全世界的玩家在 3 秒內就能親手體驗你的遊戲。

參考資料