@wethegit/react-wtc-gl
v0.1.0
Published
React components and hooks for wtc-gl recipes.
Keywords
Readme
@wethegit/react-wtc-gl
React components and hooks for wtc-gl recipes.
Currently wraps the ScrollRenderer recipe: render multiple independent WebGL scenes on a single fixed canvas, each scissor-tested to a DOM element's bounds and driven by one requestAnimationFrame loop.
Install
npm install @wethegit/react-wtc-gl wtc-glreact >= 18 and wtc-gl are peer dependencies.
Usage
Mount one ScrollRendererProvider per page (it owns the fixed, full-viewport canvas), then register scenes from descendant components.
import { useRef } from 'react'
import { Mesh, Program, Triangle } from 'wtc-gl'
import { ScrollRendererProvider, useScrollScene } from '@wethegit/react-wtc-gl'
import vertex from './hero.vert'
import fragment from './hero.frag'
function HeroSection() {
const ref = useRef<HTMLDivElement>(null)
useScrollScene(ref, ({ gl, scrollScene }) => {
const geometry = new Triangle(gl)
const program = new Program(gl, {
vertex,
fragment,
// u_time, u_resolution and u_origin, auto-updated every frame
uniforms: { ...scrollScene.uniforms }
})
new Mesh(gl, { geometry, program }).setParent(scrollScene.scene)
// Free GL resources when the section unmounts - the provider (and its
// WebGL context) may outlive any one scene.
return () => {
geometry.remove()
program.remove()
}
})
return <div ref={ref} className="hero" />
}
export default function Page() {
return (
<ScrollRendererProvider>
<HeroSection />
</ScrollRendererProvider>
)
}The setup function runs once when the scene is created and, like a useEffect callback, may return a cleanup function. Always free the GL resources setup created there (program.remove(), geometry.remove(), transformFeedback.remove()) - with a long-lived provider (e.g. mounted in a layout), anything you don't free accumulates in the WebGL context as scenes mount and unmount across navigations.
For image-based scenes (u_image / u_imageSize uniforms) use useScrollImage with a ref to an <img>:
const ref = useRef<HTMLImageElement>(null)
useScrollImage(ref, ({ gl, scrollScene }) => {
/* ... */
})
return <img ref={ref} src={src} alt="" />API
<ScrollRendererProvider rendererProps? onBeforeRender? onAfterRender? playing? className? style?>- creates theScrollRendererand canvas.useScrollRenderer()- the nearest provider'sScrollRenderer(ornullwhile it initializes).useScrollScene(elementRef, setup?, options?)- registers aScrollScene; returns a ref to it.useScrollImage(elementRef, setup?, options?)- registers aScrollImage; returns a ref to it.
options accepts everything the underlying ScrollScene / ScrollImage constructors do (camera, useViewport, clipToViewport, clearOnRender, elementSpace, margin, initializedClass, per-frame callbacks, …).
Gotchas
React 18 StrictMode mounts every component twice in development (mount → unmount → mount). The scene hooks handle this: the effect cleanup removes and destroys the scene between the two mounts, and the second mount recreates it. If scenes flicker or disappear in dev but not production, the likely culprit is the cleanup function returned by your setup - make sure it only frees resources that setup itself created.
Two-render cycle. The provider creates the renderer in an effect, which runs after the first paint, so useScrollRenderer() returns null on the first render pass and scene hooks register on the second. This is normal - nothing is visible in the gap because the canvas is transparent.
Setup runs once. The setup function is called when the scene is created (once the renderer and element are available) and never again - changing it between renders has no effect until the component remounts. The per-frame onBeforeRender / onAfterRender options are the exception: the latest ones are always called, so inline arrows capturing fresh props or state are fine there.
Scene ordering. Scenes render in registration order, which is mount order. If you composite scenes with clearOnRender: false, be aware that Suspense boundaries or conditional rendering can change mount order between dev and prod builds.
u_time resets on remount. A remounted scene starts with u_time = 0. Shaders that use time as a seed (rather than just an animation offset) will restart on remount.
Performance and memory testing
Verifying cleanup with heap snapshots (Chrome DevTools)
The main leak risk is a ScrollScene whose IntersectionObserver is never disconnected. The hooks call scene.destroy() on unmount, so a leak here usually means scenes were registered outside the hooks. To check:
- Open Memory tab → take a baseline heap snapshot.
- Mount a component that registers a scene, interact with it, then unmount it.
- Click Collect garbage (the bin icon), then take a second snapshot.
- Switch to Comparison view between the two snapshots.
- Filter by
IntersectionObserverandScrollScene. Neither should show a positive delta - if they do, the scene is not being destroyed on unmount.
Repeat with StrictMode enabled to ensure the double-invoke cycle leaves no residue.
Verifying the render loop stops
When the provider unmounts, the requestAnimationFrame loop should stop:
- Open Performance tab → start recording.
- Unmount the provider (navigate away, or conditionally render it out).
- Stop recording and inspect the flame chart. There should be no further
renderframes fromScrollRendererafter unmount.
Checking IntersectionObserver pause behaviour
ScrollScene skips rendering when its element is off screen. Confirm off-screen scenes aren't burning GPU time:
- Open Performance tab → record while scrolling a scene fully off screen.
- Verify the per-scene render cost (scissor, draw calls) drops to zero for that scene in the GPU track.
- A simpler proxy: pass
onBeforeRender: () => console.count('render: hero')in the hook options and confirm it stops incrementing when the element is not in the viewport.
Memory pressure with many scenes
If the page has a large number of scenes (10+):
- Each unmounted component should reduce the renderer's registered scene count by one.
- Use the Performance Monitor panel (three-dot menu in DevTools) to watch JS heap size over a mount/unmount cycle. A sawtooth that returns to baseline after GC is healthy; a staircase is a leak.
