背景是整個網站裡最先被看到、也最不該被在意的東西。它只是裝飾——一片會慢慢轉的星空、一顆掛在右邊的土星。可就是這片裝飾,從一個天真的小玩具,一路長成差點把整個首頁搞到白屏的怪物;前前後後花了我三個階段、二十幾個 commit。

這篇想完整記錄 koimsurai.com 那片星空的三段變形記。因為 大家或多或少聽過,但 是很新的東西、多數人不知道它到底能幹嘛,我會邊講故事邊順手科普。盡量忠實呈現整個過程,包括我判斷錯的地方。

NOTE

三個階段,一句話 Stage 1 一堆各跑各的 three.js 裝飾特效(天真、堆到後來才變重)→ Stage 2 OffscreenCanvas Worker(把星空搬離主執行緒)→ Stage 3 WebGPU/TSL 單 canvas 重寫(GPU 85–90% 直接砍到 25%)。

起點:一個天真的太空背景(Stage 1)#

老實說,我一開始根本沒打算做什麼「效能專案」。這片背景是一點一點堆出來的:先是一顆用 做的黑洞粒子(BlackHole3D),接著加了一層漂浮粒子(SpaceParticles)、一台太空梭、一個「零重力圖書館」、一套星空主題背景,最後才擺上有環有衛星的土星(Saturn3D)。每一個都是「欸這個好像很酷」就順手加上去的。

那時候我對後面會用到的那些效能招式——worker、自適應解析度、分頁可見性控制——幾乎一無所知。而且說實話,最一開始它並不重:東西還少的時候,GPU 使用率沒多少,在我機器上跑得很順,我壓根沒去在意過效能。

問題是慢慢堆、慢慢卡。等到特效愈疊愈多、我也終於認真去量的時候,苗頭才出現。而我人生第一次「認真優化」這片背景,其實是很後來的事,手法也全是治標的 OK 繃:

  • (useInView)把重量級特效包成 LazyComponent,進到可視區才載入;
  • 接 Page Visibility API,分頁切走時完全暫停所有動畫的 requestAnimationFrame 迴圈;
  • 把粒子數量砍一輪、關掉抗鋸齒、開高效能 GPU 模式。

第二階段:把星空搬進 OffscreenCanvas Worker#

真正把我逼到牆角的是一次 Lighthouse。手機分數難看到不行, 高得離譜,拿去 pagespeed.web.dev 上真機重測,桌面數字更誇張:TBT 17,470 毫秒——主執行緒被 three.js 凍結整整 17 秒。

有意思的是 FCP / LCP 都很漂亮(內容其實很快就畫出來了),但畫完之後整頁像被黏住。診斷下來,元兇不是初始化,是那個持續 60fps 的 WebGL 渲染迴圈:在 Lighthouse 的 4× CPU 節流下,每一幀都超過 50ms,17 秒的量測就累積成 17 秒 TBT。結論很硬——要在保留 60fps + 一萬八千顆星的前提下把 TBT 壓到接近零,唯一的路就是把渲染搬出主執行緒,搬進

一把壞掉的尺#

這裡先岔題講一件事,因為它是整段 Stage 2 的主軸:我拿來量的那把尺,本身是壞的。

我跑自動化 Lighthouse 的那個環境是 headless、而且沒有接到 GPU(我自己的機器是有獨顯的,但那個跑分環境沒吃到)。沒有 GPU 的環境跑 WebGL,會退回 用 CPU 硬算,把 three.js 的成本誇大到完全不像真機,而且每跑一次差很多。我親眼看著同一份 code 的 TBT 在 2860 / 2880 / 4320 / 5410ms 之間亂跳。最荒謬的一刻:我把 關掉,TBT 反而更高——這在物理上不可能,等於證明了這整段在那個環境量到的每一個 WebGL 數字都是雜訊。

CAUTION

