Skip to content

perf(Grainient): split useEffect, pause RAF when offscreen/tab hidden#908

Open
mohamed-younes16 wants to merge 1 commit intoDavidHDev:mainfrom
mohamed-younes16:feat/grainient-performance
Open

perf(Grainient): split useEffect, pause RAF when offscreen/tab hidden#908
mohamed-younes16 wants to merge 1 commit intoDavidHDev:mainfrom
mohamed-younes16:feat/grainient-performance

Conversation

@mohamed-younes16
Copy link
Contributor

perf(Grainient): eliminate WebGL teardown on prop changes + pause RAF when offscreen

Problem

Two performance issues discovered during real-world usage:

1. Full WebGL context teardown on every prop change

The entire component lived inside a single useEffect with all 22 props in the
dependency array. Changing any prop triggered:

  • cancelAnimationFrame → RAF stopped
  • container.removeChild(canvas) → canvas torn out of the DOM
  • new Renderer(...) → fresh WebGL context allocated on the GPU
  • new Program(...) → shader recompiled and re-uploaded

A full GPU pipeline rebuild for something as minor as tweaking a color value.
This is most noticeable in the interactive demo — every slider drag caused a
visible flicker and a full context reset.

2. RAF loop ran unconditionally — even when completely offscreen

requestAnimationFrame fired 60 times/sec regardless of visibility. The
fragment shader (running noise(), sin(), mix(), pow() on every pixel,
every frame) kept burning GPU cycles even when scrolled off screen or in a
background tab. In practice: audible fan spin-up when Grainient is used as a
section background on a scrollable page.

Fix

Split into two useEffects:

  • Effect 1 ([] deps) — creates renderer, canvas, program, mesh exactly once for the component lifetime
  • Effect 2 (prop deps) — only writes to uniform .value properties. Zero GPU cost, no teardown, no flicker

A WeakMap<HTMLDivElement, GrainientCtx> bridges the two effects without strong references that would leak on unmount.

Pause RAF when not needed:

  • IntersectionObserver (threshold: 0) — stops the loop the moment the canvas leaves the viewport, resumes when it re-enters
  • visibilitychange — stops the loop when the tab is hidden, resumes when the user returns

Result

The interactive demo now updates every parameter (colors, warp, grain, etc.) instantly and completely flicker-free — no context reset, no canvas remount, just a uniform value write. GPU usage drops to zero when the component is offscreen.

  • All 4 variants updated (JS-CSS, JS-TW, TS-CSS, TS-TW). Build passes ✓
  • Not A breaking change.

Problems in the original:
- Single useEffect with all 22 props as dependencies caused a full WebGL
  context teardown and canvas remount on every prop change — GPU pipeline
  rebuilt from scratch for something as minor as a color tweak.
- requestAnimationFrame ran unconditionally at 60fps even when the element
  was scrolled completely offscreen, burning GPU cycles with no visible output.
- No awareness of browser tab visibility — shader kept executing even in
  background tabs.

Changes (applied to all 4 variants: JS-CSS, JS-TW, TS-CSS, TS-TW):

1. Split into two useEffects:
   - Effect 1 ([] deps): creates renderer, canvas, geometry, program, mesh
     exactly once for the lifetime of the component.
   - Effect 2 (prop deps): writes directly to uniform values — zero GPU cost,
     no context recreation, no canvas remount.

2. WeakMap<HTMLDivElement, GrainientCtx> bridges the two effects without
   creating strong references that would leak on unmount.

3. IntersectionObserver (threshold: 0) pauses the RAF loop the moment the
   canvas scrolls offscreen and resumes when it re-enters the viewport.

4. visibilitychange listener pauses the RAF loop when the browser tab is
   hidden and resumes when the user returns to it.

Result: no unnecessary GPU work, dramatically lower CPU/GPU usage on pages
where the component is not in view, and instant prop updates without flicker.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the Grainient background component’s runtime performance by avoiding WebGL reinitialization on prop changes and reducing unnecessary GPU work when the component is not visible.

Changes:

  • Split WebGL initialization (one-time) from uniform updates (prop-driven) to prevent context teardown/rebuild on prop updates.
  • Pause/resume the RAF render loop based on viewport intersection and page/tab visibility.
  • Updated all 4 published variants plus their registry JSON snapshots.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/ts-tailwind/Backgrounds/Grainient/Grainient.tsx TS + Tailwind variant: one-time WebGL setup, prop-to-uniform sync effect, visibility-based RAF pausing.
