The background is the first thing anyone sees on the whole site, and the thing they should care about least. It's just decoration—a slowly turning starfield, a Saturn hanging off to the right. And yet this bit of decoration grew from a naive little toy into a monster that nearly white-screened the entire homepage; all told it cost me three stages and twenty-odd commits.

This post is my attempt to fully document the three-part metamorphosis of that starfield on koimsurai.com. Most people have heard of in one form or another, but is a very new thing and most people have no idea what it's actually for, so I'll do a bit of explaining as I tell the story. I'll try to present the whole process faithfully, including the places where I judged wrong.

NOTE

Three stages, in one sentence Stage 1, a pile of independently-running three.js decorative effects (naive, only heavy once they piled up) → Stage 2, an OffscreenCanvas Worker (moving the starfield off the main thread) → Stage 3, a WebGPU/TSL single-canvas rewrite (GPU cut from 85–90% straight down to 25%).

The starting point: a naive space background (Stage 1)#

Honestly, I never set out to do any kind of "performance project" at the start. This background was piled up bit by bit: first a black-hole particle effect built with (BlackHole3D), then a layer of floating particles (SpaceParticles), a space shuttle, a "zero-gravity library," a starfield-themed background, and only at the very end the ringed, moon-bearing Saturn (Saturn3D). Each one got tacked on with an "oh hey, this looks kind of cool."

Back then I knew almost nothing about the performance tricks I'd later reach for—workers, adaptive resolution, tab-visibility control. And to be honest, at the very beginning it wasn't heavy: while there was still little going on, GPU usage was low, it ran smoothly on my machine, and I never gave performance a second thought.

The problem was that it piled up slowly and bogged down slowly. The warning signs only appeared once the effects had stacked ever higher and I finally sat down to measure. And the first time in my life I "seriously optimized" this background was actually much later, with methods that were all symptomatic band-aids:

  • Using (useInView) to wrap the heavyweight effects into a LazyComponent that only loads once it scrolls into view;
  • Wiring in the Page Visibility API to fully pause every animation's requestAnimationFrame loop when the tab is switched away;
  • Cutting particle counts down a round, turning off anti-aliasing, and enabling high-performance GPU mode.

Stage two: moving the starfield into an OffscreenCanvas Worker#

What really backed me into a corner was a Lighthouse run. The mobile score was unbearably ugly, was absurdly high, and when I re-ran it on real hardware over at pagespeed.web.dev, the desktop numbers were even more outrageous: TBT of 17,470 milliseconds—the main thread frozen solid by three.js for a full 17 seconds.

Interestingly, FCP / LCP were both beautiful (the content actually painted very fast), but once painted the whole page felt glued in place. The diagnosis: the culprit wasn't initialization, it was that continuous 60fps WebGL render loop. Under Lighthouse's 4× CPU throttling every single frame took more than 50ms, so a 17-second measurement window accumulated into 17 seconds of TBT. The conclusion was hard-edged—to keep 60fps plus eighteen thousand stars while pushing TBT down to near-zero, the only path was to move rendering off the main thread, into an .

A broken ruler#

Let me digress here for a moment, because it's the through-line of the entire Stage 2: the ruler I was measuring with was itself broken.

The environment where I ran automated Lighthouse was headless and had no GPU attached (my own machine has a discrete card, but that scoring environment never got hold of it). Running WebGL in a GPU-less environment falls back to , grinding it out on the CPU, which inflates three.js's cost into something nothing like real hardware—and varies wildly from run to run. I watched with my own eyes as the TBT for the exact same code bounced around between 2860 / 2880 / 4320 / 5410ms. The most absurd moment: I turned off and TBT went up—physically impossible, which amounts to proof that every WebGL number measured in that environment across this whole stretch was noise.

CAUTION

Don't measure with an unreliable ruler A GPU-less headless environment renders WebGL in software via SwiftShader, tens of times slower than real hardware, and pumps that cost straight into TBT. Scoring "homepage 3D" in an environment like that is meaningless—real numbers only come from real hardware with a GPU, running an actual PageSpeed test.

And it was precisely because of this broken ruler that, when I moved the starfield into the worker and the scores barely budged, I momentarily reasoned in the wrong direction, thinking the bottleneck wasn't the stars but Saturn's bloom—and I nearly reverted the entire worker change.

Splitting it up: starfield into the worker, Saturn stays on the main thread#

The real fix was to cleave the scene in two:

  • The starfield (roughly 27,000 points: two layers of stars + debris + twinkling stars) moves into a . It has almost no DOM dependencies, which makes it a great candidate to move.
  • Saturn stays on a separate canvas on the main thread. It needs to load textures, read scroll position, interact with the cursor, and run bloom post-processing—none of which can go into a worker. But it's a single object, and its cost is far below twenty-seven thousand stars.

