Three.jsWeb 3DPerformanceGLBBlender3D Pipeline

我的 3D 網頁傳了 1.9MB,其中 90% 從來沒有被壓縮過

··閱讀約 6 分鐘

我一直知道 3D 網頁很重,但沒有真的去量過。

直到有一天我打開 DevTools 的網路面板,看 Midnight Run 到底傳了什麼。

資源傳輸量磁碟大小
xinyi-night-loop-city.glb1,329 KB1,329 KB
midnight-assets.glb385 KB385 KB
three.module.js142 KB564 KB
MidnightRun.js20 KB64 KB
GLTFLoader.js14 KB44 KB
合計1,894 KB
Midnight Run 首次載入的實際傳輸量(瀏覽器實測)

兩個 GLB 加起來 1,714KB,佔整頁的 90%。

但真正讓我停下來的不是那個 90%,是右邊那一欄。

JS 被壓了四倍,GLB 一個位元組都沒少

three.js 在磁碟上是 564KB,實際只傳了 142KB——伺服器把它壓掉了四分之三。GLTFLoader 也一樣,44KB 傳了 14KB。

但那兩個 GLB,磁碟多大就傳多大。

我去看了線上的 response header,答案很直接:

資源content-typecontent-encoding
index.jsapplication/javascriptbr
kof-3d-studio.glbmodel/gltf-binary(無)
同一個站上,兩種資源的壓縮待遇

Cloudflare 是依 content-type 的白名單決定要不要壓縮的。application/javascript 在名單裡,model/gltf-binary 不在。

所以整個網站最大的那個檔案,是唯一一個完全沒有享受到壓縮的檔案。而且這件事不會有任何警告——它安靜地存在了好幾個月。

先問最便宜的問題:如果壓了會怎樣?

在研究 Draco 那些東西之前,我想先知道最單純的壓縮能做到什麼程度。所以我直接對兩個 GLB 跑 gzip 和 brotli:

檔案原始gzipbrotli
kof-3d-studio.glb2,926 KB407 KB293 KB(−90%)
xinyi-night-loop-city.glb1,329 KB613 KB567 KB(−57%)
純粹的通用壓縮,不改檔案內容

3D Studio 那個場景,光是 brotli 就砍掉九成。

這個數字大得有點荒謬,但它有道理:未壓縮的 GLB 裡放的是一大片 32 位元浮點數,相鄰的頂點座標高度相似,通用壓縮器最擅長吃這種東西。

問題是,在 Cloudflare Pages 上我沒辦法叫它壓這個 content-type。決定權不在我手上。

所以只剩一條路:把壓縮烤進檔案本身。

三種把壓縮烤進 GLB 的方式

glTF 生態系有三個常見選項,我用 gltf-transform 各跑一次:

方案3D StudioMidnight Run 城市
原始2,926 KB1,329 KB
quantize(降精度)993 KB(−66%)1,102 KB(−17%)
meshopt675 KB(−77%)658 KB(−50%)
Draco489 KB(−83%)437 KB(−67%)
同樣的模型,三種壓縮方式

Draco 在兩邊都贏。如果只看這張表,結論很明顯:用 Draco。

但這張表少算了一件事。

解碼器不是免費的

壓過的檔案瀏覽器看不懂,要先載入解碼器。而這兩個方案的解碼器差很多:

解碼器大小
Draco(wasm + wrapper)245 KB
meshopt28 KB
three.js 內附的解碼器大小

Draco 的解碼器比 meshopt 大了將近九倍。把它加回去之後,答案就翻轉了:

頁面方案模型解碼器總計
3D Studio原始2,927 KB2,927 KB
Draco489 KB245 KB734 KB
meshopt675 KB28 KB704 KB
Midnight Run原始1,714 KB1,714 KB
Draco505 KB245 KB750 KB
meshopt771 KB28 KB799 KB
首次載入的真實總量(模型 + 解碼器)

3D Studio 那一頁,meshopt 反而比 Draco 少 30KB——雖然它的壓縮率明顯比較差。

Midnight Run 則相反,Draco 贏 49KB。差別在於這一頁有兩個模型、幾何量比較大,足以把那 245KB 的解碼器攤平。

換算成一句可以帶走的規則:Draco 要贏,它省下的必須超過 meshopt 大約 216KB。低於這個數,那顆比較大的解碼器就把優勢吃光了。

