《九重燼》不是只有幾張背景的小 Demo。現在 asset-manifest.ts 裡登記了 413 筆資產,其中 93 筆背景、32 筆 CG、61 筆道具,音訊也有 227 筆登記。這些不一定都有正式素材,有些還指向 placeholder,但數量已經大到不能再靠「用到再說」混過去。
如果每次換場景才去抓圖,玩家一定會在場景切換時看到卡頓、黑畫面,或一張 CG 突然慢半拍跳出來。Day23 要談的就是這件事:這個專案目前怎麼做預載、快取、低效能模式,以及哪些地方還不夠漂亮。
最省事的做法,是遊戲啟動時把所有圖片全部塞進快取。這樣切場景當然最穩,但玩家一開始會等很久,手機也可能直接吃爆記憶體。
《九重燼》目前採用比較折衷的做法:用 PreloadManager.ts 註冊 Pixi Assets bundle,進入遊戲時先載 boot,章節切換時再載對應章節 bundle,離開章節後卸載上一章。
src/engine/PreloadManager.ts 是單例。constructor 裡會呼叫 addBundles(),把 src/data/preload-manifest.json 裡的每個 group 註冊成 Pixi bundle。
核心邏輯大概是這樣:
for (const [bundleId, urls] of Object.entries(manifest)) {
const pixiUrls = urls.filter((url) => this.isPixiAsset(url))
const assetsList = pixiUrls.map(url => ({
alias: `${bundleId}:${url}`,
src: url,
}))
if (assetsList.length === 0) continue
Assets.addBundle(bundleId, assetsList)
}
兩個重點:
第一,只把圖片類型放進 Pixi bundle。isPixiAsset() 只接受 avif/gif/jpg/jpeg/png/svg/webp。音訊不進 Pixi bundle,這點後面再講。
第二,alias 不是單純用檔名,而是 ${bundleId}:${url}。
Pixi 的 resolver 是全域的。如果兩個 bundle 都有一張同名圖,例如不同資料夾底下都叫 night.webp,你用檔名當 alias,就可能被後載入的 bundle 覆蓋。Console 也會開始噴 overwrite 警告。
所以現在 alias 直接帶上 bundle id 和完整 url:
alias: `${bundleId}:${url}`
這樣同名檔案就不會撞在一起。CH01:/assets/scenes/foo/night.webp 和 CH02:/assets/scenes/bar/night.webp 是兩個不同 alias。
preload-manifest.json 不是手寫的。scripts/generate-preload-manifest.ts 會掃兩個來源:
src/narrative/**/*.ink 裡的 # bg、# cg、# char、# bgm、# sfx、# ambience
asset-manifest.ts 裡帶有 chapters 欄位的資產掃描到背景 id,就去 characters-scenes.json 找 background URL;掃到 CG,就找 cgs;掃到角色,就找立繪圖。最後把每個 group 的 Set 轉成 array,寫進 src/data/preload-manifest.json。
目前生成出來的 group 有:
boot, common, CH00, CH01, CH02, CH03, CH04, CH05, CH06, CH07, CH08, EXTRA
我實際數過,現在 boot 只有 1 筆,common 有 146 筆,EXTRA 有 125 筆,CH00 到 CH08 則各自有一批章節素材。
目前的分組不是完美的「每章只載自己會用的東西」。腳本會讀 # chapter:CHxx 標籤,也會從 === CHxx... === 這類 knot 名稱推斷章節;但檔名推斷只抓 ch\d+ 這種格式,章節標籤大小寫也還不夠寬鬆。再加上番外檔案和 asset manifest 裡的共用資料,common 變得很大。
目前已經有 bundle 架構,也有部分章節分組,但 manifest 生成規則還可以再細修。
GameView.vue 裡有兩段 preload 流程。
第一次進入遊戲畫面時,載 boot:
await PreloadManager.getInstance().initBoot((progress) => {
store.loadingProgress = progress
})
章節切換時,watch store.chapterId:
await PreloadManager.getInstance().loadChapter(newChapter, (progress) => {
store.loadingProgress = progress
})
if (oldChapter && oldChapter !== newChapter) {
await PreloadManager.getInstance().unloadChapter(oldChapter)
}
返回主選單時,會 unloadAllChapters(),再重新 initBoot()。
這套流程的好處是,資源生命週期跟章節狀態綁在一起。進章節就載,離開章節就卸。缺點是如果 manifest 分組太粗,某些資源仍然會被 common 提早載進來,節省記憶體的效果就會打折。
LoadingScreen.vue 有個很小但實用的設計:載入超過 120ms 才顯示 loading。
if (loading) {
if (!showTimer) showTimer = setTimeout(() => {
visible.value = true
}, 120)
}
如果 Pixi cache 已經命中,isLoading 可能在同一幀內從 true 回到 false。這時如果 loading 畫面立刻進場,反而會看到畫面閃一下,甚至 transition 卡在透明狀態攔截點擊。
所以 120ms 是一個小緩衝。很快的載入就不要打擾玩家;真的等了一下,再顯示進度條。
這種細節不是效能本身,但會影響玩家怎麼感覺效能。
PreloadManager 的 loadWithRetry() 會最多試 3 次,每次失敗後等 1000ms。
await Assets.loadBundle(bundleId, onProgress)
如果 bundle 載入失敗,它會記錄:
[PreloadManager] Load failed for ${bundleId}, attempt ${i + 1}/${retries}
SceneManager 也有自己的 _loadTextureWithRetry()。背景圖載入會重試 2 次,也就是最多 3 次嘗試;全部失敗後回傳 null,場景改用漸層 fallback。
這兩層重試各有位置。PreloadManager 負責「章節資源包」;SceneManager 負責「實際切到某個場景時,如果那張圖還是沒成功,就不要黑畫面」。
網路不穩的時候,這比直接 throw 給玩家看好多了。
Pixi Assets 很適合管理圖片、texture、sprite 會用到的東西。但音訊不是 Pixi 的責任。
Day21 講過,音訊目前由 AudioManager 管 BGM、Ambience、SFX、Voice 四條通道,用的是 HTMLAudioElement。
所以 PreloadManager 裡這段過濾很刻意:
private isPixiAsset(url: string): boolean {
return /\.(avif|gif|jpe?g|png|svg|webp)$/i.test(url)
}
generate-preload-manifest.ts 雖然有掃 # bgm、# sfx、# ambience,但它最後也只會把圖片副檔名放進 preload group。現在 boot 裡沒有 opening BGM,也是這個原因:BGM 是 mp3,不會進 Pixi bundle。
音訊目前有自己的播放和 retry 策略,例如 autoplay 被擋時等首次互動再補播。硬把 mp3 塞進 Pixi bundle,反而會讓責任變得模糊。
SceneManager.init() 會根據 lowPerformance 調整 Pixi renderer:
antialias: options?.lowPerformance !== true,
resolution: options?.lowPerformance === true ? 1 : (globalThis.devicePixelRatio ?? 1),
autoDensity: true,
低效能模式打開時,抗鋸齒關掉,解析度固定 1x。這對高 DPI 螢幕差很多。一般模式可能用 devicePixelRatio 2,也就是實際渲染像素量大幅增加;低效能模式犧牲一點銳利度,換比較穩的幀率和比較低的記憶體壓力。
這個設定是在 SceneManager 初始化時決定的,所以不是所有 renderer 參數都能即時改。抗鋸齒和解析度比較像「重新進遊戲後最穩」的設定,不像粒子數量可以即時套用。
粒子就比較好處理。
GameCanvas.vue 有一個 particleScale():
if (store.settings.lowPerformance) return 0.4
const density = store.settings.vfxParticleDensity
return density === 'low' ? 0.4 : density === 'high' ? 1.6 : 1
SceneManager.setParticleScale() 收到新比例後,如果持續型特效正在播,會清掉再用新的數量重建。
雨的基準數量是 140,血滴是 30,灰塵是 80。低效能模式下變成 0.4 倍;high density 則是 1.6 倍。這比單純開關特效更細一點:玩家可以保留雨、燭光、灰塵的氣氛,但把粒子數壓低。
另外 reduceMotion 會直接停掉持續型效果;disableShake 會跳過 screen_shake。效能和可及性在這裡其實重疊了:不想看動態效果的玩家,常常也是希望裝置少做一點工作的玩家。
SceneManager.setScene() 裡有一個細節:切背景時,不會先清掉舊背景再等新圖載入。它會先載新圖,成功後才清掉舊背景。
const texture = await this._loadTextureWithRetry(backgroundUrl)
if (request !== this.sceneRequest) return
if (texture) {
this._clearBackground()
this.currentBackground = new Sprite(texture)
...
}
如果新圖還在載,舊圖會先留在畫面上。這樣網路慢的時候不會閃成全黑。sceneRequest 則用來處理競態:如果玩家很快連續切場景,舊的載入結果回來時,發現 request 已經不是最新,就直接丟掉。
這個比「載入快一點」還重要。因為 Web 上很多卡頓不是完全消失,而是要讓它不要以難看的方式露出來。
Day23 不是說這套資源系統已經完美。現在它比較像第一版能用的管線,還有幾個明顯可以改善的地方。
第一,common bundle 太大。preload-manifest.json 裡 common 有 146 筆,這代表章節推斷還不夠精準。generate-preload-manifest.ts 可以補上 chapter_00.ink、chapter_01.ink 這類檔名規則,也可以把 # chapter:ch07 這種小寫標籤正規化,讓更多資源落到正確 CH group。
第二,Audio cache 還不是完整的預載系統。音訊目前靠 AudioManager 播放時 resolve source,沒有像圖片一樣分章節預載。對 BGM 來說還可以接受,因為有 crossfade 和首次互動補播;但如果未來 SFX 很多,可能要做一層音效池或預熱。
第三,Pixi texture 釋放要小心。unloadBundle() 可以釋放 bundle,但如果畫面上還有 Sprite 引用某個 texture,就要確認銷毀順序。現在 SceneManager 在換背景和 CG 時會 destroy child,但資源生命周期日後還是值得壓測。
效能最佳化不一定是什麼華麗技術。很多時候只是把資源放在正確的時間載入、在正確的時間釋放,失敗時不要讓畫面破掉,低效能裝置也有退路。
真正麻煩的是誠實面對現況。PreloadManager 已經讓資源載入從「臨時抓圖」進到「bundle 管理」;但 common 太大、音訊還沒有完整預載、manifest 分組還可以更準。這些寫清楚,後面才知道下一輪優化要往哪裡做。