At the time there was a ready-made bridge from the official side: @react-three/offscreen, where a single line of <Canvas worker={worker} fallback={<Scene/>}/> could toss the scene into a worker, and even older Safari would automatically fall back to running the same scene on the main thread.

I made the worker a module-level singleton (spun up only once for the whole app, so that 832K chunk isn't downloaded repeatedly); on mobile I went even more decisive, stripping out the whole WebGL bundle and swapping in a pure-CSS starfield, and while I was at it reworking the hero into a compact layout.

The real-hardware reality check (the good kind)#

The broken ruler couldn't settle anything; only real-hardware PageSpeed could. The result was overwhelming:

17,470 → 30ms
Desktop TBT
51 → 94
Desktop Lighthouse
1,450 → 50ms
Mobile TBT
33 → 65
Mobile Lighthouse

Desktop TBT dropped from 17,470ms to 30ms. The starfield left the main thread entirely, and the main thread was almost never jammed again. That "the bottleneck must be bloom" reasoning was the broken ruler lying to me—on real hardware, those eighteen thousand stars were the bottleneck itself.

TIP

A lesson picked up along the way: test Lighthouse in an incognito window Later there was a run where desktop only scored 53, and Lighthouse itself warned that "Chrome extensions dragged down loading performance"—7.5MB of it was all extension-injected (AdBlock, Grammarly, wakatime…), while my own site's code took up only a sliver. Re-tested in incognito: desktop 96, all five Core Web Vitals green. When your score is flaky, sometimes it isn't your website.

Stage 2 closed in a nice place, but in my wrap-up notes I left one line that turned out to be a prophecy: that @react-three/offscreen was an RC plus effectively unmaintained, and the day I bumped three to a major version it might blow up; to truly root out the dependency risk, someday I'd have to write a worker myself in pure three (a big job). That "big job" arrived two months later.

Stage three: a WebGPU/TSL rewrite from scratch#

A quick primer: what WebGPU actually is#

Before going further, let me spend thirty seconds making this protagonist clear. As I said earlier, this background is drawn by three.js, and three.js stands on WebGL underneath—an old API introduced in 2011 that maps to OpenGL ES. It runs everywhere, but its architecture is old, and the CPU is very chatty when talking to the GPU.

is its successor: it wires the browser straight onto modern native graphics APIs, giving finer control over the GPU with lower CPU overhead, and it unlocks s—letting the GPU do more than just "draw things" and instead crunch large amounts of parallel math. For the vast majority of websites this is using a sledgehammer to crack a nut; what I was genuinely curious about was whether it's actually useful for a full-screen background that's always in motion. The answer turned out far more subtle than I expected—spoiler first: the reason I wanted it at the start was wrong.

Coming with it is : a node-based language for writing shaders in JS that auto-compiles to both backends, and the main tool for this rewrite.

The trigger: a graphics card I don't own#

One day a friend sent me a screenshot: he opened koimsurai.com on his machine and the entire homepage blew up into a white screen. The error was THREE.WebGLRenderer: Error creating WebGL context. His machine was an RTX 5060 + Edge 150—newer than mine, and yet it died.

The root cause had two layers, and the second layer was a hole of my own making:

  1. Chromium removed WebGL's SwiftShader software fallback (deprecated in Chrome 130, removed in 137). Previously, when hardware context creation failed, the browser would silently drop down to CPU software rendering; now getContext() simply returns null.
  2. My app had zero s. After the worker failed it would fall back to the main-thread Canvas, the main-thread WebGL died too → React render threw straight up → nobody caught it → the entire root was unmounted → the whole page went white.

In other words, a purely decorative background became a single point of failure capable of killing the entire page. It had long been covered by SwiftShader's invisible safety net, and the moment that net was pulled away, the hole was exposed.

IMPORTANT

The first thing to fix wasn't performance, it was "don't let decoration kill the whole page" Regardless of whether I'd swap technologies next, the first thing had nothing to do with the render stack: add robustness. WebGL detection + ErrorBoundary + a worker error channel—a machine with no GPU at all degrades straight to pure-DOM effects instead of a white screen.

tsx
// A background decoration must never be allowed to kill the entire 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;
  }
}

A measurement bench: ?debug=perf#

Before rewriting, I needed numbers first, otherwise a claim like "it's better after the rewrite" means nothing. Rather than hand-rolling my own, I wired in the 2026 standard approach, stats-gl—it uses a GPU timer query to measure the real GPU time, and supports both WebGL and WebGPU.

