問題:一個 Hero 要付出 2MB 的 runtime
我網站的 3D 開場最初是一個「點擊啟動」的 Spline 內嵌舞台:訪客會先看到海報和一顆「啟動 3D 場景」按鈕。我否決了它——使用者不能一開始就看到作品,還要等很久的 loading,這不是我要的第一印象。
但直接內嵌即時場景的成本也是實測過的:Spline 的 runtime.js 未壓縮就有 2,043,193 bytes,參考頁面完整載入花了 4,475ms;免費方案還會在右下角加上「Built with Spline」徽章;我們的視口觸發版本在觸控裝置上乾脆停用了場景。三個都不能接受。
最終做法:把場景「拍」下來——擷取一次幀序列,之後只服務純圖片。結果是 48 幀、桌機 WebP 總共 337KB(手機版 141KB),沒有 runtime、沒有徽章,手機和舊版 Safari 都能看。
關鍵洞察:浮水印從未進入 canvas
可以分享的「啊哈」時刻:免費方案的浮水印是一個浮在 canvas 上方的 DOM 元素(一個 <a>),它從未被畫進 WebGL canvas。所以直接讀取 canvas 得到的,本來就是乾淨的畫面。
重要的界線:這是我自己的場景、自己的內容——這個技巧讀取的是我親手做的像素。它不是拿來去除別人浮水印的方法,來源記錄也完整寫在專案的資產出處登記裡。
擷取裝置
- Monkey-patch
requestAnimationFrame:包住每個 callback,讓擷取緊接在引擎渲染後的同一個 task 內執行。同 task 讀取時 WebGL 的 drawing buffer 仍然有效——不需要preserveDrawingBuffer(託管場景也設不了)。 - 頁內縮放:經離屏 2D canvas 從 1680×983 縮裁成 1280×720,
toDataURL("image/webp", 0.75)。 - 取樣:每 2–3 個 rAF tick 取一幀(有效約 20–30fps),共 48 幀。
- 黑幀閘門:編碼後小於 9KB 的幀直接丟棄——純黑 1280×720 WebP 約 3KB,真實星空幀是 12–16KB,這道閘門自動跳過引擎暖機幀。
- 隱藏分頁會凍結 rAF:背景視窗完全不渲染,所以擷取器改成「武裝待發」——分頁變可見後的第一個 rAF tick 自動開錄。
過程中的翻車紀錄
第一次擷取的第 0 幀是全黑(引擎暖機)——黑幀閘門因此誕生。更有趣的是 v1:我們用振幅 0.33 的合成指標事件替相機編舞,結果軌道繞得太遠、繞到場景 3D 文字的背面——中段的幀全是鏡像字。這個 bug 是我自己從截圖裡抓到的。

v2 改用低振幅正面弧線(0.10/0.06)修掉鏡像。而最終上線的 v4 徹底放棄合成輸入:我直接在瀏覽器裡把大字擺到正面、選好構圖,擷取器只是錄下場景自己的相機動畫。這一版的設計課是——最好的運鏡不是合成事件,是設計師的手。

還有一個現實障礙:瀏覽器擴充的資安過濾會擋下工具回應裡的大型 base64 字串,所以幀資料改以單一 Blob 下載離開頁面(約 600KB 的 JSON,逐檔使用者核准),再在本機解碼回 WebP。
資產管線
擷取出的 WebP 經 sharp 進入正式資產:桌機 1280×720(WebP q66+AVIF q52)、手機 720×405(WebP q64+AVIF q50),海報從起始幀輸出 JPG(q80、27.9KB)。實測總量:桌機 WebP 337KB,手機 141KB,AVIF 更小。
前後對比
之前:2,043,193 bytes 的 runtime、4,475ms 的頁面載入、角落的徽章、觸控裝置停用。之後:27.9KB 海報作為 LCP、337KB 延遲載入的圖片、零額外 runtime,所有裝置一致。同一個場景,兩種成本。至於這批幀怎麼變成會播放、會縮小、永不讓人等的 React hero——那是下一篇的主題。