Fullscreen Triangle Pass
The smallest possible host for a full-screen fragment shader: a raw WebGL2 context, one buffer, one triangle whose vertices sit at (-1,-1), (3,-1) and (-1,3). The clipped triangle covers the viewport exactly, so there is no diagonal seam and no doubly-shaded band where two triangles would meet. Pass any GLSL ES 3.00 fragment source and it runs against `v_uv`, `u_resolution`, `u_time` and `u_pointer`.
Fullscreen Triangle Pass
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/fullscreen-triangle-pass.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Fullscreen Triangle Pass
The smallest possible host for a full-screen fragment shader: a raw WebGL2 context, one buffer, one triangle whose vertices sit at (-1,-1), (3,-1) and (-1,3). The clipped triangle covers the viewport exactly, so there is no diagonal seam and no doubly-shaded band where two triangles would meet. Pass any GLSL ES 3.00 fragment source and it runs against `v_uv`, `u_resolution`, `u_time` and `u_pointer`.
Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.
## What it does
One 3-vertex triangle covers the viewport and runs your fragment shader. No three, no ogl, no seam.
- Category: webgl
- Runtime cost: webgl
- Dependencies: none beyond React
## How it works
1. A single `Float32Array([-1,-1, 3,-1, -1,3])` is uploaded once as `STATIC_DRAW` and bound into a VAO. The vertex shader is `gl_Position = vec4(a_pos, 0.0, 1.0)` — the positions are already in clip space, so there is no model, view or projection matrix anywhere in the component.
2. `v_uv = a_pos * 0.5 + 0.5` gives uv 0..1 at the visible corners and uv 2.0 at the two overshooting vertices; the rasteriser clips the triangle to the viewport and interpolates the varying linearly, so the surviving fragments carry exactly the uv a two-triangle quad would have produced.
3. The context is requested with `antialias: false, depth: false, stencil: false, alpha: false` — a fullscreen pass has no polygon interiors to multisample and no depth to test, and every one of those buffers costs width x height x DPR bytes plus a resolve.
4. Uniform locations are resolved lazily and memoised in a `Map` keyed by name, because `getUniformLocation` is a synchronous driver round-trip and calling it per frame per uniform is the single most common cause of a raw-GL component being slower than a three.js one.
5. `u_pointer` is fed from `useSmoothPointer` with the y axis flipped (`1 - ny`), so the shader receives the same handedness as `gl_FragCoord`.
6. Resize writes `canvas.width/height` at `pixelRatio(maxDpr)` and calls `gl.viewport`, then draws one frame immediately — which is also the only draw that happens under reduced motion.
## Constants — use these values
These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".
- `TRIANGLE = [-1,-1, 3,-1, -1,3]`
The minimum overshoot that still guarantees full coverage. With 1.0 instead of 3.0 you get a half-screen triangle; with 5.0 the uv range widens to 0..3 and any shader that assumes uv is 0..1 at the vertices misinterprets its own derivatives.
- `MAX_DPR = 2`
Fragment count is quadratic in DPR. A fullscreen shader at DPR 3 costs 2.25x what it costs at 2 for no perceptible gain, and it is the usual reason a hero shader stutters on a phone but not on the laptop it was built on.
- `pointer smoothing = 0.002`
Fraction of the pointer gap remaining after one second. Raw pointer coordinates arrive in 8-16 ms bursts and pushing them straight into a uniform makes the light jitter one pixel per event.
## API
```ts
function FullscreenTrianglePass(props: { fragment?: string; uniforms?: Record<string, number | readonly number[]>; speed?: number; maxDpr?: number; className?: string }): JSX.Element
```
- `fragment: string` — default `FULLSCREEN_TRIANGLE_FRAG`
GLSL ES 3.00 fragment source. Must open with `#version 300 es` and declare an `out vec4`. Changing it relinks the program.
- `uniforms: Record<string, number | readonly number[]>`
Extra uniforms pushed every frame. Numbers become `uniform1f`; arrays of length 2/3/4 become `uniform2f`/`3f`/`4f`.
- `speed: number` — default `1`
Multiplier applied to `u_time`.
- `maxDpr: number` — default `2`
Upper bound on device pixel ratio.
- `className: string`
Merged onto the host element.
## Reference implementation of the core
This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.
```tsx
/* Two vertices outside the viewport, one at the origin corner. Anything larger
than 3.0 also works; 3.0 is the minimum that guarantees full coverage. */
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const MAX_DPR = 2;
interface Pass {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
locations: Map<string, WebGLUniformLocation | null>;
}
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
// Log rather than throw: a shader that fails to compile should degrade to
// the static fallback, not take the whole React tree down with it.
console.error('[fullscreen-triangle-pass]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createPass(gl: WebGL2RenderingContext, fragment: string): Pass | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, FULLSCREEN_TRIANGLE_VERT);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragment);
const program = gl.createProgram();
if (!vert || !frag || !program) {
if (vert) gl.deleteShader(vert);
if (frag) gl.deleteShader(frag);
if (program) gl.deleteProgram(program);
return null;
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
// Safe to delete immediately after linking — the program holds a reference.
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[fullscreen-triangle-pass]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vao || !buffer) {
gl.deleteProgram(program);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
return { gl, program, vao, buffer, locations: new Map() };
}
/** Uniform locations are stable for the life of a program — look up once. */
function uniformLocation(pass: Pass, name: string): WebGLUniformLocation | null {
const cached = pass.locations.get(name);
if (cached !== undefined) return cached;
const found = pass.gl.getUniformLocation(pass.program, name);
pass.locations.set(name, found);
return found;
}
function setUniform(
gl: WebGL2RenderingContext,
location: WebGLUniformLocation | null,
value: UniformValue,
): void {
// Null means the uniform was optimised out because the shader never reads
// it. Silently skipping is correct; calling uniform*f with null is a no-op
// that some drivers log as an error every frame.
if (location === null) return;
if (typeof value === 'number') {
gl.uniform1f(location, value);
return;
}
const a = value[0] ?? 0;
const b = value[1] ?? 0;
const c = value[2] ?? 0;
const d = value[3] ?? 0;
if (value.length === 2) gl.uniform2f(location, a, b);
else if (value.length === 3) gl.uniform3f(location, a, b, c);
else if (value.length >= 4) gl.uniform4f(location, a, b, c, d);
else gl.uniform1f(location, a);
}
function disposePass(pass: Pass): void {
const { gl } = pass;
gl.deleteVertexArray(pass.vao);
gl.deleteBuffer(pass.buffer);
gl.deleteProgram(pass.program);
// dispose alone leaves the context alive; only the extension hands it back.
loseContext(gl);
}
export function FullscreenTrianglePass({
fragment = FULLSCREEN_TRIANGLE_FRAG,
uniforms,
speed = 1,
maxDpr = MAX_DPR,
className,
}: FullscreenTrianglePassProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const passRef = useRef<Pass | null>(null);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
const pointer = useSmoothPointer({ target: host, smoothing: 0.002 });
const [supported, setSupported] = useState(true);
// Held in refs so a changed uniform object never re-creates the program.
const uniformsRef = useRef(uniforms);
uniformsRef.current = uniforms;
const speedRef = useRef(speed);
speedRef.current = speed;
const drawRef = useRef<(elapsed: number) => void>(() => {});
drawRef.current = (elapsed: number) => {
const pass = passRef.current;
if (!pass) return;
const { gl } = pass;
gl.useProgram(pass.program);
setUniform(gl, uniformLocation(pass, 'u_resolution'), [
gl.drawingBufferWidth,
gl.drawingBufferHeight,
]);
setUniform(gl, uniformLocation(pass, 'u_time'), elapsed * speedRef.current);
const p = pointer.current;
// Flip y: DOM coordinates grow downward, gl_FragCoord grows upward.
setUniform(gl, uniformLocation(pass, 'u_pointer'), [p.nx, 1 - p.ny]);
const extra = uniformsRef.current;
if (extra) {
for (const name of Object.keys(extra)) {
const value = extra[name];
if (value !== undefined) setUniform(gl, uniformLocation(pass, name), value);
}
}
gl.bindVertexArray(pass.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
// `antialias: false` — a fullscreen pass has no polygon edges to smooth,
// so MSAA buys nothing and costs a multisample resolve every frame.
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const pass = createPass(gl, fragment);
if (!pass) {
loseContext(gl);
setSupported(false);
return;
}
passRef.current = pass;
setSupported(true);
return () => {
passRef.current = null;
disposePass(pass);
};
}, [fragment]);
// Resize and the static frame share one effect: whenever the backing store
// changes we must redraw, and under reduced motion that redraw is the only
// one that ever happens.
useEffect(() => {
const canvas = canvasRef.current;
const pass = passRef.current;
if (!canvas || !pass || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
pass.gl.viewport(0, 0, w, h);
drawRef.current(0);
}, [width, height, maxDpr, supported, fragment]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((_dt, elapsed) => drawRef.current(elapsed), running);
```
## Requirements
These are not optional. A version of this component that skips any of them is
not finished.
**Cleanup.** Deletes the VAO, the vertex buffer and the program, then calls `loseContext(gl)` to hand the WebGL context back to the browser. The rAF subscription unsubscribes with the hook. The effect is keyed on `fragment`, so a source change tears the old program down before building the new one rather than leaking it.
**Reduced motion.** The rAF subscription never starts. The pass still draws exactly one frame on mount and one on every resize, so you get a fully composed still image rather than a blank canvas — `u_time` is pinned to 0 and the pointer light sits wherever it was seeded.
**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.
**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.
## Known pitfalls — avoid these specifically
- Putting a newline, a comment, or a leading space before `#version 300 es`. The directive must be the first token in the source or the compile fails with "version directive must occur before anything else", and because the shader source lives in a template literal the offending newline is invisible in the diff.
- Drawing a quad out of two triangles. GPUs shade in 2x2 fragment quads, so every quad the shared diagonal crosses is rasterised by both triangles and those fragments are shaded twice — a band of pure waste down the diagonal. It also breaks `fwidth`/`dFdx` across the seam, which shows up as a visible hairline in any analytic-AA shader.
- Calling `gl.getUniformLocation` inside the render loop. It is a driver round-trip, not a hash lookup; the locations are constant for the life of the program, so resolve them once and cache them.
- Passing a uniform that the shader never reads. `getUniformLocation` returns `null` for anything the compiler optimised out, and `gl.uniform1f(null, x)` is a silent no-op that some drivers log to the console 60 times a second. Skip on null.
- Calling `renderer.dispose()` or just dropping the canvas and assuming the context is released. Only `WEBGL_lose_context.loseContext()` hands it back, and Chromium kills the OLDEST live context when a page exceeds roughly 16 — so a leaked context here makes an unrelated component elsewhere on the page go black.
- Throwing on a shader compile error. A syntax error in a user-supplied fragment then unmounts the entire React subtree; logging the info log and falling back to the static poster keeps the page alive and puts the actual error in the console.
## Notes
- WebGL2 only. GLSL ES 3.00 gives `in`/`out`, integer bit operators and core derivatives without an extension dance; WebGL2 is available in every browser that has shipped since 2021 except pre-15.4 Safari, which the `hasWebGL` guard catches.
- Everything else in the WebGL category that does not use three.js is built on this exact pass — it is the shared floor, not a demo.
## Definition of done
The grid fills the frame edge to edge with no lighter or darker band along either diagonal, and the fine grid brightens under the cursor. Resize the container and the grid stays square with hairlines exactly one pixel wide at any DPR. Unmount and `WEBGL_lose_context` fires — mount and unmount twenty times and the twenty-first still renders instead of going black.
When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
Source
9 files, exactly as installed.
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useSmoothPointer } from '@/hooks/use-smooth-pointer';
import { useVisibility } from '@/hooks/use-visibility';
import { hasWebGL, loseContext, pixelRatio } from '@/lib/webgl';
import {
FULLSCREEN_TRIANGLE_FRAG,
FULLSCREEN_TRIANGLE_VERT,
} from '@/components/motion/shaders/fullscreen-triangle-pass';
/* ==========================================================================
Fullscreen Triangle Pass
--------------------------------------------------------------------------
Raw WebGL2. No three, no ogl — a framework here would ship 600 KB to draw
three vertices.
The geometry is ONE triangle at (-1,-1), (3,-1), (-1,3). It overshoots the
viewport on two sides and the clipped remainder covers the screen exactly.
Two triangles would meet along the diagonal, and the GPU shades in 2x2 quads:
every quad the seam passes through is rasterised by BOTH triangles, so those
fragments are shaded twice. On a full-screen pass that is a measurable band
of redundant work down the diagonal, plus one extra vertex, plus a visible
seam wherever a derivative (`fwidth`, `dFdx`) is taken across it.
========================================================================== */
/** Scalar or vec2/vec3/vec4, as a plain array. Matrices are not supported. */
export type UniformValue = number | readonly number[];
export interface FullscreenTrianglePassProps {
/**
* GLSL ES 3.00 fragment source. Must begin with `#version 300 es` on the
* very first line and declare an `out vec4`. Receives `v_uv` (0..1),
* `u_resolution` (device px), `u_time` (seconds) and `u_pointer` (0..1, y up).
*/
fragment?: string;
/** Extra uniforms, pushed every frame. Names must match the declarations. */
uniforms?: Record<string, UniformValue>;
/** Multiplier on `u_time`. */
speed?: number;
/** Upper bound on device pixel ratio. Fill rate is quadratic in this. */
maxDpr?: number;
className?: string;
}
// #region critical
/* Two vertices outside the viewport, one at the origin corner. Anything larger
than 3.0 also works; 3.0 is the minimum that guarantees full coverage. */
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const MAX_DPR = 2;
interface Pass {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
locations: Map<string, WebGLUniformLocation | null>;
}
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
// Log rather than throw: a shader that fails to compile should degrade to
// the static fallback, not take the whole React tree down with it.
console.error('[fullscreen-triangle-pass]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createPass(gl: WebGL2RenderingContext, fragment: string): Pass | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, FULLSCREEN_TRIANGLE_VERT);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragment);
const program = gl.createProgram();
if (!vert || !frag || !program) {
if (vert) gl.deleteShader(vert);
if (frag) gl.deleteShader(frag);
if (program) gl.deleteProgram(program);
return null;
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
// Safe to delete immediately after linking — the program holds a reference.
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[fullscreen-triangle-pass]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vao || !buffer) {
gl.deleteProgram(program);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
return { gl, program, vao, buffer, locations: new Map() };
}
/** Uniform locations are stable for the life of a program — look up once. */
function uniformLocation(pass: Pass, name: string): WebGLUniformLocation | null {
const cached = pass.locations.get(name);
if (cached !== undefined) return cached;
const found = pass.gl.getUniformLocation(pass.program, name);
pass.locations.set(name, found);
return found;
}
function setUniform(
gl: WebGL2RenderingContext,
location: WebGLUniformLocation | null,
value: UniformValue,
): void {
// Null means the uniform was optimised out because the shader never reads
// it. Silently skipping is correct; calling uniform*f with null is a no-op
// that some drivers log as an error every frame.
if (location === null) return;
if (typeof value === 'number') {
gl.uniform1f(location, value);
return;
}
const a = value[0] ?? 0;
const b = value[1] ?? 0;
const c = value[2] ?? 0;
const d = value[3] ?? 0;
if (value.length === 2) gl.uniform2f(location, a, b);
else if (value.length === 3) gl.uniform3f(location, a, b, c);
else if (value.length >= 4) gl.uniform4f(location, a, b, c, d);
else gl.uniform1f(location, a);
}
function disposePass(pass: Pass): void {
const { gl } = pass;
gl.deleteVertexArray(pass.vao);
gl.deleteBuffer(pass.buffer);
gl.deleteProgram(pass.program);
// dispose alone leaves the context alive; only the extension hands it back.
loseContext(gl);
}
export function FullscreenTrianglePass({
fragment = FULLSCREEN_TRIANGLE_FRAG,
uniforms,
speed = 1,
maxDpr = MAX_DPR,
className,
}: FullscreenTrianglePassProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const passRef = useRef<Pass | null>(null);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
const pointer = useSmoothPointer({ target: host, smoothing: 0.002 });
const [supported, setSupported] = useState(true);
// Held in refs so a changed uniform object never re-creates the program.
const uniformsRef = useRef(uniforms);
uniformsRef.current = uniforms;
const speedRef = useRef(speed);
speedRef.current = speed;
const drawRef = useRef<(elapsed: number) => void>(() => {});
drawRef.current = (elapsed: number) => {
const pass = passRef.current;
if (!pass) return;
const { gl } = pass;
gl.useProgram(pass.program);
setUniform(gl, uniformLocation(pass, 'u_resolution'), [
gl.drawingBufferWidth,
gl.drawingBufferHeight,
]);
setUniform(gl, uniformLocation(pass, 'u_time'), elapsed * speedRef.current);
const p = pointer.current;
// Flip y: DOM coordinates grow downward, gl_FragCoord grows upward.
setUniform(gl, uniformLocation(pass, 'u_pointer'), [p.nx, 1 - p.ny]);
const extra = uniformsRef.current;
if (extra) {
for (const name of Object.keys(extra)) {
const value = extra[name];
if (value !== undefined) setUniform(gl, uniformLocation(pass, name), value);
}
}
gl.bindVertexArray(pass.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
// `antialias: false` — a fullscreen pass has no polygon edges to smooth,
// so MSAA buys nothing and costs a multisample resolve every frame.
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const pass = createPass(gl, fragment);
if (!pass) {
loseContext(gl);
setSupported(false);
return;
}
passRef.current = pass;
setSupported(true);
return () => {
passRef.current = null;
disposePass(pass);
};
}, [fragment]);
// Resize and the static frame share one effect: whenever the backing store
// changes we must redraw, and under reduced motion that redraw is the only
// one that ever happens.
useEffect(() => {
const canvas = canvasRef.current;
const pass = passRef.current;
if (!canvas || !pass || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
pass.gl.viewport(0, 0, w, h);
drawRef.current(0);
}, [width, height, maxDpr, supported, fragment]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((_dt, elapsed) => drawRef.current(elapsed), running);
// #endregion critical
return (
<div ref={host} className={cn('relative h-full w-full overflow-hidden', className)}>
{supported ? (
<canvas ref={canvasRef} className="block h-full w-full" />
) : (
<div
aria-hidden
className="h-full w-full bg-bg"
style={{
backgroundImage:
'repeating-linear-gradient(0deg, var(--k-line) 0 1px, transparent 1px 28px), repeating-linear-gradient(90deg, var(--k-line) 0 1px, transparent 1px 28px)',
}}
/>
)}
</div>
);
}
/* ==========================================================================
Fullscreen Triangle Pass — GLSL ES 3.00
--------------------------------------------------------------------------
`#version 300 es` MUST be the first token in the source, before any
whitespace or comment. That is why these template literals open flush
against the backtick instead of on the next line.
========================================================================== */
/**
* The entire vertex stage. No matrices, no projection, no model transform —
* the three vertices are already in clip space.
*
* `a_pos * 0.5 + 0.5` maps clip space to 0..1, so `v_uv` reads 0..1 across the
* VISIBLE region even though two of the three vertices sit outside it at
* uv 2.0. The rasteriser clips the triangle to the viewport and interpolates
* the varying linearly, so the clipped result carries exactly the same uv a
* quad would have produced.
*/
export const FULLSCREEN_TRIANGLE_VERT = `#version 300 es
in vec2 a_pos;
out vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
/**
* Default payload: an aspect-corrected measuring grid with a soft light that
* follows the pointer. Deliberately cheap — the point of this component is the
* pass, not the picture.
*/
export const FULLSCREEN_TRIANGLE_FRAG = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_pointer;
/* Analytic anti-aliased grid. Dividing the distance-to-line by fwidth gives a
value measured in PIXELS, so a single smoothstep produces a line of constant
apparent weight no matter how far the grid is scaled. */
float gridMask(vec2 p, float thickness) {
vec2 w = fwidth(p);
vec2 d = abs(fract(p - 0.5) - 0.5) / max(w, vec2(1e-5));
float line = min(d.x, d.y);
return 1.0 - smoothstep(thickness - 0.5, thickness + 0.5, line);
}
void main() {
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
vec2 p = v_uv;
p.x *= aspect;
vec2 m = u_pointer;
m.x *= aspect;
/* Two grids an octave apart: the coarse one carries structure, the fine one
only appears near the pointer, which reads as focus rather than as detail. */
float coarse = gridMask(p * 9.0, 0.6);
float fine = gridMask(p * 36.0, 0.5);
float d = distance(p, m);
float focus = smoothstep(0.55, 0.0, d);
/* A slow breathing sweep so the pass is visibly live without animating the
grid itself, which would read as a rendering fault. */
float sweep = 0.5 + 0.5 * sin(p.x * 1.6 - p.y * 0.9 - u_time * 0.35);
vec3 col = vec3(0.035, 0.038, 0.045);
col += vec3(0.10, 0.13, 0.16) * coarse * (0.35 + 0.65 * sweep);
col += vec3(0.16, 0.34, 0.30) * fine * focus;
col += vec3(0.10, 0.22, 0.20) * focus * focus * 0.55;
float vignette = smoothstep(1.35, 0.35, distance(v_uv, vec2(0.5)));
col *= mix(0.72, 1.0, vignette);
fragColor = vec4(col, 1.0);
}
`;
/* ==========================================================================
Frame-rate-independent interpolation.
--------------------------------------------------------------------------
The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice
as fast on a 120 Hz display as on a 60 Hz one, so the same code feels
different on different hardware and visibly speeds up when the browser
drops frames. `damp` fixes it by raising the retention factor to the power
of elapsed time, which is the exact continuous form of the same curve.
========================================================================== */
/**
* Fraction of the remaining distance to close this frame.
*
* @param smoothing Fraction of the gap that REMAINS after one second.
* Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft,
* 0.5 ≈ slow drift.
* @param dt Seconds elapsed since the previous frame.
*/
export function dampFactor(smoothing: number, dt: number): number {
return 1 - Math.pow(smoothing, dt);
}
/** Move `current` toward `target` at a rate independent of frame rate. */
export function damp(
current: number,
target: number,
smoothing: number,
dt: number,
): number {
return current + (target - current) * dampFactor(smoothing, dt);
}
/**
* The per-frame lerp constant an author measured at 60fps, converted into the
* `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame"
* be ported without re-tuning by eye:
*
* damp(x, t, perFrameToSmoothing(0.1), dt)
*/
export function perFrameToSmoothing(alpha: number, baseFps = 60): number {
return Math.pow(1 - alpha, baseFps);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
export function inverseLerp(a: number, b: number, v: number): number {
return a === b ? 0 : (v - a) / (b - a);
}
export function mapRange(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
doClamp = true,
): number {
const t = inverseLerp(inMin, inMax, v);
const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t);
return r;
}
/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp(inverseLerp(edge0, edge1, x), 0, 1);
return t * t * (3 - 2 * t);
}
/**
* Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at
* distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and
* no branches, which is why carousels that use it feel analogue rather than
* stepped.
*/
export function gaussian(distance: number, falloff = 1): number {
const d = distance * falloff;
return Math.exp(-(d * d));
}
'use client';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One shared requestAnimationFrame loop.
--------------------------------------------------------------------------
Forty components each calling requestAnimationFrame means forty callbacks
the browser schedules separately and forty chances to read layout after a
write. One loop, one ordered pass, one place to clamp delta time.
Delta is clamped to 100 ms. Without that, returning to a backgrounded tab
delivers a multi-second dt and every physics integration in the page
explodes on the first frame back.
========================================================================== */
export type RafCallback = (dt: number, elapsed: number) => void;
const callbacks = new Set<RafCallback>();
let frame = 0;
let last = 0;
let start = 0;
const MAX_DT = 0.1;
const DEFAULT_DT = 1 / 60;
function tick(now: number) {
frame = requestAnimationFrame(tick);
const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT);
last = now;
const elapsed = (now - start) / 1000;
// Copy: a callback may unsubscribe itself (or another) mid-iteration.
for (const cb of Array.from(callbacks)) {
if (callbacks.has(cb)) cb(dt, elapsed);
}
}
function subscribe(cb: RafCallback) {
callbacks.add(cb);
if (frame === 0) {
start = performance.now();
last = 0;
frame = requestAnimationFrame(tick);
}
return () => {
callbacks.delete(cb);
if (callbacks.size === 0 && frame !== 0) {
cancelAnimationFrame(frame);
frame = 0;
last = 0;
}
};
}
/**
* Run `cb` every frame while `enabled`.
*
* The callback is held in a ref, so it does not need to be memoised and the
* subscription is never torn down just because the closure changed identity.
*/
export function useRaf(cb: RafCallback, enabled = true): void {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
if (!enabled) return;
return subscribe((dt, elapsed) => ref.current(dt, elapsed));
}, [enabled]);
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
prefers-reduced-motion, SSR-safe.
--------------------------------------------------------------------------
useSyncExternalStore rather than useState + useEffect: the server snapshot
is an explicit `false`, so the markup React renders on the server and the
markup it hydrates with agree, and the real value lands in the same commit
as hydration instead of one frame later. A useEffect version flashes the
animated variant before removing it — the exact thing the user asked not to
see.
Reduced motion is not "duration: 0". For most components here it means a
different render: the final state, a static poster, or a paused frame.
========================================================================== */
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
function getSnapshot() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/** Generic media-query hook, same SSR guarantees. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
},
() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
},
() => false,
);
}
'use client';
import { useEffect, useState, type RefObject } from 'react';
/* ==========================================================================
Element size, from one shared ResizeObserver.
--------------------------------------------------------------------------
Sized from the element rather than the window because a preview in a grid
cell resizes when the grid reflows, not when the viewport does. Reading
`window.innerWidth` is the reason so many canvas components are correct on
load and wrong after a sidebar opens.
========================================================================== */
export interface Size {
width: number;
height: number;
}
type Handler = (size: Size) => void;
let observer: ResizeObserver | null = null;
const handlers = new Map<Element, Handler>();
function ensureObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
observer ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.contentBoxSize?.[0];
const size: Size = box
? { width: box.inlineSize, height: box.blockSize }
: { width: entry.contentRect.width, height: entry.contentRect.height };
handlers.get(entry.target)?.(size);
}
});
return observer;
}
export function useResizeObserver(ref: RefObject<Element | null>): Size {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
const ro = ensureObserver();
if (!el || !ro) return;
// Seed synchronously: waiting for the first observer callback leaves one
// frame where a canvas is sized 0x0, which some drivers log as an error.
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
handlers.set(el, (next) =>
setSize((prev) =>
prev.width === next.width && prev.height === next.height ? prev : next,
),
);
ro.observe(el);
return () => {
handlers.delete(el);
ro.unobserve(el);
};
}, [ref]);
return size;
}
'use client';
import { useEffect, useRef, type RefObject } from 'react';
import { useRaf } from '@/hooks/use-raf';
import { damp } from '@/lib/damp';
/* ==========================================================================
Pointer position, smoothed, in several coordinate spaces at once.
--------------------------------------------------------------------------
Almost every cursor effect wants the same four things and computes them
slightly differently each time: client pixels, element-local pixels, 0..1
normalised, and -1..1 NDC for shaders. Producing all four from one damped
source keeps a cursor dot and the shader it drives exactly in step.
Exposed via ref for the same reason as scroll velocity: per-frame values
must not re-render.
========================================================================== */
export interface PointerState {
/** Viewport pixels. */
clientX: number;
clientY: number;
/** Pixels relative to the target element's top-left. */
x: number;
y: number;
/** 0..1 across the element. */
nx: number;
ny: number;
/** -1..1, y up. Feed straight into a shader uniform. */
ndcX: number;
ndcY: number;
/** Whether the pointer has been seen. Nothing should render before. */
active: boolean;
}
export interface UseSmoothPointerOptions {
/** Fraction of the gap remaining after one second. Lower = tighter. */
smoothing?: number;
/** Listen on this element rather than the window. */
target?: RefObject<HTMLElement | null>;
/** Follow touch drags as well as mouse moves. */
touch?: boolean;
}
function makeInitialState(): PointerState {
return {
clientX: 0,
clientY: 0,
x: 0,
y: 0,
nx: 0.5,
ny: 0.5,
ndcX: 0,
ndcY: 0,
active: false,
};
}
export function useSmoothPointer(
options: UseSmoothPointerOptions = {},
): { current: PointerState } {
const { smoothing = 0.001, target, touch = true } = options;
const state = useRef<PointerState>(makeInitialState());
// `null` means "no real pointer yet", which is distinct from a legitimate
// pointer at (0, 0) in the top-left corner.
const targetPos = useRef<{ clientX: number; clientY: number } | null>(null);
const seeded = useRef(false);
// Options the listener needs but that must not re-subscribe it on change.
const touchRef = useRef(touch);
touchRef.current = touch;
useEffect(() => {
const el = target?.current ?? null;
const node: HTMLElement | Window = el ?? window;
const onMove = (event: Event) => {
const e = event as PointerEvent;
if (!touchRef.current && e.pointerType === 'touch') return;
targetPos.current = { clientX: e.clientX, clientY: e.clientY };
};
node.addEventListener('pointermove', onMove, { passive: true });
return () => node.removeEventListener('pointermove', onMove);
// `target` is a ref object; its identity is stable for the component's
// lifetime, so this subscribes once.
}, [target]);
useRaf((dt) => {
const el = target?.current ?? null;
const rect = el
? el.getBoundingClientRect()
: {
left: 0,
top: 0,
width: window.innerWidth,
height: window.innerHeight,
};
const pointer = targetPos.current;
if (pointer === null) {
// Park at the centre until a real pointer arrives, so the first move
// eases out from the middle rather than snapping in from (0, 0). Done
// once — re-centring every frame would fight a slow first movement.
if (!seeded.current && rect.width > 0 && rect.height > 0) {
seeded.current = true;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
state.current = {
...state.current,
clientX: cx,
clientY: cy,
x: cx - rect.left,
y: cy - rect.top,
};
}
return;
}
const clientX = damp(state.current.clientX, pointer.clientX, smoothing, dt);
const clientY = damp(state.current.clientY, pointer.clientY, smoothing, dt);
const x = clientX - rect.left;
const y = clientY - rect.top;
const nx = rect.width > 0 ? x / rect.width : 0.5;
const ny = rect.height > 0 ? y / rect.height : 0.5;
state.current = {
clientX,
clientY,
x,
y,
nx,
ny,
ndcX: nx * 2 - 1,
ndcY: -(ny * 2 - 1),
active: true,
};
});
return state;
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
Document visibility, shared.
--------------------------------------------------------------------------
A backgrounded tab still runs `setInterval`, still holds WebGL contexts,
and — on some platforms — still gets throttled rAF ticks with multi-second
deltas. Anything expensive should stop when the tab is hidden and resume
when it comes back, rather than trying to catch up.
`useSyncExternalStore` with an explicit server snapshot so SSR markup and
the first client render agree.
========================================================================== */
function subscribe(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
function getSnapshot(): boolean {
return document.visibilityState === 'visible';
}
/** Assume visible on the server: the alternative flashes a paused state. */
function getServerSnapshot(): boolean {
return true;
}
export function useVisibility(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
import type * as THREE from 'three';
/* ==========================================================================
WebGL context lifecycle.
--------------------------------------------------------------------------
Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on
low-end devices). Creating the 17th does not fail — it fires
`webglcontextlost` on the OLDEST one, so an unrelated component elsewhere
on the page goes black. Same-origin iframes share the renderer process, so
isolating previews in iframes does not raise the ceiling.
The only real fix is to release contexts on unmount, and `renderer.dispose()`
ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself
alive until GC decides otherwise, which may be never. `forceContextLoss()`
is the call that actually hands it back.
========================================================================== */
/**
* Fully release a three.js renderer. Call this in the `useEffect` cleanup of
* any component that constructs a `WebGLRenderer` — the preview host drives
* unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal
* path and nothing component-specific is required.
*/
export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void {
if (!renderer) return;
// Stop the loop first: a queued frame that renders into a lost context
// throws, and the throw escapes the cleanup function.
renderer.setAnimationLoop(null);
renderer.dispose();
renderer.forceContextLoss();
const canvas = renderer.domElement;
if (canvas) {
// Zeroing the backing store releases the drawing buffer immediately
// instead of holding width×height×4 bytes until the node is collected.
canvas.width = 0;
canvas.height = 0;
}
}
/**
* Release a raw WebGL context (ogl, or hand-written GL). The extension is the
* only portable way to do it; it is absent in some headless configurations,
* hence the optional call.
*/
export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void {
if (!gl) return;
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
/** Recursively dispose geometries, materials and textures under a root. */
export function disposeObject(root: THREE.Object3D | null | undefined): void {
if (!root) return;
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
if (!material) return;
const materials = Array.isArray(material) ? material : [material];
for (const m of materials) {
for (const value of Object.values(m as unknown as Record<string, unknown>)) {
if (value && typeof value === 'object' && 'isTexture' in value) {
(value as THREE.Texture).dispose();
}
}
m.dispose?.();
}
});
}
/**
* Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and
* the cost is quadratic; on a 3x phone an uncapped full-screen shader is the
* single most common cause of a janky first paint.
*/
export function pixelRatio(max = 2): number {
if (typeof window === 'undefined') return 1;
return Math.min(window.devicePixelRatio || 1, max);
}
/**
* Whether WebGL is actually available. Some browsers expose the constructor
* but fail to create a context (blocklisted GPU, no hardware acceleration);
* a component that assumes success renders a permanently blank box.
*/
export function hasWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl2') ??
canvas.getContext('webgl') ??
canvas.getContext('experimental-webgl');
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
return true;
} catch {
return false;
}
}
API
Every parameter, its type and what happens when you change it.
function FullscreenTrianglePass(props: { fragment?: string; uniforms?: Record<string, number | readonly number[]>; speed?: number; maxDpr?: number; className?: string }): JSX.Element| Parameter | Type | Default | Description |
|---|---|---|---|
| fragment | string | = FULLSCREEN_TRIANGLE_FRAG | GLSL ES 3.00 fragment source. Must open with #version 300 es and declare an out vec4. Changing it relinks the program. |
| uniforms | Record<string, number | readonly number[]> | — | Extra uniforms pushed every frame. Numbers become uniform1f; arrays of length 2/3/4 become uniform2f/3f/4f. |
| speed | number | = 1 | Multiplier applied to u_time. |
| maxDpr | number | = 2 | Upper bound on device pixel ratio. |
| className | string | — | Merged onto the host element. |
How it works
The technique in order, not the code in order.
- 01
A single
Float32Array([-1,-1, 3,-1, -1,3])is uploaded once asSTATIC_DRAWand bound into a VAO. The vertex shader isgl_Position = vec4(a_pos, 0.0, 1.0)— the positions are already in clip space, so there is no model, view or projection matrix anywhere in the component. - 02
v_uv = a_pos * 0.5 + 0.5gives uv 0..1 at the visible corners and uv 2.0 at the two overshooting vertices; the rasteriser clips the triangle to the viewport and interpolates the varying linearly, so the surviving fragments carry exactly the uv a two-triangle quad would have produced. - 03
The context is requested with
antialias: false, depth: false, stencil: false, alpha: false— a fullscreen pass has no polygon interiors to multisample and no depth to test, and every one of those buffers costs width x height x DPR bytes plus a resolve. - 04
Uniform locations are resolved lazily and memoised in a
Mapkeyed by name, becausegetUniformLocationis a synchronous driver round-trip and calling it per frame per uniform is the single most common cause of a raw-GL component being slower than a three.js one. - 05
u_pointeris fed fromuseSmoothPointerwith the y axis flipped (1 - ny), so the shader receives the same handedness asgl_FragCoord. - 06
Resize writes
canvas.width/heightatpixelRatio(maxDpr)and callsgl.viewport, then draws one frame immediately — which is also the only draw that happens under reduced motion.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| TRIANGLE | = [-1,-1, 3,-1, -1,3] | The minimum overshoot that still guarantees full coverage. With 1.0 instead of 3.0 you get a half-screen triangle; with 5.0 the uv range widens to 0..3 and any shader that assumes uv is 0..1 at the vertices misinterprets its own derivatives. |
| MAX_DPR | = 2 | Fragment count is quadratic in DPR. A fullscreen shader at DPR 3 costs 2.25x what it costs at 2 for no perceptible gain, and it is the usual reason a hero shader stutters on a phone but not on the laptop it was built on. |
| pointer smoothing | = 0.002 | Fraction of the pointer gap remaining after one second. Raw pointer coordinates arrive in 8-16 ms bursts and pushing them straight into a uniform makes the light jitter one pixel per event. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The rAF subscription never starts. The pass still draws exactly one frame on mount and one on every resize, so you get a fully composed still image rather than a blank canvas — u_time is pinned to 0 and the pointer light sits wherever it was seeded.
Cleanup
Deletes the VAO, the vertex buffer and the program, then calls loseContext(gl) to hand the WebGL context back to the browser. The rAF subscription unsubscribes with the hook. The effect is keyed on fragment, so a source change tears the old program down before building the new one rather than leaking it.
Definition of done
The grid fills the frame edge to edge with no lighter or darker band along either diagonal, and the fine grid brightens under the cursor. Resize the container and the grid stays square with hairlines exactly one pixel wide at any DPR. Unmount and WEBGL_lose_context fires — mount and unmount twenty times and the twenty-first still renders instead of going black.
Pitfalls
- Putting a newline, a comment, or a leading space before
#version 300 es. The directive must be the first token in the source or the compile fails with "version directive must occur before anything else", and because the shader source lives in a template literal the offending newline is invisible in the diff. - Drawing a quad out of two triangles. GPUs shade in 2x2 fragment quads, so every quad the shared diagonal crosses is rasterised by both triangles and those fragments are shaded twice — a band of pure waste down the diagonal. It also breaks
fwidth/dFdxacross the seam, which shows up as a visible hairline in any analytic-AA shader. - Calling
gl.getUniformLocationinside the render loop. It is a driver round-trip, not a hash lookup; the locations are constant for the life of the program, so resolve them once and cache them. - Passing a uniform that the shader never reads.
getUniformLocationreturnsnullfor anything the compiler optimised out, andgl.uniform1f(null, x)is a silent no-op that some drivers log to the console 60 times a second. Skip on null. - Calling
renderer.dispose()or just dropping the canvas and assuming the context is released. OnlyWEBGL_lose_context.loseContext()hands it back, and Chromium kills the OLDEST live context when a page exceeds roughly 16 — so a leaked context here makes an unrelated component elsewhere on the page go black. - Throwing on a shader compile error. A syntax error in a user-supplied fragment then unmounts the entire React subtree; logging the info log and falling back to the static poster keeps the page alive and puts the actual error in the console.
Notes
- WebGL2 only. GLSL ES 3.00 gives
in/out, integer bit operators and core derivatives without an extension dance; WebGL2 is available in every browser that has shipped since 2021 except pre-15.4 Safari, which thehasWebGLguard catches. - Everything else in the WebGL category that does not use three.js is built on this exact pass — it is the shared floor, not a demo.