要跑到 58fps,每一幀只能用 17.2 毫秒。JS 主執行緒自己就吃掉 23.4 毫秒 —— 已經超過整個預算。而那裡面有將近一半是 three.js,我們自己寫的遊戲碼只佔 4 毫秒。
瓶頸是 CPU 上的 JavaScript,不是 GPU、不是三角形、不是遊戲邏輯。而 JS 裡的錢幾乎都付給了框架與繪圖驅動:three.js 11.1ms + 原生 GL 5.6ms=16.7ms,正好等於整個 58fps 的預算。我們自己的遊戲碼只有 4ms —— 就算把遊戲碼全部刪光也達不到目標。
下面這條就是一幀。紅線是 58fps 的預算線 —— 線右邊的全部都要消失。
這一節不是開關差分推的,是直接抓 CPU profile、再用 sourcemap 還原到真實原始檔得到的。表格互不重疊,加起來就是 JS 總量。
| 來源 | 每幀 ms | 佔 JS | 是什麼 |
|---|---|---|---|
| three.js | 11.11 | 47.6% | WebGLRenderer、場景圖矩陣、動畫插值 |
| 原生 / GL driver | 5.62 | 24.1% | 貼圖上傳、buffer 寫入、狀態綁定、編譯 |
| React / UI | 1.25 | 5.3% | HUD 元件(含開發模式專用開銷) |
| 渲染 Adapters | 1.12 | 4.8% | 殭屍 0.71、道具 0.33、其餘零星 |
| instanced-mesh 套件 | 1.08 | 4.6% | 逐 instance 矩陣與骨骼上傳 |
| 遊戲邏輯 / Engine | 1.05 | 4.5% | AI、物理、生成、迴圈 |
| Canvas 2D 疊層 | 0.95 | 4.0% | Overlay/ 那四個檔 |
| 其他 RenderSystem + legacy + 引擎工具 | 1.18 | 5.1% | |
| JS 主執行緒合計 | 23.36 | 100% | 整幀 28.1ms,其餘 ~4.7ms 不是 JS |
| 分區 | 每幀 ms | 主要函式 |
|---|---|---|
| WebGLRenderer 與其他 | 5.20 | renderBufferDirect · setProgram · 各種綁定 |
| 場景圖:矩陣更新與相乘 | 3.23 | updateMatrixWorld 2.19 · multiplyMatrices 0.53 |
| 動畫:Mixer 與關鍵影格插值 | 2.68 | update 1.21 · evaluate 1.08 · slerpFlat 0.39 |
後兩列合計 5.91ms,全部由殭屍的逐隻骨骼更新驅動 —— 每隻怪每幀各跑一次 mixer.update() 與 updateMatrixWorld()。這是整份報告裡最大的可處理區塊。
第一版我照打包產物的 chunk 名字分類,得到「Canvas 2D 疊層 5.70ms、第二大」。那是錯的 —— 真正的數字是 0.95ms,差了六倍。
原因:WebGL2DContext-*.js 這個 chunk 的名字只是打包器挑的進入點檔名,裡面實際裝著 three.js 的 WebGLRenderer(用字串常數驗證:SHADOWMAP_TYPE_PCF、PMREM、ACES_FILMIC 全部只出現在這個 chunk 裡)。我把框架的成本記到了疊層頭上。
修法是開 sourcemap 重新打包、把每個取樣點還原回原始檔,也就是現在這張表。教訓:chunk 名字不是模組歸屬。
板子上連續擷取 60 秒、2097 幀的每一幀耗時(一般遊玩,不是量測場景)。平均 34.9 fps —— 但平均值在這裡沒有意義:
| 每一幀落在哪一格 | 幀數 | 比例 | 玩起來是 |
|---|---|---|---|
| 1 個 vsync(≤20ms) | 894 | 42.6% | 那一幀是 60fps |
| 2 個 vsync(20–37ms) | 1038 | 49.5% | 那一幀是 30fps |
| 3 個 vsync(37–54ms) | 102 | 4.9% | 明顯頓一下 |
| 4 個以上(>54ms) | 63 | 3.0% | 卡 |
幀長幾乎五五對分在 60fps 與 30fps 兩格之間。畫面每一幀都在這兩個速度之間跳,而人眼對「忽快忽慢」比對「一直慢」敏感得多 —— 穩定的 30fps 會比現在這種 34.9 fps 好看。
另外還有 5.05% 的幀超過 50ms(106 次/60 秒),最差單幀 466.7ms(將近半秒)。那是實實在在的頓挫。
| 每幀耗時 | p50 | p95 | p99 | 最差 |
|---|---|---|---|---|
| 毫秒 | 33.3 | 50.0 | 83.3 | 466.7 |
開發版左上角只有一個 FPS 數字。單一數字看不出上面這種「五五對分」的抖動 —— 它會在 30 和 60 之間跳,或顯示一個沒有意義的平均。即時走勢圖已經排進待做。
這一節是交錯 ABAB 開關量的,條件與上一節不同(較早的版本、殭屍 38 隻、開發伺服器)。只能拿來排序,不能跟上面的 ms 相加。
| 子系統 | 關掉值多少 | 判定 |
|---|---|---|
| 殭屍(全部) | +23.7 fps | 最大 但全部拿掉也只有 9.6ms,光靠它到不了 58 |
| 陀螺 | +4.8 fps | 第二大,但動它會直接影響玩法主體 |
| 背景 | +2.5 fps | 可考慮 |
| UI Canvas | +2.2 fps | 比疊層本身的 0.95ms 還大,因為這個開關也擋掉了它引發的 three.js 繪製 |
| 殭屍描邊 | +1.7 fps | 幅度散 |
| 特效 / 票券 / 障礙 / 發射台 / 陀螺描邊 | 各 ≤1.0 fps | 全部加起來也補不上缺口 |
| 影子同步 | +0.3 fps | 不用碰 |
殭屍描邊原本是另一組獨立的渲染物件,帶著自己的矩陣與骨骼貼圖 —— 完整第二份上傳。這批把它併成同一個物件的第二個材質群組,場上殭屍相關物件從 14 個降到 6 個。
第一輪只跑 4 對,量到 +1.70ms,我就報出去了。加到 8 對之後收斂到 +0.58ms。
原因是同一份程式碼、不同時段跑,段與段之間就會差到 1.95 fps —— 雜訊跟訊號一樣大。教訓:這塊板子上任何小於 2 fps 的宣稱,需要 8 對以上才作數。
對照兩個數字:把物件整個關掉不畫,一個約值 0.4 fps;只是把物件併起來、繪製照舊,一個只值 0.1 fps。
⇒ 真正貴的是「畫」,不是「管理」。先前把「少一個物件」講成單一原因是不夠精確的 —— 裡面有兩塊,而這批只吃到便宜的那塊。
這一節的東西都做出來了、也都在板子上量了,結果是量不到。負面結果跟正面結果一樣要留紀錄,否則下一個人會再做一次。
| 做了什麼 | 實測 | 判定 |
|---|---|---|
| 姿勢共用池 同型別、同動作、同時間點的怪只算一次姿勢 | +0.55 fps 區間 −0.08~+1.17 | 跨過 0 姿勢計算確實從 26 次降到 16 次(−38%),但換不到可量到的時間 |
| 動畫時間量化 把動畫時間對齊格子以提高共用率 | 26→15 次 (15Hz) | 沒有價值 不量化就已經 26→16;15Hz 只多省 1 次、12Hz 多省 2 次。不必為它付畫面代價 |
| 整個關掉描邊 用現成開關直接量上界 | ~+0.9 fps | 上界很低 三角形 −28%、draw call −6,fps 幾乎沒動。「改描邊做法」這條線比先前以為的低很多 |
每一個改動的效果都在 0.5~1 fps,而同一份程式碼、不同時段跑,段間差可達 2.7 fps(標準差 0.83)。
量測誤差比被量的東西還大 ⇒ 每一批的結論都會是「可能有、可能沒有」,而決定會建立在猜測上。
雜訊的主要來源找到了 —— 怪的數量在 21~38 之間自由變動,而 fps 對怪數極度敏感。做了一個只存在於開發版的量測場景(怪數、種類、位置全部固定,不生成也不死亡),在板子上量:
| 同一塊板子、同一份 build、各 6 段 × 20 秒 | 段間標準差 | 最大差 | 怪數 | draw call |
|---|---|---|---|---|
| 一般遊玩 | 2.114 | 6.10 fps | 26 / 37 / 38 | 64–83 |
| 固定場景 | 0.973 | 2.32 fps | 26(唯一) | 59–65 |
標準差砍半、最大差砍到三分之一。但 0.97 仍然和要量的效果同一量級,而且 draw call 還在 59–65 之間跳 —— 還有東西沒釘住,正在找。
工作量計數器(呼叫了幾次動畫更新、幾次矩陣重算、幾次骨骼上傳)驗「這個改動有沒有做到它宣稱的事」—— 這是確定性的、零雜訊。
fps 只用來驗「好幾個改動疊起來值不值得」,不再對單一改動下 fps 結論。
前一節的三個負面結果讓人以為「殭屍骨骼」這條路是死的。不是 —— 在固定場景裡把共用力道開到極限之後,效果非常明確。
| 固定場景 26 隻、4 輪交錯、每段 20 秒 | 目前設定 | 開到極限 | 差 |
|---|---|---|---|
| 實際計算的姿勢數 | 22 / 26 | 6 / 26 | −16 |
| 矩陣重算次數 | 2049 | 1295 | −37% |
| 動畫更新次數 | 22 | 6 | −16 |
| fps | 39.94 | 43.22 | +3.27 |
| 逐輪差 +1.37 / +4.68 / +3.69 / +3.37 —— 4/4 同向 | = 1.90 ms/幀 | ||
批 3 的預設設定幾乎沒有共用到(26 隻裡只共用掉 4 隻)。不是這條路沒價值,是力道沒開。
同時這個實驗也否證了一個先前的判斷 —— 有人推測「矩陣重算主要來自繪圖框架每幀對整個場景的那一次,跟我們算幾次姿勢無關」。實測是姿勢少算 14 次、矩陣重算就少 652 次,關係非常直接(每次遞迴約 47 個節點,剛好是一副骨架的量級)。
上面的極限是靠「把動畫時間對齊到 1 秒的格子」逼出來的,那會讓動畫一頓一頓。
正在做的替代方案是相位分桶:現在每隻怪的動畫起始點都不一樣(用 id 算的),所以永遠湊不成一組;改成出生時分到 K 個相位桶其中一個,16 隻小怪在 K=6 時就變成最多 6 組。
好處:時間仍然連續前進,動畫不會一頓一頓。代價:同一桶的怪會同手同腳。K 越小省越多,也越明顯。
靜態截圖看不出「一群怪同步走路」,所以驗收材料包含連續幀格狀圖。這是產品判斷,不是工程判斷。
| 問題 | 調查結果 |
|---|---|
| 全部 10 種怪都烘 | 不可行 需要 121.6 MB 常駐記憶體(含法線 243 MB),板子吃不下;其中 4 種頂點數還超過貼圖上限 |
| 只做一種怪(man,場上約 16 隻) | 技術可行,貼圖 1.72 MB |
| 能拿回多少 | 上界約 2ms(+3 fps),而缺口是 10.9ms |
| VAT 自己的成本 | 每頂點每幀多 1–2 次貼圖取樣 —— 3000 頂點 × 16 隻 = 每幀 4.8 萬次,描邊再一次。淨值有可能是負的 |
| 會失去什麼 | 殭屍轉頭看最近陀螺的效果會不見(那是當下才知道的資料,烘不進去)。頭部上仰可以烘進去 |
| 未查的風險 | 影子走另一條 CPU 骨架複製路徑,身體改 VAT 之後兩邊姿勢可能對不上 |
建議不做。不是因為難,是投報率:新增一條烘焙管線與一份進版控的產物,換 2ms 上界、賠掉轉頭、還帶一個沒查的影子問題。
three.js 加上原生 GL driver 就是 16.7ms,而 58fps 的整個預算是 17.2ms。我們自己寫的遊戲碼全部加起來只有 4ms,就算刪光也達不到目標。
⇒ 真正能動的只有兩件事:讓框架少做事(少算姿勢、少更新矩陣)與讓它少畫一次(少 draw call、少狀態切換)。單一改動不會達標,必須累積。