目標是板子上穩定 60fps,現在是 38–42fps。瓶頸已完全定位在 CPU 的程式運算,不是顯示晶片。已完成的優化拿到 3.5 毫秒,還差 7.2–9.6 毫秒 —— 剩下兩條路樂觀估計剛好夠,但都需要投入開發時間。
前三節(目前的狀況 → 結論 → 做了什麼還沒做什麼)看完即可掌握全貌;技術細節與量測資料集中在後段。
下面兩根是同一把尺。上面是 60fps 的預算,下面是現在實際跑出來的。
這張圖回答「一幀的時間被誰用掉」。看比例就好(它在固定場面上量的,總長比實戰短)。
最右邊「等顯示晶片」只有 0.75 毫秒,細到幾乎看不見 —— 佔 4%。
⇒ 顯示晶片一直在等我們,不是我們在等它。時間全部花在 CPU 跑程式。換更好的顯示晶片不會有幫助。
① 瓶頸已經完全定位 —— 在 CPU 的程式運算,不是硬體。每一毫秒都指認到具體的程式段落,沒有未知的黑洞。
② 60fps 有機會但不保證 —— 剩兩條路(分工到第二條執行緒、用更快的語言重寫核心計算),樂觀估計剛好補得上缺口,但都要投入數週,而且各有一個還沒被證明的前提。
③ 建議先花兩天各做一個小驗證,量出「真的拿得到多少」再決定要不要投 —— 可以避免投錯一到兩週。
改成鎖定穩定 30fps。30fps 的預算是 33.3 毫秒,而我們現在是 23.9–26.3 —— 已經在裡面了。
那樣問題會從「要快一倍」變成「消除偶爾的頓挫」,而頓挫的成因已經全部查明(下一節第 ② 項),是可以在數天內解掉的東西。
⚠️ 這不是技術判斷,是產品決定 —— 60fps 與 30fps 的手感差異要由產品端評估。
優化開始前,這個缺口是 10.7–13.1 毫秒;現在是 7.2–9.6。
| 成果 | 數字 | 玩家感受 |
|---|---|---|
| 渲染優化(批次化、描邊合併、動畫相位共用等五項) | −3.5 ms | 幀率從約 32–37 提升到 38–42 fps |
| 載入流程修正 | −1.47 秒 | 每一局都少等 1.5 秒 |
| 顯示記憶體 | −144 MB | 降低記憶體壓力的風險 |
| 項目 | 投入 | 拿到什麼 | 風險 |
|---|---|---|---|
| ① 併入載入修正 | 已完成 只差核可 |
每局少等 1.5 秒 記憶體省 144MB |
低 畫面已驗過無差異 |
| ② 貼圖預先載入 | 約 2–3 天 | 戰鬥中 5 次頓挫消失 | 低 載入多 0.8 秒、記憶體多 58MB |
| ③ 改用第二條執行緒 | 數週 需重做資料流 |
最多 6.15 ms | 中 雛形跑得比原本慢,要重新設計才拿得到 |
| ④ WASM 重寫核心計算 | 1–2 週 | 約 3 ms | 中 倍率是業界經驗值,我們沒量過 |
第一步(低風險、玩家馬上有感):核可 ①、執行 ②。合計約 2–3 天,玩家會感覺「載得快了、打起來不卡了」。⚠️ 但幀率不會變。
第二步(各花一天買一個數字):③④ 各做一個小規模驗證,量出「真的拿得到多少」。兩天,可以避免投錯一到兩週。
第三步:拿到那兩個數字之後,再決定要不要投 ③④ 的完整版。
| 量測方式 | 在實際的 Android 板子上量,正式打包版本,真的玩一局 |
| 宣稱門檻 | 由量測誤差反推:差距小於 0.6 毫秒的一律不宣稱;0.6–1.5 毫秒要交錯對照至少三對 |
| 覆蓋範圍 | 每幀 19.4 毫秒全部拆解到具體程式段落,沒有無法解釋的部分 |
| 自我修正 | 過程中有 7 個結論被後續量測推翻並更正,清單附在文末。這是刻意保留的 —— 避免有人引用已作廢的數字 |
上面是結論,下面是支撐它的量測與推導。不需要逐段讀 —— 需要查證某一個數字的時候再翻到對應那節。
下面兩根是同一個尺。上面是預算,下面是現在。(正式版打包、真的玩一局量到的。)
60fps = 1000÷60 = 16.67 毫秒,而這塊板子確實跑得到 60Hz(空白頁實測兩輪:59.95/60.03 fps,p50 16.7、p99 16.8–16.9,連尾巴都貼著 16.7)⇒ 16.67 就是正確的目標。
(更早的報告採用 17.24=58fps,理由是「板子天花板只有 57–59fps」—— 那個說法實測不成立。⚠️ 唯一沒驗的是「有 WebGL context 時會不會降頻」,那個中間狀態沒量過。)
超出去的那 7.2–9.6 毫秒,幾乎全在 CPU 上的 JavaScript —— 等 GPU 只佔 4%。
⇒ 換更好的顯示晶片不會有幫助,問題不在那裡。能做的只有兩種:讓那些計算變快,或把一部分搬到第二條執行緒同時做。
這張圖回答的是「時間花在哪一塊」,不是「現在幾毫秒」。
它在量測場面上量的(怪固定 26 隻釘死不動、開發版打包)—— 場面釘住才比得出「某個改動值多少」,代價是它比實戰輕,所以總長只有 20.25 而不是 23.9–26.3。看比例,不要看絕對值。
① 最右邊那條「等 GPU」只有 0.75 毫秒,細到幾乎看不見。⇒ 顯示晶片一直在等我們,不是我們在等它。
② B 和 C 是最重的兩塊(合計 66%)—— 而剩下能做的兩條路,正好一條打 B、一條打 C。
因為「現在 23.9–26.3」已經是含了所有優化之後的數字。沒有那些優化會更慢。三根同一個尺,都是實戰量到的:
第一根:如果我們什麼都沒做,一幀要 27.1–31.0 毫秒(約 32–37 fps)。
第二根:我們做的優化把它拉到 23.9–26.3(約 38–42 fps)。那就是那 3.5 毫秒的位置 —— 它已經被花掉了。
第三根:而 60fps 要的是 16.67。所以還差 7.2–9.6。
⇒ 「還差 7.2–9.6」是扣掉我們已經做的之後還差的,不是還沒開始做。
陀螺批次化、殭屍描邊併材質群組、相位分桶、10 套骨架不掛進場景樹、動畫格號改走 uniform 陣列。
合起來 3.5 毫秒(整合分支 vs main,同一次跑完、逐對 3.6/3.6/3.5,三次幾乎一樣,很穩)。
多執行緒 = 現在「算遊戲」跟「畫畫面」是同一個人在做,一件做完才做下一件。多執行緒就是找第二個人專門畫畫面,兩邊同時做。
WASM = 網頁的程式是用 JavaScript 寫的,那是為了「好寫」設計的語言,速度有天花板。WASM 是另一種格式,可以把 C 或 Rust 這類比較接近機器的語言編譯成瀏覽器看得懂的東西 —— 同樣的計算通常快 2 到 5 倍。完整說明另開一頁。
最上面那根是「要填滿的洞」,下面三根是「各能填多少」。看長度夠不夠到最上面那根就好。
多執行緒 6.15 = 量到的。那就是 renderer.render() 每幀的實測值(本頁第四節的 C 段)。把它整段搬走,主線就從 19.4 降到 13.35。
⚠️ 但「拿得到」還沒被證明 —— 整幀會變成「主線」與「第二條執行緒」兩者中比較慢的那個,而現在那條執行緒自己要跑 22.7–29.3ms(它把主線做過的工作原封不動再做一次)。要拿到這 6.15,得先把它的資料流重做。
WASM 約 3 = 量到的數字打了兩層折。推導鏈:
B1 算動畫 3.40 + B2 骨頭換算 2.55 = 5.95ms(實測,而且兩種完全不同的儀器互相印證:profiler 5.91/直接包住方法 5.95)
→ 扣掉量測工具本身的成本、換算到現行的分桶設定 → 3.3–4.1ms(這是天花板)
→ 再扣 WASM 自己的跨語言成本 → 約 3ms
🔴 最後那一步沒有數字支撐,是我抓的。WASM 不會讓那段歸零(資料要在兩種語言之間進出),但到底要付多少,只有做原型量了才知道 —— 那就是為什麼建議先花一天做原型。
單獨做任一條都填不滿。多執行緒 6.15 差一點、WASM 約 3 差比較多。
兩條都做約 9.15,剛好落在缺口的上緣(7.2–9.6) —— 也就是順利的話剛好夠,不順利就還差一點。
它們打的是不同的兩段(一段是「算骨頭」、一段是「畫出來」),理論上不重疊,但沒有人實測過兩個一起做會不會真的相加。
這三十輪裡已經有好幾個「應該會相加」的東西,實測不會。
錢幾乎全花在「算」(前兩塊合計 5.95ms),不是花在「送」(0.95ms)。這就是為什麼 WASM —— 讓計算本身變快 —— 打在對的地方。
那 0.73ms 的「轉頭」值得單獨記著:每隻怪每一幀都重算一次「頭要看哪個陀螺」,而相位分桶碰不到它(分桶只讓「姿勢」能共用)。
真正「畫」的只有 GL 那 7% 裡的九分之一。其餘 93% 是 three.js 在做準備 —— 走過場景、排序、每次繪製前重設一遍狀態。
D 不是一個功能,是「扣掉 A、B、C 之後剩下的一切」。拆開之後發現它不是一塊,是一堆各自 1 毫秒以下的小東西:
| 是什麼 | 毫秒 | 能不能動 |
|---|---|---|
| 其他角色的狀態映射 陀螺、特效、場地 —— B 只涵蓋殭屍 | 1.0–1.7 | 真實工作,理論上可以像 B 一樣優化,但單項就這個量級 |
| 透明畫布疊層 票券數字、空中特效那一層 | 0.85 | 真實渲染 |
| 其他每幀的小工作 七個不在主迴圈裡的回呼,各自 <0.5 | 1.18 | 各自都是真實工作 |
| 瀏覽器自己的排程開銷 | 約 1.0 | 動不了 |
| 量測工具本身的成本 | 約 0.7 | 不是真實成本 正式跑的時候沒有這一項 |
⇒ 扣掉量測工具之後真實約 4.8ms,而最大的單項只有 1.0–1.7ms —— 沒有值得單獨動的東西。
⚠️ 兩件順帶查到的:透明畫布那層在量測場面完全沒在畫(九個 2D 繪圖方法一次都沒被呼叫)—— 那只對這個場面成立,實戰有票券數字時會有。另外,我們一度以為 D 是「主迴圈裡的一整塊」,拆完才發現超過一半根本不在主迴圈裡。
「它的架構比較適合遊戲」有依據;「它跑我們的內容在這塊板子上能到 60fps」沒有人量過 —— 那是推論不是量測。
核心差別只有一句:遊戲引擎把「每幀重複做的事」設計成只在有變化時才做;繪圖函式庫沒有那個假設。我們做的每一批優化,本質上都是在手工補上引擎本來就內建的東西。
| 機制 | Cocos 的做法 | 我們的現況 | 能不能效法 |
|---|---|---|---|
| 每次繪製怎麼傳參數 | 每個物件有常駐的 GPU 參數區,畫的時候只綁一次 | 每個物件、每幀、每個參數各推一次 | 架構限制 three.js 有自己的一套,繞過要重寫渲染管線 |
| 沒變就不做 | 更新前先判斷「這個變了嗎」 | 部分低頻參數每幀仍重寫 | 我們的用法 可以改 |
| 合批 | 引擎內建自動合併 | 要手動做 —— 就是我們的「陀螺批次化」 | 我們的用法 已做 |
| 骨骼動畫 | 預烘成貼圖,每幀只傳播播放進度 | 每幀每隻怪在 CPU 上重算整棵骨架 | 我們的用法 預烘已做但目前預設關(見第六節) |
不換引擎改不動的固定成本:three.js 每次繪製前重設一遍狀態、以及場景樹的走訪 —— 合計約 7–8 毫秒。那就是為什麼「把渲染搬到第二條執行緒」是唯一能繞開它的方向(見第七節)。
先前的報告寫過「three.js 每幀從頭重建一次繪製清單,那是它跟 Cocos 差最多的地方」。實測:排序每幀只叫 1 次、0.04 毫秒;建清單 0.19 毫秒。
真正的大宗是「每次繪製前準備材質與 skinning 狀態」的那 52% —— 要減它得改材質與批次結構,不是換一個旗標。
| 問題 | 六張玩家陀螺皮膚 + 三張,在正式版一直以 2048² PNG 載入。原因是 PropAdapter.ts:320 把「含副檔名的檔名」直接串成路徑,繞過了會選 WebP 的解析函式(其他貼圖早就是 1024² WebP) |
| 做法 | 改走帶 fallback 的解析函式 + 補出缺的 9 張 1024² WebP |
| 省下 | 載入時間 −1.47 秒(−5.8%)、GPU 常駐記憶體估 −144MB。八張貼圖 29.5MB → 0.90MB |
| 戰鬥中 | 零效果 —— 那些貼圖在按下開始鍵的當下就上傳完了,比進戰鬥早 25 秒 |
| 視覺 | 零代價。先造「已知變糊」與「已知壓縮瑕疵」兩個對照證明指標抓得到,出貨版兩項都沒落在退化那側 |
待決 改動還沒 commit(已另外備份到專案內,資料不會丟)。
| 問題 | 預烘骨骼要告訴 GPU「這隻怪播到第幾格」,而套件的通用機制是把值排成一張貼圖每幀上傳。每多開一種預烘怪,每幀就多一次貼圖上傳 |
| 做法 | 改成 uniform float uBakedFrame[200],shader 用 instance 編號查。完全不用上傳 |
| 結果 | 上傳的劑量曲線整條消失(四種劑量全是 18 次);中位數收益從 1.0–2.7 變成 2.0–2.4ms(更大也更穩) |
| 但 | 卡頓沒有跟著好 ⇒ 證明先前「上傳次數 ↔ 卡頓」那條曲線是混淆不是因果 |
整套優化 vs origin/main(同一 session、同一組態):−3.5 ms(逐對 3.6/3.6/3.5,全距 0.1ms)。
| 項目 | 為什麼放棄 |
|---|---|
| 預烘骨骼(維持預設關) | 收益隨「有幾種怪」放大,代價隨「有幾隻怪」放大。三種全留:平均收益撐不住宣稱、長幀 4–6 倍;只留一種:+1.4ms、長幀 2–3 倍。而 1.4ms 不會讓任何一幀進得了 vsync,卡頓卻看得見。程式留著,缺口縮小後再回來評估 |
| 動畫時間量化 | 整條只值 0.2 次上傳。三種怪是三張獨立貼圖,分桶只在同型別內收斂 ⇒ 不需要蔡做那個視覺決定 |
| 把姿勢計算搬到第二條 Worker | 要接受「動畫慢一幀」,蔡否決。且 14 種動作有 8 種的時間來自遊戲計時器不是牆上時鐘,GPU 算不出來 |
| 特效圖集降尺寸 | 按需載入,完整一局 45 秒零請求 ⇒ 不是尖峰來源。但 63 張合計 527MB 是未爆彈 |
| 靜態節點 matrixAutoUpdate=false | 機制上完全無效 —— 那些呼叫全部帶 force=true,three 在 force 為真時不看那個旗標 |
| 怪物貼圖降到 512² | 最差幀 497→160ms、視覺零代價,但中位數一格沒動(降 16 倍像素)⇒ 貼圖頻寬不是瓶頸。正式版起點已是 1024²,再降的空間比看起來小 |
| dirty flag 類做法 | 角色幾乎每幀都變、命中率趨零;而且那些呼叫本來就帶 force=true,是「明知要重算」才叫的 |
| draw call 清理(47→30) | 0ms。空的粒子發射器每幀什麼都不做,拿掉等於沒差 |
| VAT(全頂點烘成貼圖) | 需 121.6MB,板子吃不下;4 種怪頂點數超過貼圖上限 |
問題:現在「算遊戲」跟「畫畫面」是同一個人在做,一件做完才做下一件。畫畫面那段要 6.15ms。
做法:找第二個人(瀏覽器的第二條執行緒)專門畫畫面,兩邊同時做。畫面資料每一幀送過去。
拿到:主線從 19.4ms 降到 13.35ms —— 進得了 60fps 的預算。
代價:按鍵到畫面反應會多約 16 毫秒(一幀)。畫面本身是對的,不會錯位,只是整張晚一點出現。你已經接受這個代價。
難在哪:不是打開一個開關,是要重做資料流。現在的版本讓第二個人「把第一個人做過的事再做一次」(把資料解開、重建場景),所以它自己要跑 22.7–29.3ms,比主線整幀還多。真正要解的是「兩邊共用同一份資料」而不是各建一份。
已經證明的:第 1–4 階段做完並驗過逐像素零差異;第二條執行緒畫同一批東西沒有比較慢(4.94ms vs 主線 6.15ms)。我原本反對的理由(「GPU 只有一顆,搬過去沒用」)被三次量測連續否定。
問題:每一幀要算 26 隻怪的骨頭要擺在哪裡(動畫插值 3.4ms + 骨架傳播 2.55ms)。那是純數學計算,而 JavaScript 算數學不夠快。
做法:那兩段改用 C 或 Rust 寫,編譯成 WASM(見上一節的說明框)。
拿到:最多 3.3–4.1ms,而且還要扣掉 WASM 自己的成本(資料要在兩種語言之間搬來搬去)。實際大概 3ms 上下。
代價:一到兩週,而且要引進一整套新的建置工具。等於重寫整個動畫系統。
不確定的地方:「快 2–5 倍」是業界的一般經驗值,我們這個案例能加速多少沒有人量過。⇒ 建議先花一天做一個小原型,只搬一小段、在板子上量出真實倍率,倍率撐得住再做完整版。
本來的疑問:每幀有 5.35ms(27%)我們不知道是什麼,而它比 WASM 能拿的還多。
查完了:沒有大塊。扣掉量測工具自己的成本後真實約 4.8ms,而最大的一項只有 1.0–1.7ms —— 其他 adapter 與狀態映射 1.0–1.7、疊層 0.85、其他排程 <0.5 各項、瀏覽器自己的排程約 1.0(動不了)。
結論:這裡沒有 WASM 那個量級的東西。而且整條渲染路徑現在完全解釋得通、沒有藏起來的大塊 —— 再查下去不會再冒出新東西。
| 項目 | 問題是什麼 | 做法 | 能省 |
|---|---|---|---|
| 轉頭重算 | 每隻怪每一幀都重算一次「頭要看哪裡」,即使最近的陀螺沒換 | 只在「最近的陀螺換了」才重算 | 0.73 ms 未評估 |
| 貼圖預載 要你決定 | 已完整歸屬。進入戰鬥後只會再上傳 9 張怪物貼圖,造成 5 個長幀、合計約 660ms:三張同一幀 264–274ms、Bomb 71ms、木乃伊 72–81ms、狗+盜墓者 119–133ms、Boss+Boss 車 118ms | 載入畫面就先送進去。那 9 張檔案早就下載完了,缺的只是「建 texture 並上傳 GPU」。陀螺已經證明這個時機做得到 —— 怪物只是沒有人在那時去建它們 | 戰鬥中 5 個頓挫消失 代價:載入 +0.7–0.8 秒、GPU 記憶體 +58.7MB(是「提前」不是「新增」) |
| 特效圖集 | 63 張特效圖合計 527MB,最大單張 47MB。只有玩家真的打出那個特效才載 ⇒ 是顆未爆彈 | 可能降尺寸,但判斷不了 —— 缺「每一格在畫面上多大」的資料 | 未知 |
| 詞 | 白話是什麼 |
|---|---|
| WASM | 一種可以在瀏覽器裡跑的「接近機器碼」的格式,W3C 標準、業界通用(Figma、Google Earth 網頁版都用它)。同樣的計算通常比 JavaScript 快 2–5 倍,純數字運算差最多。但要用另一種語言重寫那一段,不是加設定 |
| 每幀 / 幀時間 | 畫面每更新一次叫一「幀」。要跑 60fps,每一幀只能用 16.7 毫秒(這塊板子實測上限是 17.24)。超過就會掉格 |
| p50 / p95 / p99 | 把所有幀從快到慢排好:p50 是「中間那一幀」(一般狀況)、p95 是「最慢的 5% 從哪開始」、p99 是「最慢的 1%」。p50 代表順不順,p95/p99 代表會不會頓 |
| draw call | CPU 對 GPU 說「畫這一批東西」的一次指令。次數越多,CPU 每幀要交代的事越多。批次化就是把很多次併成一次 |
| vsync | 螢幕的更新節拍。一幀如果沒在節拍內畫完,就得等下一拍 ⇒ 畫面掉格 |
| 貼圖上傳 | 把一張圖從記憶體送進 GPU。第一次用到才送,而那一幀就會變得特別長(我們量到 200–490 毫秒) |
| adapter / mapper | 遊戲的「狀態」(誰在哪、血量多少)跟畫面的「物件」是分開的。mapper 負責把狀態翻成畫面要的資料,adapter 負責把它套到實際的 3D 物件上 |
| uniform | 從 CPU 傳給 GPU 著色程式的參數。傳一個數字有很多種方式,方式選錯會變成「每幀傳一整張圖」 |
| instanced batch(批次化) | 把很多長得一樣的東西(例如六台同型陀螺)合成「一次畫」,而不是各畫各的 |
| Worker / 多執行緒 | 瀏覽器裡的第二條執行緒。可以讓渲染跟遊戲邏輯同時跑,但兩邊不共用記憶體,資料要「送過去」 |
| ASTC | 一種 GPU 直接看得懂的壓縮圖片格式。好處是不用先解碼、記憶體也小很多。這塊板子支援,但我們的程式沒用到 |
| 預烘骨骼 | 把所有動作姿勢「離線先算好」存成一張表,跑的時候不算了、直接查表 |
| 相位分桶 | 同一種怪的走路動畫,本來每隻都播在不同的時間點。分桶就是把它們收斂成幾組,同組的姿勢算一次共用 —— 代價是同組的怪會同手同腳 |
放在最後,是因為讀報告的人要的是現在的結論,不是它怎麼變成現在這樣。但改過的地方仍然要留痕跡 —— 不然下一個引用舊版的人不會知道那已經不算數。
| 原本寫的 | 錯在哪 | 現在是 |
|---|---|---|
| 60fps 的預算是 17.24ms | 17.24 其實是 58fps(1000÷58)。沿用了更早報告的數字,沒問它是怎麼來的;而那份的「板子只有 57–59fps」實測也不成立 | 60fps = 16.67ms(板子實測 59.95/60.03 fps),缺口從 6.7–9.1 改為 7.2–9.6 |
| 開頭那張圖標「20.25ms = 現在的一幀」 | 那是量測場面(26 隻釘死、開發版打包),與實戰的 23.9–26.3 是兩種條件,並排會讀成矛盾 | 開頭改成實戰數字;拆解圖移到第二節並標明「看比例不看絕對值」 |
| C 的拆解:走場景樹 30%、排序建清單 10% | 量測的包裝少了一個作用域閘門,把 B 段的骨架運算也算了進來 | 走場景樹每幀只叫 1 次、幾乎不花時間;那 41% 才是第二大的一塊 |
| 「靜態節點旗標值得順手做」 | 那些呼叫全部帶 force=true,three 在 force 為真時不看那個旗標 ⇒ 機制上無效 | 從清單移除。另一個相關旗標實測也省 ~0 |
| 「多執行緒該收掉,因為 GPU 只有一顆」 | 拿 Worker 端的 23.2ms(含它自己的 JS)跟主線的 6.15ms 直接比,兩個數字內容不同 | 三次量測連續否定:Worker 端 gl.flush 一次都沒被叫、所有 GL 只佔 5–7% ⇒ 這條路重開 |
| 「補上缺的 WebP 就能省尖峰」 | 不是 fallback —— PropAdapter 那條路把含副檔名的檔名直接串成路徑,從來沒問過 WebP。而且量錯了時間窗 | 改走正確的解析路徑,價值是載入 −1.47 秒不是戰鬥中的尖峰 |
六項裡有五項是團隊成員主動推翻的,一項是使用者指出來的。