With a ruler in hand, the diagnosis was clear: the bottleneck was full-screen / bandwidth, not s, not particle count. The whole scene's draw calls could be counted on one hand. What was actually burning the GPU was—two full-screen canvases each running a , with 8x on by default. This, right here, was the true face of that 60% GPU.

MSAA's life-or-death experiment#

Since MSAA was the big-ticket item, what would happen if I cut it? I ran a round of measurements across three settings (4070 Ti Super, 180Hz):

MSAA accounted for nearly half the entire GPU load. But here's the cruel trade-off: cutting MSAA loses stars. I counted pixels—MSAA 4 and 0 both lost about half the fine stars' bright cores (retention down to only 46–50%), while only MSAA 8 kept them all. The reason is subtle: those star points smaller than a pixel, without MSAA, either cover the pixel center (the whole star lights up) or miss it (the whole star vanishes); MSAA's multi-sampling happened to serve as sub-pixel coverage detection.

Is WebGPU actually worth it: three rounds of back-and-forth#

This is the most interesting stretch of the whole rewrite, and also the easiest one to feel smug about. I'm recording it in full, because here I got it wrong several rounds in a row.

Round one. At the start I treated WebGPU as "the only structural piece of magic in 2026," taking for granted that it would be faster.

Round two, verification. One look and I deflated: WebGPU mainly wins on CPU overhead and compute capability. But my cost structure was "per-pixel × per-frame" fill-rate—swapping the API wouldn't make me draw a single pixel less. The CPU submissions saved by approach zero in a scene with only ten draw calls; the compute-particle dividend doesn't show up until you're at the hundred-thousand level. Conclusion: for my scene's bottleneck, WebGPU offered zero dividend. So I put it on ice.

Round three, slapping my own face. In my argument I'd cited a PR as evidence, and then when I went back and checked it myself—it was an RFC that had already been closed back in 2024; the one actually merged into three was a differently-numbered PR, and it had landed around r166, not any "new thing."

WARNING

Before citing a PR as evidence, check its status Using a closed RFC as an argument is a genuine, honest-to-goodness mistake. In technical judgment, a piece of firsthand evidence's 'status' matters just as much as its 'content.'

Round four, the overturning. What actually flipped the conclusion was that I swapped out the question itself. The question I'd been answering was: "For the same image, can WebGPU be cheaper?" The answer was always no. But if you change it to: "For the same GPU budget, can WebGPU buy more image?"—the answer to that one is yes, and clearly so:

  • Adding stars is nearly free: bloom is a fixed full-screen cost, independent of star count;
  • What WebGPU's compute actually buys is "bringing a hundred thousand stars fully to life," a quality ceiling WebGL can't reach;
  • TSL's selective bloom lets me collapse down to a single canvas, pulling out the dual-canvas compositing tax in one move;
  • The new comes with a WebGL2 fallback built in, so robustness gets patched in for free.

The rewrite: a hand-rolled worker entry + the blood and tears of Sprite instancing#

First, upgrade the foundation: three went from r175 to r185. Then the first structural decision surfaced—the @react-three/offscreen path no longer worked: its protocol couldn't pass the async factory that WebGPURenderer needs. This was exactly the "big job" Stage 2 had prophesied: I swapped it out wholesale, writing a minimal worker entry myself, with all the scene and render logic gathered into a single lib/starfieldGpu.ts (imperative pure three/webgpu, no React, no R3F). This one piece of code serves four uses at once:

And then I hit the nastiest landmine of the entire rewrite. The moment the PoC ran, the GPU dropped to 20% and it looked incredibly cheap—but the stars were thin and constantly flickering, and switching to the WebGPU backend gave me a totally black sky with no stars, while the WebGL2 fallback was perfectly fine.

One error spammed the console:

The GPUValidationError spamming inside the worker (excerpt)
THREE.AttributeNode: Vertex attribute "uv" not found on geometry. @ spaceGpuWorker-BiAVyl7F.js ...(the same message repeated thousands of times)

It took reading r185's type definitions to figure out that these three symptoms shared the same root:

CAUTION

THREE.Points is always 1px This is a hard platform limitation; size is entirely ignored. So: thin stars (size ignored), sub-pixels jumping around the pixel grid = flicker, and ultra-low GPU (it isn't actually drawing quads at all, just 1px points). And the pointUV node hard-codes GLSL's gl_PointCoord → which detonates into a validation error over in WGSL; falling back to uv() instead fails because points geometry has no uv attribute → all 0 → all black.

The correct answer is the pattern three officially prescribes: use + , not THREE.Points. That way both backends go through an "instanced billboard quad," size takes effect, and uv() has values—the entire backend fork gets cut away, one path serves all.

The right answer for point clouds on 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

Once I switched to quads, the "sub-pixel grid-jumping" flicker vanished at the root, and only then did the "MSAA 0 retention ≥ 95%" acceptance criterion become achievable at all. Converging to a passing bar took six iterations along the way:

IterationChangeStar-point retentionBright-core retention
v0hard squares (flickering version)flickers, not soft
v1soft glow pow3all black 💀 (uv() is 0 on points)
v2switched to point uv + pow2.419.3%22.5%
v3brightness ×2.6 + larger bloom radius22.0%36.2%
v4disc-shaped soft-glow profile31.3%68.3%
v5star count 18k → 26k58.9%97.6% ✓ passes the bar

The look-and-feel details there was no un-seeing#

The stars stopped flickering, but "compared to the old version, something about the flavor was just a little off." This whole stretch is sensory debt:

  • Flickering too fast. In the new pipeline all 26,000 stars are flickering, whereas the old stack actually only had 800 that flickered. Having all 26k flicker produces a busy, "strobing" feeling. The fix: stretch the flicker period out to a slow 10–25-second breathing, with the amplitude distributed per-star (most stars barely move, a few noticeably so).
  • Too much haze, bloom is wrong. I assumed I could just "copy the old bloom parameters over" and be done, but pmndrs's BloomEffect and three TSL's BloomNode are two different implementations—even the parameter names don't line up. "Copy the parameters" does not equal "copy the look," so it had to be re-balanced independently.
  • The colors were off (the sneakiest one). Bloom is additive, and after compositing the canvas's alpha got pushed to 1 → an opaque black covered up the page's original deep-purple backdrop entirely. The fix is to composite only rgb on output and preserve the scene's original alpha:
ts
// If additive bloom pushes alpha all the way to 1 → opaque black covers the page's deep-purple backdrop (the culprit behind "the colors were off")
const combined = scenePassColor.add(bloomPass);
// Composite bloom on rgb, take alpha from the scene's original → the canvas stays transparent, layered over the page's deep-purple backdrop
const pipeline = new THREE.RenderPipeline(renderer, vec4(combined.rgb, scenePassColor.a));

Collapsing down to a single canvas#

The last step was to bring Saturn in too, realizing the core selling point of that earlier overturning: a single canvas. The starfield and Saturn now share one pipeline, doing selective bloom via TSL's per-material mrtNode override—the stars' material writes 1 to the bloom channel while Saturn keeps the global default of 0, so within the same pipeline two materials get different bloom treatment.

The slider below has, on the left, the earliest WebGPU PoC with just a bare point cloud (sparse stars, Saturn not yet in), and on the right, the look after Saturn was ported back in and the soft glow was balanced—by the way, the right image is running the WebGL2 fallback path, proving that the same code looks consistent on a machine without WebGPU:

The final result#

The whole rewrite was a completed within a single day—that same day it flipped to default, the old stack was retired, and all 20 commits were pushed:

85–90 → 25%
GPU (4070 Ti Super @180Hz)
800 → 26,000stars
Breathing stars
2 → 1
Full-screen canvases
114%
Bright-core retention (vs. old stack)

GPU went from 85–90% down to 25%, 99% similar to the naked eye, and React in the render path is zero—the scene is all imperative three/webgpu. As for my friend's machine that used to blow up? It now degrades gracefully to pure-DOM effects, zero errors, no more white screen.

In closing: three ledgers#

Looking back, this background's long march was really three different levels of problem, each with its own lesson:

  1. The main thread is sacred. Everything in Stage 2 revolved around "don't run a full-screen render loop on the main thread." Cutting particles and pausing animations are peripheral; moving rendering into a worker is the essence.
  2. Measure on real hardware, in an incognito window. I got fooled by a GPU-less scoring environment for an entire stretch and nearly reverted the correct fix. The ruler in your hand might be broken—swapping in a reliable one matters more than measuring ten more times.
  3. Decoration is not allowed to be a single point of failure. A purely background piece should never have the power to unmount the entire app. ErrorBoundary + detection + a degradation path is the insurance premium every "show-off" component should pay up front.
  4. Distinguish the "performance optimization" ledger from the "quality upgrade" ledger. The same WebGPU, booked on different ledgers, yields completely opposite conclusions. Before you judge, first make sure which question you're answering.

There's still one debt not fully repaid: the 3D ZeroGravityLibrary bookshelf is still hanging on the old fiber / drei / pmndrs ecosystem, and only once it too has migrated can it be pulled out completely. The long march hasn't reached its end, but that starfield, at last, is something I wrote myself, one line at a time.

參考連結