別用一把不可靠的尺 沒有 GPU 的 headless 環境會用 SwiftShader 軟體渲染 WebGL,比真機慢數十倍,還會把成本灌進 TBT。在那種環境測「首頁 3D」的分數毫無意義——真實數字只能靠有 GPU 的真機、跑真正的 PageSpeed。

也正因為這把壞尺,當我把星空搬進 worker、跑分數字卻幾乎沒動時,我一度推論錯方向,以為瓶頸不是那些星、是土星的 bloom,還差點把整個 worker 改動 revert 掉。

拆:星空進 worker、土星留主執行緒#

真正的解法是把場景一刀切兩半:

  • 星空(約 27,000 個點:兩層星星 + 碎片 + 閃爍星)搬進 。它幾乎沒有 DOM 依賴,很適合搬。
  • 土星留在主執行緒的另一張 canvas。它要載貼圖、要讀捲動位置、要跟游標互動、要跑 bloom 後處理——這些全都進不了 worker。但它是單一物件,成本遠低於兩萬七千顆星。

當時官方有個現成的橋:@react-three/offscreen,一行 <Canvas worker={worker} fallback={<Scene/>}/> 就能把場景丟進 worker,連舊版 Safari 都會自動退回主執行緒跑同一份場景。

worker 我讓它當模組級單例(整個 app 只起一次,不重複下載那 832K 的 chunk);手機那邊更乾脆,整包 WebGL 拿掉、換成純 CSS 的星空,順便把 hero 改成緊湊排版。

真機打臉(好的那種)#

那把壞尺沒法下結論,只能靠真機 PageSpeed。結果是壓倒性的:

17,470 → 30ms
桌面 TBT
51 → 94
桌面 Lighthouse
1,450 → 50ms
手機 TBT
33 → 65
手機 Lighthouse

桌面 TBT 從 17,470ms 掉到 30ms。 星空整個離開主執行緒,主執行緒幾乎不再被卡。那個「以為瓶頸是 bloom」的推論,是那把壞尺騙我的——真機上,那一萬八千顆星才是瓶頸本人。

TIP

附帶撿到的一課:用無痕視窗測 Lighthouse 後來有一次桌面只跑 53 分,Lighthouse 自己警告「Chrome 擴充功能拖累了載入效能」——7.5MB 全是擴充注入的(AdBlock、Grammarly、wakatime…),我網站自己的 code 只佔一丁點。開無痕重測:桌面 96、五項 Core Web Vitals 全綠。你的分數飄忽,有時候不是你的網站。

Stage 2 收在一個漂亮的地方,但我在收尾筆記裡留了一句話,結果它成了預言:那個 @react-three/offscreen 是 RC + 實質停更,哪天升級 three 大版本可能爆;真要根除依賴風險,總有一天得自己用純 three 寫一個 worker(大工)。那個「大工」,兩個月後就來了。

第三階段:WebGPU/TSL 從頭重寫#

先科普一下:WebGPU 到底是什麼#

在往下走之前,先花三十秒講清楚這個主角。前面說過,這片背景是 three.js 畫的,而 three.js 底層踩的是 WebGL——一套 2011 年問世、對應 OpenGL ES 的老 API。它到處都能跑,但架構老、跟 GPU 溝通時 CPU 很囉唆。

就是它的繼任者:把瀏覽器直接接到現代原生圖形 API 上,能更細地控制 GPU、CPU 開銷更低,還解鎖了 ——讓 GPU 不只「畫東西」,還能拿來算大量平行數學。對絕大多數網站來說,這是殺雞用牛刀;我真正好奇的,是它對一片滿螢幕、一直在動的背景到底有沒有用。答案比我想的微妙得多——先爆雷:一開始我想要它的理由,是錯的。

配套的還有 :一種用 JS 寫 shader、再自動編譯到兩種後端的節點式語言,是這次重寫的主要工具。

導火線:一張我沒有的顯示卡#

某天朋友傳來一張截圖:他的機器打開 koimsurai.com,整個首頁炸掉、白屏。錯誤是 THREE.WebGLRenderer: Error creating WebGL context。他的機器是 RTX 5060 + Edge 150——比我的還新,卻掛了。

根因有兩層,而且第二層是我自己的洞:

  1. Chromium 移除了 WebGL 的 SwiftShader 軟體回退(Chrome 130 棄用、137 移除)。以前硬體 context 建立失敗時,瀏覽器會靜默降到 CPU 軟體渲染;現在 getContext() 直接回 null
  2. 我的 app 裡零個 。worker 失敗後會 fallback 到主執行緒 Canvas,主執行緒 WebGL 也死 → React render 直接 throw → 沒人接 → 整個 root 被卸載 → 整頁白掉

換句話說,一片純裝飾的背景,成了能殺死整頁的單點故障。過去它一直被 SwiftShader 這張隱形安全網蓋著,網子一撤,洞就露出來了。

IMPORTANT

先修的不是效能,是「別讓裝飾殺死整頁」 不管接下來要不要換技術,第一件事跟渲染棧無關:補上穩健性。WebGL 偵測 + ErrorBoundary + worker 錯誤通道——沒有任何 GPU 的機器,直接降級成純 DOM 特效,而不是白屏。

tsx
// 背景裝飾永遠不准殺死整個 app。
export default class BackdropErrorBoundary extends Component<Props, State> {
  static getDerivedStateFromError(): State {
    return { failed: true };
  }
  render() {
    return this.state.failed ? this.props.fallback : this.props.children;
  }
}

一台量測台:?debug=perf#

重寫之前,得先有數字,不然「重寫完更好了」這種話沒有意義。我沒有自己手搓,而是接了 2026 的標準做法 stats-gl——它走 GPU timer query,能量到真正的 GPU 時間,WebGL / WebGPU 都支援。

有了尺,診斷結論很清楚:瓶頸是全螢幕的 / 頻寬,不是 、不是粒子數。 整個場景的 draw call 一隻手數得完。真正在燒 GPU 的是——兩張滿螢幕 canvas 各自跑一條 ,而且預設開了 8x。這,就是那 60% GPU 的真身。

MSAA 的生死實驗#

既然 MSAA 是大宗,那砍掉它會怎樣?我把三個檔位實測了一輪(4070 Ti Super、180Hz):

MSAA 幾乎佔了全 GPU 負載的一半。 但這裡有個殘酷的取捨:砍 MSAA 會掉星星。我數了像素,MSAA 4 和 0 都掉了約一半的細星亮核(保留率只剩 46–50%),只有 MSAA 8 全保。原因很微妙:那些小於一個像素的星點,沒有 MSAA 時要嘛蓋到像素中心(整顆亮)、要嘛沒蓋到(整顆消失);MSAA 的多重取樣正好充當了亞像素的覆蓋偵測。

WebGPU 到底值不值得:三輪攻防#

這是整個重寫最有意思、也最容易自我感覺良好的一段。我把它完整記下來,因為我在這裡連錯了好幾輪

第一輪。 我一開始把 WebGPU 當成「2026 唯一的結構級黑科技」,想當然耳它會更快。

第二輪,查證。 一查就洩氣了:WebGPU 主要贏在 CPU 開銷compute 能力。可我的成本結構是「每像素 × 每幀」的 fill-rate——換 API 不會讓我少畫任何一個像素。 省下的 CPU 提交,在只有十個 draw call 的場景下趨近於零;compute 粒子的紅利要到十萬顆級才顯現。結論:WebGPU 對我這個場景的瓶頸,零紅利。 於是我把它冷凍了。

第三輪,打臉自己。 我在論證裡引了一個 PR 當證據,結果被我自己回頭一查——那是 2024 年就已經關閉的 RFC,真正合併進 three 的是另一個編號的 PR,而且早在約 r166 就進去了,不是什麼「新東西」。

WARNING

引 PR 當證據前,先查它的狀態 拿一個已關閉的 RFC 當論據,是實打實的錯。技術判斷裡,一手證據的『狀態』和它的『內容』一樣重要。

第四輪,翻案。 真正讓結論翻盤的,是我換掉了問題本身。我一直在回答的題目是:「同樣的畫面,WebGPU 能不能更便宜?」答案始終是不能。但如果換成:「同樣的 GPU 預算,WebGPU 能不能買到更多畫面?」——這題答案是能,而且明顯:

  • 加星星幾乎免費:bloom 是固定的全螢幕開銷,跟星數無關;
  • WebGPU 的 compute 真正買到的是「讓十萬顆星全部活起來」,這是 WebGL 做不到的品質上限;
  • TSL 的 selective bloom 讓我能收成單一 canvas,一次拔掉雙 canvas 的合成稅;
  • 新的 自帶 WebGL2 fallback,穩健性等於免費補上

重寫:自製 worker entry + Sprite instancing 的血淚#

先升級地基:three 從 r175 升到 r185。然後第一個結構決定就浮現了——@react-three/offscreen 那條路走不通了:它的協定傳不了 WebGPURenderer 需要的 async factory。這正是 Stage 2 預言過的「大工」:我把它整個換掉,自己寫一個極簡的 worker entry,場景與渲染邏輯全部收進一份 lib/starfieldGpu.ts(命令式的純 three/webgpu,無 React、無 R3F)。這份 code 一份四吃:

然後,就撞上了整個重寫裡最坑的一顆地雷。PoC 一跑,GPU 掉到 20%、看起來超省——但星星又細又一直閃,而且換到 WebGPU backend 直接全黑沒星星,WebGL2 fallback 卻好好的。

console 洗版一個錯:

worker 內洗版的 GPUValidationError(節錄)
THREE.AttributeNode: Vertex attribute "uv" not found on geometry. @ spaceGpuWorker-BiAVyl7F.js ...(同一則重複上千次)

查了 r185 的型別文件才搞懂,這三個症狀是同一個根:

CAUTION

THREE.Points 永遠是 1px 這是平台硬限制,size 全部無效。所以:星星細(size 被無視)、亞像素在像素格上跳 = 閃爍、GPU 超低(其實根本沒在畫 quad,只在畫 1px 點)。而 pointUV 節點硬編碼了 GLSL 的 gl_PointCoord → 到 WGSL 直接炸驗證錯誤;退而用 uv() 又因為 points geometry 沒有 uv attribute → 全 0 → 全黑。

正解是 three 官方指定的模式: + ,而不是 THREE.Points。這樣兩個 backend 都走「實例化 billboard quad」,size 生效、uv() 有值——backend 分叉整條砍掉,一份路徑通吃。

WebGPU 上點雲的正解:Points → Sprite instancing+52
// WebGPU 上 THREE.Points 永遠 1px,size 全部無效、也沒有 quad uv
const stars = new THREE.Points(geometry, new THREE.PointsNodeMaterial())
// 官方模式:Sprite + instancing,兩個 backend 都走實例化 quad,單一路徑
const mat = new THREE.PointsNodeMaterial()
mat.positionNode = instancedBufferAttribute(new THREE.InstancedBufferAttribute(positions, 3), 'vec3')
const sprite = new THREE.Sprite(mat)
sprite.count = STAR_COUNT

換成 quad 之後,「亞像素跳格」的閃爍從根上消失,那條「MSAA 0 保留率 ≥ 95%」的驗收也才有可能達成。收斂到過門檻,中間跑了六個迭代:

迭代改動星點保留亮核保留
v0硬方塊(閃爍版)會閃、不柔和
v1柔光 pow3全黑 💀(uv() 在 points 上是 0)
v2改用 point uv + pow2.419.3%22.5%
v3亮度 ×2.6 + bloom 半徑加大22.0%36.2%
v4碟形柔光輪廓31.3%68.3%
v5星數 18k → 26k58.9%97.6% ✓ 過門檻

觀感回不去的那些細節#

星星不閃了,但「跟舊版比,就是差一點味道」。這一段全是感官債:

  • 閃太快。 新管線 26,000 顆星全部在閃,舊棧其實只有 800 顆會閃。26k 全閃會產生一種忙碌的「快閃感」。修法:把閃爍週期拉到 10–25 秒的慢速呼吸,振幅做 per-star 分佈(多數星幾乎不動、少數明顯)。
  • 霧化太重、bloom 不對。 我以為 bloom 參數「照抄舊的」就好,結果 pmndrs 的 BloomEffect 和 three TSL 的 BloomNode 是兩套不同實作,連參數名都不對齊。「照抄參數」不等於「照抄觀感」,只能獨立重新配平。
  • 顏色有差(最陰的一個)。 bloom 是加法疊加,疊上去之後 canvas 的 alpha 被打到 1 → 不透明的黑把頁面本來的深紫底整個蓋掉了。修法是輸出時只疊 rgb、alpha 保留場景原值:
ts
// bloom 疊加若讓 alpha 全變 1 → 不透明黑蓋掉頁面深紫底(「顏色有差」的元兇)
const combined = scenePassColor.add(bloomPass);
// rgb 疊 bloom、alpha 用場景原值 → canvas 維持透明,疊在頁面深紫底上
const pipeline = new THREE.RenderPipeline(renderer, vec4(combined.rgb, scenePassColor.a));

收成單一 canvas#

最後一步是把土星也搬進來,實現當初翻案的核心賣點:一張 canvas。星空和土星現在共用同一條管線,靠 TSL 的 per-material mrtNode 覆寫做 selective bloom——星星的材質把 bloom 通道寫 1、土星維持全域預設的 0,於是同一條管線裡兩種材質吃到不同的 bloom 待遇。

下面這組滑桿,左邊是最早只有裸點雲的 WebGPU PoC(星星稀疏、土星還沒進來),右邊是土星移植回來、柔光配平後的樣子——順帶一提,右邊那張跑的是 WebGL2 fallback 路徑,證明同一份 code 在沒有 WebGPU 的機器上觀感一致:

最終成果#

整段重寫是同一天內跑完的 ,當天就翻成預設、舊棧退役,一共 20 個 commit 全數 push:

85–90 → 25%
GPU(4070 Ti Super @180Hz)
800 → 26,000
會呼吸的星星
2 → 1
全螢幕 canvas 數
114%
亮核保留(對比舊棧)

GPU 從 85–90% 砍到 25%,肉眼比對 99% 相似,而且渲染路徑裡的 React 歸零——場景全是命令式的 three/webgpu。至於朋友那台會炸的機器?現在會優雅降級成純 DOM 特效,零錯誤,不再白屏。

收束:三本帳#

回頭看,這片背景的長征其實是三個不同層級的問題,各自有各自的教訓:

  1. 主執行緒是神聖的。 Stage 2 的一切都繞著「別在主執行緒上跑滿螢幕的渲染迴圈」。砍粒子、暫停動畫都是外圍,把渲染搬進 worker 才是本質。
  2. 在真機、無痕視窗上量。 我被沒接到 GPU 的跑分環境騙了整整一段,還差點 revert 掉正確的解。你手上那把尺可能是壞的——換一把可靠的,比多測十次更重要。
  3. 裝飾不准是單點故障。 一片純背景,絕不該有能力卸載整個 app。ErrorBoundary + 偵測 + 降級路徑,是每一個「炫技」元件都該先付的保費。
  4. 分清「效能優化」和「品質升級」兩本帳。 同一個 WebGPU,記在不同的帳本上,結論完全相反。判斷之前,先確認自己在回答哪一道題。

還有一條沒還完的債:書架那個 3D 的 ZeroGravityLibrary 還掛在舊的 fiber / drei / pmndrs 生態上,得等它也遷移完才能徹底拔掉。長征還沒到終點,但那片星空,總算是我自己一行一行寫出來的了。

參考連結