Skip to content

Large performence improvements to Plasma background - #1007

Merged
DavidHDev merged 5 commits into
DavidHDev:mainfrom
elias4044:fix/plasma-performance
Jul 23, 2026
Merged

Large performence improvements to Plasma background#1007
DavidHDev merged 5 commits into
DavidHDev:mainfrom
elias4044:fix/plasma-performance

Conversation

@elias4044

Copy link
Copy Markdown
Contributor

Summary

Plasma runs a per-pixel raymarching shader with a 60-iteration loop, at full resolution and full device pixel ratio, every frame. That's expensive, on a 1440p+ display at DPR 2 it's shading roughly 15 million pixels per frame, most of which is invisible given how soft the final output is. Along side this I noticed that this background just doesn't run well on lower-end machines in general. This PR adds four opt-in props that let devs trade a bit of that invisible fidelity for a real drop in GPU cost, without changing much for people already using the component.

Changes

Added to Plasma across all four variants (JS/TS × CSS/Tailwind):

  • renderScale (default 0.55) — renders the WebGL buffer at a fraction of the container size and lets CSS scale it back up. This is where most of the win comes from, since cost tracks pixel count almost linearly.
  • maxDpr (default 1.5) — caps the devicePixelRatio used for rendering. A blurred background rarely needs full DPR on high-density screens.
  • targetFps (default 60) — throttles the render loop to a target rate instead of letting requestAnimationFrame run flat out.
  • quality (default 60) — controls the raymarch iteration count. Unlike the three props above, this one changes the actual pattern rather than just how much of it gets rendered or how often — see note below.
  • Tab visibility handling — pauses the loop on document.visibilitychange, on top of the existing IntersectionObserver pause for off-screen instances. Right now the component keeps rendering in a backgrounded tab.

A few smaller things I found along the way:

  • Mouse position now gets read into the GL uniform once per rendered frame instead of on every mousemove event.
  • Resize events are batched through requestAnimationFrame rather than calling setSize synchronously on every ResizeObserver firing.
  • The component now respects prefers-reduced-motion — it paints one static frame and stops instead of starting the loop.

A note on quality

renderScale, maxDpr, and targetFps are all "free" in the sense that they only change how much gets rendered or how often, the underlying pattern at any given moment is identical either way. quality isn't free in that sense.

The loop accumulates a running distance (z) that feeds into the next iteration's position, which is standard raymarching — each step is figuring out how far a ray has traveled based on every step before it. Cut the iteration count from 60 to 45 and the ray simply hasn't traveled as far, so it's landing on a different point of the pattern, not a lower-detail version of the same point. In testing, lower values noticeably changed how much of the bright/white area shows up in the render — not just softness or sharpness, but the actual balance of the pattern.

Because of that, I'd treat quality as a distinct trade-off from the other three rather than something to casually lower alongside them.

Testing

Tested with renderScale={0.55}, maxDpr={1.5}, targetFps={60}.

Before

Before

After

After

Visually near-identical at normal viewing distance, roughly 2x improvement. My GPU usage went from 95% down to 45%. My laptop, which was visibly lagging and very noticeably slow on the older version, handles the new version with no problem and there is zero noticeable lag. Try it out yourself!

❤️ from Elias.

@saadpocalypse

Copy link
Copy Markdown

Just a few notes.

Would it help to transfer the control of the <canvas> to a Web Worker through canvas.transferControlToOffscreen()? It'll move all of the render loop, frame throttling and uniform updates off of the main thread.

Also, you wrote how cutting the loop iterations from 60 to 45 alters the ray distance (z) and changes the visual output. I belive this can be fixed without changing the visuals too much.

Instead of fixed steps, you can scale the step size inversely with iteration count.

$$\text{stepSize}_{\text{new}} = \text{stepSize}_{\text{orig}} \times \left(\frac{\text{steps}_{\text{orig}}}{\text{steps}_{\text{new}}}\right)$$

Fragment precision can be set to precision mediump floatl instead of highp where high trignometric accuracy isn't important. You can also break out of the raymarch loop early if the ray hits an opaque threshold or exits the bounding volume.

@elias4044

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback and tips!

OffscreenCanvas: Agreed this is the right direction, but it's quite a big
architectural change; once the canvas is transferred, its
.style is no longer reachable from the worker, so the CSS-stretch sizing
has to stay on the main thread while only pixel dimensions get posted to
the worker. That means splitting ResizeObserver/mouse/visibility handling
from the render loop itself, plus a fallback path for browsers without
OffscreenCanvas support. I'd rather do that as a focused follow-up PR than
folding into this one.

Step scaling: I made an attempt at fixing this but I was unable to resolve a fix, mostly due to the fact that I am not very extremely familiar with WebGL. Instead, I changed the "Quality" prop to "Iterations", as this is a much more accurate description of it and "Quality" could very easily confuse anyone. I'd be happy to see someone else implementing this if felt like necessary - but for the moment I'm keeping it as "Iterations", allowing for more customization instead.

Precision : I tried mediump, but T (derived from iTime) propagates
into nearly every trig call in the shader, and since this runs
continuously for as long as the tab's open, unbounded iTime in
mediump risks visible jitter after long sessions. Went with mixed
precision instead - mediump by default, T and its uniform kept
highp - should still save something on the rest of the math without
that long-session risk.

Early exit: I held off on this one. This shader accumulates color on
every iteration rather than stopping at a hit, so a d-based break would
cut the same terms from the sum that reducing iterations does - same
distortion we're trying to avoid. A distance-based bailout (z >
some bound) would be safe, but given the fixed camera and compact scene
I suspect it rarely fires for on-screen pixels. Didn't want to add
complexity without measuring an actual win first — open to revisiting if
profiling shows otherwise.

@saadpocalypse

Copy link
Copy Markdown

Got it. I love how this component looks but I avoid even clicking on it because it freezes my system as well, so thank you so much for this. Once your PR gets approved, I'll try and build upon it to see if I can find ways to make it more efficient.

@DavidHDev

Copy link
Copy Markdown
Owner

Thanks for the hard work! Merging.

@DavidHDev
DavidHDev merged commit 4ebca5b into DavidHDev:main Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants