Devlog: Wrapping WebGL in a React Component
Infinitus has relatively simple graphics, and I felt that using a full 3D library like THREE.js would be overkill. However, I didn’t want to rely on a plain 2D canvas either—I wanted lighting effects and didn’t want to limit my possibilities by sticking with the standard canvas. So I decided to use WebGL, and the process has been interesting. Rather than scattering boilerplate everywhere, I built a dedicated <code>GLCanvas</code> component to handle setup, the game loop, input, and FPS tracking. Here’s how the journey went:
1. Defining the Component
I started by sketching out the props interface:
type GLCanvasProps = {
width?: number;
height?: number;
fps?: number; // Target frames per second
onMouseMove?: (event: MouseMoveEvent) => void;
onClick?: (event: ClickEvent) => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyUp?: (event: KeyboardEvent) => void;
onUpdate?: (deltaTime: number) => void; // For game logic
onRender?: (gl: WebGL2RenderingContext, deltaTime: number) => void; // For drawing
onInitialize?: (gl: WebGL2RenderingContext) => void; // Called once WebGL is ready
};
Then I laid out the basic JSX skeleton:
const GLCanvas: React.FC<GLCanvasProps> = ({
width = 800,
height = 800,
fps: fpsTarget = 60,
onMouseMove,
onClick,
onKeyDown,
onKeyUp,
onUpdate,
onRender,
onInitialize,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
return (
<div
className="gl-canvas-container"
style={{ width: `${width}px`, height: `${height}px` }}
onMouseMove={handleMouseMove}
onClick={handleClick}
>
<canvas
className="gl-canvas"
ref={canvasRef}
width={width}
height={height}
style={{
width: `${width}px`,
height: `${height}px`,
imageRendering: 'pixelated',
zIndex: -1
}}
/>
<div className="player-stats">
FPS {fpsCounter}
</div>
<div id="loading" className="loading-screen">
Loading...
</div>
</div>
);
};
2. Grabbing the WebGL Context
As soon as the canvas mounts, I need to grab its webgl2 context.
A simple useEffect does the trick:
const [context, setContext] = useState<WebGL2RenderingContext | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('webgl2');
if (ctx) {
setContext(ctx);
onInitialize?.(ctx);
}
}, []);
3. Building the Game Loop
The heart of the component is a stable, fixed-timestep loop. I rely on
requestAnimationFrame and a few refs to keep things in sync:
const requestRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const updateAccum = useRef<number>(0);
useEffect(() => {
const frameInterval = 1000 / fpsTarget;
function loop() {
const now = performance.now();
const deltaTime = now - lastTimeRef.current;
lastTimeRef.current = now;
updateAccum.current += deltaTime;
while (updateAccum.current >= frameInterval) {
updateAccum.current -= frameInterval;
onUpdate?.(deltaTime);
if (context && onRender) {
onRender(context, deltaTime);
}
}
requestRef.current = requestAnimationFrame(loop);
}
requestRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(requestRef.current);
}, [fpsTarget, context, onUpdate, onRender]);
4. Handling User Input
To make the canvas interactive, I attached mouse listeners to the container and normalized the coordinates to a 0–1 range:
const handleMouseMove = (event: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = 1 - (event.clientY - rect.top) / rect.height;
onMouseMove?.({ x, y });
};
const handleClick = (event: React.MouseEvent) => {
// similar calculations...
onClick?.({ /* ... */ });
};
5. Adding an FPS Counter
I hooked into the game loop to count how many frames render each second, updating state to drive a simple on-screen display:
const [fpsCounter, setFpsCounter] = useState<number>(0);
const fpsTracking = useRef({ frames: 0, time: 0 });
// inside the loop:
fpsTracking.current.time += deltaTime;
fpsTracking.current.frames++;
if (fpsTracking.current.time >= 1000) {
setFpsCounter(fpsTracking.current.frames);
fpsTracking.current.time = 0;
fpsTracking.current.frames = 0;
}
6. Pausing on Tab Change
To save resources when the tab’s hidden, I used the Page Visibility API
to cancel and resume requestAnimationFrame:
function onVisibilityChange() {
if (document.hidden) {
cancelAnimationFrame(requestRef.current);
} else {
lastTimeRef.current = performance.now();
updateAccum.current = 0;
requestRef.current = requestAnimationFrame(loop);
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
// (and don’t forget to remove the listener on cleanup)
Next comes, shaders, lighting, etc.
I am not exactly an expert at webgl (or React), so let me know if I could have done anything better here.