src/ts-default/Backgrounds/Grainient/Grainient.tsx TS + CSS variant: same lifecycle split and RAF pausing behavior.
src/tailwind/Backgrounds/Grainient/Grainient.jsx JS + Tailwind variant: same lifecycle split and RAF pausing behavior.
src/content/Backgrounds/Grainient/Grainient.jsx JS + CSS/content variant: same lifecycle split and RAF pausing behavior.
public/r/Grainient-TS-TW.json Registry snapshot updated to reflect TS+TW implementation changes.
public/r/Grainient-TS-CSS.json Registry snapshot updated to reflect TS+CSS implementation changes.
public/r/Grainient-JS-TW.json Registry snapshot updated to reflect JS+TW implementation changes.
public/r/Grainient-JS-CSS.json Registry snapshot updated to reflect JS+CSS implementation changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +298 to +302
u.uCenterOffset.value = new Float32Array([centerX, centerY]);
u.uZoom.value = zoom;
u.uColor1.value = new Float32Array(hexToRgb(color1));
u.uColor2.value = new Float32Array(hexToRgb(color2));
u.uColor3.value = new Float32Array(hexToRgb(color3));
Comment on lines +191 to +195
uTimeSpeed: { value: 0.25 },
uColorBalance: { value: 0.0 },
uWarpStrength: { value: 1.0 },
uWarpFrequency: { value: 5.0 },
uWarpSpeed: { value: 2.0 },
Comment on lines +299 to +303
u.uCenterOffset.value = new Float32Array([centerX, centerY]);
u.uZoom.value = zoom;
u.uColor1.value = new Float32Array(hexToRgb(color1));
u.uColor2.value = new Float32Array(hexToRgb(color2));
u.uColor3.value = new Float32Array(hexToRgb(color3));
Comment on lines +159 to +179
uTimeSpeed: { value: 0.25 },
uColorBalance: { value: 0.0 },
uWarpStrength: { value: 1.0 },
uWarpFrequency: { value: 5.0 },
uWarpSpeed: { value: 2.0 },
uWarpAmplitude: { value: 50.0 },
uBlendAngle: { value: 0.0 },
uBlendSoftness: { value: 0.05 },
uRotationAmount: { value: 500.0 },
uNoiseScale: { value: 2.0 },
uGrainAmount: { value: 0.1 },
uGrainScale: { value: 2.0 },
uGrainAnimated: { value: 0.0 },
uContrast: { value: 1.5 },
uGamma: { value: 1.0 },
uSaturation: { value: 1.0 },
uCenterOffset: { value: new Float32Array([0, 0]) },
uZoom: { value: 0.9 },
uColor1: { value: new Float32Array([1, 1, 1]) },
uColor2: { value: new Float32Array([1, 1, 1]) },
uColor3: { value: new Float32Array([1, 1, 1]) }
Comment on lines +267 to +271
u.uCenterOffset.value = new Float32Array([centerX, centerY]);
u.uZoom.value = zoom;
u.uColor1.value = new Float32Array(hexToRgb(color1));
u.uColor2.value = new Float32Array(hexToRgb(color2));
u.uColor3.value = new Float32Array(hexToRgb(color3));
Comment on lines +160 to +164
uTimeSpeed: { value: 0.25 },
uColorBalance: { value: 0.0 },
uWarpStrength: { value: 1.0 },
uWarpFrequency: { value: 5.0 },
uWarpSpeed: { value: 2.0 },
Comment on lines +268 to +272
u.uCenterOffset.value = new Float32Array([centerX, centerY]);
u.uZoom.value = zoom;
u.uColor1.value = new Float32Array(hexToRgb(color1));
u.uColor2.value = new Float32Array(hexToRgb(color2));
u.uColor3.value = new Float32Array(hexToRgb(color3));
uColorBalance: { value: 0.0 },
uWarpStrength: { value: 1.0 },
uWarpFrequency: { value: 5.0 },
uWarpSpeed: { value: 2.0 },
@mohamed-younes16
Copy link
Contributor Author

Thanks for the Copilot review.

Main point for this PR: it fixes the largest runtime costs in Grainient:

  1. removing full WebGL teardown/rebuild on every prop change, and
  2. pausing RAF when offscreen / tab-hidden.

These are the high-impact performance issues and the primary reason for this PR.

I also reviewed the Copilot follow-up notes (initial uniform values and in-place Float32Array updates). Those are valid micro-optimizations, but comparatively lower impact than the architectural fixes above.

Happy to push a follow-up commit for those refinements as well if you’d like.

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.

2 participants