The Impulse Feed

DEEP.DIVE.042 // SHIPPED // 2024.03.15

Rewriting the DOM in WebGL

THREE.JS • REACT FIBER • CUSTOM SHADERS • ZUSTAND • WEB WORKERS • GLSL • OBSESSIVE COMPULSION • THREE.JS • REACT FIBER • CUSTOM SHADERS • ZUSTAND • WEB WORKERS • GLSL • OBSESSIVE COMPULSION •

The browser is a marvel of engineering, but let's be honest: the DOM is slow. When you're trying to render 100,000 independent moving particles that also need to act as interactive UI elements, standard HTML/CSS falls apart. The garbage collector panics, the main thread locks up, and the user's laptop fan spins up like a jet engine.

I decided I didn't want to use the DOM anymore. I wanted to build a UI entirely inside a WebGL canvas, bypassing the layout engine entirely. This is the story of how I lost three weeks of my life to matrix multiplication.

"At 3 AM, I realized I was writing my own text-wrapping algorithm in GLSL. That's when I knew I had gone too far."

— Commit message, Day 14

The Architecture

To make this work, I had to essentially rebuild a minimalist version of the browser's rendering engine. I created a hierarchical scene graph where every "div" was a PlaneGeometry, and text was rendered using Signed Distance Fields (SDF) to keep it crisp at any zoom level.

FIG 01. SCENE GRAPH
ROOT_CANVAS UI_LAYER TEXT_SDF PARTICLES
INFO: Decoupling the interaction layer from the render loop via OffscreenCanvas.

Event delegation was another nightmare. Since there's only one actual DOM element (the canvas), I had to cast a ray from the mouse position through the camera frustum on every pointer move, intersecting it against bounding boxes mapped to the "virtual" DOM elements.

I used a spatial hash grid to optimize the raycasting, which kept the frame rate locked at 60fps even with 10k interactive nodes. You can read more about that approach in this excellent breakdown of spatial partitioning.

src/core/Raycaster.ts
export function intersectVirtualDOM(
  pointer: Vector2, 
  camera: Camera, 
  sceneGraph: VirtualNode[]
) {
  // Normalized Device Coordinates
  _raycaster.setFromCamera(pointer, camera);

  // The brutal reality of O(N) checking
  const hits = [];
  for (let i = 0; i < sceneGraph.length; i++) {
    const node = sceneGraph[i];
    if (node.isInteractive && checkBounds(node, _raycaster.ray)) {
      hits.push(node);
    }
  }
  
  return hits.sort((a, b) => b.zIndex - a.zIndex)[0];
}

The Breaking Point

It all worked beautifully until I tried to implement accessibility. Trying to map a complex WebGL scene graph to an invisible, screen-reader-friendly DOM tree in real-time proved to be a sync nightmare. The state drift between the visual representation and the a11y layer was unacceptable.

Ultimately, I abandoned the project. It remains a fascinating monument to over-engineering. The repo is public if you want to poke at the corpse.