iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄系列 第 23

Day23 效能最佳化:PixiJS Renderer、資源預載與快取策略

  • 分享至 

  • xImage
  •  

《九重燼》不是只有幾張背景的小 Demo。現在 asset-manifest.ts 裡登記了 413 筆資產,其中 93 筆背景、32 筆 CG、61 筆道具,音訊也有 227 筆登記。這些不一定都有正式素材,有些還指向 placeholder,但數量已經大到不能再靠「用到再說」混過去。

如果每次換場景才去抓圖,玩家一定會在場景切換時看到卡頓、黑畫面,或一張 CG 突然慢半拍跳出來。Day23 要談的就是這件事:這個專案目前怎麼做預載、快取、低效能模式,以及哪些地方還不夠漂亮。

先講結論:現在不是全部一次載完

最省事的做法,是遊戲啟動時把所有圖片全部塞進快取。這樣切場景當然最穩,但玩家一開始會等很久,手機也可能直接吃爆記憶體。

《九重燼》目前採用比較折衷的做法:用 PreloadManager.ts 註冊 Pixi Assets bundle,進入遊戲時先載 boot,章節切換時再載對應章節 bundle,離開章節後卸載上一章。

PreloadManager:Pixi Assets 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}

Alias 加 bundle 前綴:避免同名素材互相覆蓋

Pixi 的 resolver 是全域的。如果兩個 bundle 都有一張同名圖,例如不同資料夾底下都叫 night.webp,你用檔名當 alias,就可能被後載入的 bundle 覆蓋。Console 也會開始噴 overwrite 警告。

所以現在 alias 直接帶上 bundle id 和完整 url:

alias: `${bundleId}:${url}`

這樣同名檔案就不會撞在一起。CH01:/assets/scenes/foo/night.webpCH02:/assets/scenes/bar/night.webp 是兩個不同 alias。

preload-manifest.json 怎麼來

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 什麼時候載入和卸載

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:快取命中時不要閃一下

LoadingScreen.vue 有個很小但實用的設計:載入超過 120ms 才顯示 loading。

if (loading) {
  if (!showTimer) showTimer = setTimeout(() => {
    visible.value = true
  }, 120)
}

如果 Pixi cache 已經命中,isLoading 可能在同一幀內從 true 回到 false。這時如果 loading 畫面立刻進場,反而會看到畫面閃一下,甚至 transition 卡在透明狀態攔截點擊。

所以 120ms 是一個小緩衝。很快的載入就不要打擾玩家;真的等了一下,再顯示進度條。

這種細節不是效能本身,但會影響玩家怎麼感覺效能。

載入失敗:Preload 和場景載入都有重試

PreloadManagerloadWithRetry() 會最多試 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 Cache,音訊走 AudioManager

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,反而會讓責任變得模糊。

Renderer 調校:低效能模式做了什麼

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 的另一個保護:舊背景先留著

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.jsoncommon 有 146 筆,這代表章節推斷還不夠精準。generate-preload-manifest.ts 可以補上 chapter_00.inkchapter_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 分組還可以更準。這些寫清楚,後面才知道下一輪優化要往哪裡做。


上一篇
Day22 利用 Ink Tags 打造可擴充事件系統
下一篇
Day24 多平台展望:Web 之外還能怎麼走?
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言