而且解碼器會被瀏覽器快取,所以「使用者會看幾頁 3D」也是變數。單一頁面的一次性造訪,和一個有五個 3D 場景的網站,最佳解不會一樣。

貼圖是另一半的故事

Draco 和 meshopt 都只壓幾何,不碰貼圖。信義區那個城市檔案裡,貼圖佔了 25%:

項目JPEGWebP
內嵌貼圖合計334 KB125 KB(−63%)
佔整個 GLB 比例25%11%
GLB 內嵌貼圖轉成 WebP

幾何和貼圖各壓各的,兩個一起做才是完整答案:

處理大小
原始1,329 KB
Draco437 KB
Draco + WebP228 KB(−83%)
信義區城市:疊加所有手段

一個我沒預期的發現:那 2.9MB 裡沒有一張貼圖

量到一半我才發現,3D Studio 的 2,926KB 裡,貼圖數量是零。

整個檔案是純粹的幾何——85,976 個頂點,位置、法線、UV 全部用 32 位元浮點數存。

這解釋了為什麼 brotli 對它特別有效,也解釋了為什麼 quantize 就能砍掉三分之二:那些浮點數根本不需要那麼高的精度。一個 11 公尺寬的場景,位置精度到小數點後七位是沒有意義的。

如果我當初有量過,這個場景從一開始就不會是 2.9MB。

所以我把它做完了

量完之後我選了 meshopt,然後把三個 GLB 全部重新壓過。

選 meshopt 不是因為它壓得比較小——Draco 在 Midnight Run 那頁還是贏 49KB。是因為它的解碼器是一個可以直接打包進 bundle 的 JS 模組,不像 Draco 需要另外部署 WASM 檔案並設定 decoder path。少一個部署環節、少一個可能失敗的網路請求,而且解碼快得多——對一個遊戲頁面來說,這比那 49KB 值錢。

壓縮做成一個可以重跑的腳本,貼圖轉 WebP、幾何用 meshopt,並且會偵測已經壓過的檔案直接跳過,所以重跑不會把編碼疊起來。

bash
npm run optimize:3d

OPTIMIZED public/lab/3d-studio/models/kof-3d-studio.glb 2996752 -> 691620 bytes (-77%)
OPTIMIZED public/games/midnight-run/models/xinyi-night-loop-city.glb 1360852 -> 460520 bytes (-67%)
OPTIMIZED public/games/midnight-run/models/midnight-assets.glb 393920 -> 114764 bytes (-71%)

載入端只改了一行:三個 new GLTFLoader() 換成一個共用的 createGltfLoader(),裡面掛上 MeshoptDecoder。壓過的檔案沒有解碼器會直接拒絕載入,所以把這個配對放在同一個地方,之後不會有人漏掉。

實際結果:

頁面壓縮前壓縮後減少
3D Studio2,926 KB701 KB−76%
Midnight Run1,714 KB588 KB−66%
兩個 3D 頁面壓縮前後的實際傳輸量

壓縮後的數字包含了 26KB 的解碼器。兩個場景我都重新跑過一次確認畫面正常——285 個 mesh 的工作室場景、還有信義區的城市與車輛,全部照舊。

整個實作花的時間比量測還少。難的從來不是壓縮,是先去看那兩欄數字。

如果你也要量自己的 3D 網頁

照這個順序,每一步都比前一步貴,所以做到夠用就停:

一、先看 DevTools 的網路面板,比對「傳輸量」和「大小」兩欄。如果兩欄一樣,代表那個檔案沒有被壓縮,先去查它的 content-type。

二、確認你的 CDN 有沒有壓縮那個 content-type。這是唯一一個零客戶端成本的優化,能做就一定要做。

三、量一次 quantize。它不需要任何解碼器,對浮點數過剩的場景效果驚人。

四、再考慮 meshopt 或 Draco,而且一定要把解碼器算進總量。壓縮率最高的方案不一定是傳輸量最小的方案。

五、貼圖分開處理。WebP 幾乎是免費的六成。

整個過程我用的是 gltf-transform,一個指令跑一種方案,比想像中快得多。真正花時間的不是壓縮,是願意先去看一眼那兩欄數字。

文中量測的場景:Midnight Run

參考資料