Creation Wasteland Logo

Infinitus: Wrapping WebGL in a React Component

Category: Game Development

Published on 5/30/2025
By creationwasteland

SHARE:

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.


sour old man

Like what I said? Hate what I said?
Tell me what you think! kameron@creation-wasteland.com

SHARE: