背景是整个网站里最先被看到、也最不该被在意的东西。它只是装饰——一片会慢慢转的星空、一颗挂在右边的土星。可就是这片装饰,从一个天真的小玩具,一路长成差点把整个首页搞到白屏的怪物;前前后后花了我三个阶段、二十几个 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 生态上,得等它也迁移完才能彻底拔掉。长征还没到终点,但那片星空,总算是我自己一行一行写出来的了。

參考連結