WebGL / React Three Fiber
July 29, 2026 · View on GitHub
WebGL 2-accelerated 3D rendering with a persistent root canvas, built on
@react-three/fiber's WebGLRenderer. GPU simulations (fluid, flowmap) run
as GLSL3 RawShaderMaterial passes.
Quick Start
import { Wrapper } from '@/components/layout/wrapper'
import { WebGLTunnel } from '@/webgl/components/tunnel'
export default function Page() {
return (
<Wrapper>
<WebGLTunnel>
<My3DScene />
</WebGLTunnel>
<section>HTML overlay</section>
</Wrapper>
)
}
No configuration needed — the root canvas is mounted once in the shared layout
(lib/features), so any page can portal 3D content into it with <WebGLTunnel>.
Two canvas strategies (pick one)
The canvas is mounted with <Canvas root>. Choose one place to do it:
- Shared (default):
<Canvas root />lives in the layout (lib/features), so the context persists across route navigation. Pages just use<WebGLTunnel>. - Per page: remove the shared canvas and pass
webglto the Wrapper (<Wrapper webgl>), which mounts the canvas only on that page.
There is exactly one root canvas at a time — the store enforces this at runtime, so if both are mounted the first one wins and the second is a no-op (with a dev warning), not a second canvas eating GPU.
Perf: opting into GPU simulations
<Canvas root> mounts FlowmapProvider with no GPU simulations by default —
mounting a sim without a consumer wastes a render pass and window listeners.
Pass simTypes with the sims you actually use:
<Canvas root simTypes={['flowmap']} />
Device gating
The canvas is rendered only when useDeviceDetection().isWebGL is true (a
working WebGL2 context on a desktop viewport) AND the user does not prefer
reduced motion. On mobile, unsupported devices, or under
prefers-reduced-motion it's a no-op — nothing mounts. Rendering is driven
manually by the RAF component (frameloop="never"), not the default r3f
render loop.
Always ship a fallback. Because reduced-motion (and non-WebGL devices) means the canvas may never mount, any page that puts content in WebGL — not just decoration — must render a non-WebGL fallback (static image, DOM equivalent) for that state. If the WebGL content is essential and motionless, mount with
forceand damp motion inside the scene instead.
Architecture
<Canvas root> (layout OR per-page Wrapper) → rendered only when isWebGL
└─ WebGLTunnel.Out (portals 3D content from any page)
Key benefits (shared/layout strategy):
- Context persists across navigation (no recreation)
- Seamless route transitions
- Shared assets stay loaded
- No-op on non-WebGL devices
Components
| Component | Purpose |
|---|---|
Canvas | Mounts the canvas via root (layout or per-page Wrapper) |
WebGLTunnel | Portal 3D content into the canvas |
DOMTunnel | Portal HTML overlays |
Hooks
import { useDeviceDetection } from '@/hooks/use-device-detection'
import { useWebGLElement } from '@/webgl/hooks/use-webgl-element'
// Sync a DOM element's rect into the scene (+ on-screen visibility)
const { setRef, rect, isVisible } = useWebGLElement()
// Gate rendering on capability
const { isWebGL } = useDeviceDetection()
useWebGLRect is the lower-level primitive (useWebGLElement is built on it):
it returns a stable getter for an element's current transform, for reading
inside a useFrame loop.
DOM-Synced Component
import { useWebGLElement } from '@/webgl/hooks/use-webgl-element'
import { WebGLTunnel } from '@/webgl/components/tunnel'
function WebGLBox({ className }) {
const { setRef, rect, isVisible } = useWebGLElement()
return (
<div ref={setRef} className={className}>
<WebGLTunnel>
<MyMesh rect={rect} visible={isVisible} />
</WebGLTunnel>
</div>
)
}