Full-stack .NET / Angular dev with a DevOps streak on Azure Cloud. Also fluent in Flutter + Firebase. I build products, ship them, and keep them healthy.
A DOOM-style engine in the browser, with three backends
S
super-dev ✓
2 days ago • 11 min read • 1.4k reads
This portfolio hides a DOOM-style FPS — a real 3D engine, in the browser, without Three.js or WebGL . No graphics library: a *software renderer* written by hand that computes every pixel in TypeScript, exactly as id Software did in 1993. The modern twist: the same pixel comes out of three interchangeable backends — a single-thread CPU, a worker pool, and a WebGPU compute shader — and a test proves they all render the same image .
BSP rendering, 1993-style
The map is compiled into a BSP (Binary Space Partitioning) tree: a recursive split of the plane that gives, for any camera position, the exact order of walls from nearest to farthest. Rendering is a simple *front-to-back* traversal: walk the tree, project each wall segment into a vertical column of the screen, texture it, and a per-column z-buffer stops anything already hidden. Zero over-draw, zero object sorting.
TypeScript
1// one wall wins per screen column x — the nearest unoccluded one
8zbuffer[col.x] = col.depth; // this column is resolved for good
9 }
10 });
11}
The projection is DOOM's: a fixed focal distance, a column height inversely proportional to depth, and a vertical *shear* for looking up/down. Floors and ceilings are filled in horizontal bands, each row carrying its own world-to-screen scale.
One backend isn't enough
This renderFrame is pure : input data, an output pixel buffer, no browser API. It's the reference — and the last resort. Above it, two accelerators.
The first splits the screen into row bands distributed across a worker pool , all wired to the sameSharedArrayBuffer : the framebuffer is shared without copying. Eight threads, ~4.5 ms per frame, a sustained 120 fps. The entry price: shared memory requires COOP/COEP headers on *all* responses, otherwise SharedArrayBuffer is unavailable and the worker renders a black canvas.
TypeScript
1// each worker renders its band [rowStart, rowEnd) into the shared framebuffer
The second pushes everything onto the GPU in compute . The CPU no longer rasterizes there: it *records* the BSP traversal as per-column command buffers (wall spans, glass layers, sprites), and a WGSL shader executes them in parallel before reading the result back into the framebuffer. No swap-chain, no WebGL canvas: pure compute, an image returned.
The same pixel, proven
Three render paths means three chances to diverge. The guarantee rests on a test: render the same scene through the CPU renderer and the WebGPU backend, into two buffers, then compare them.
The GPU computes in f32 , the CPU mixes integers and floats: agreement is *within a tolerance*, not bit-exact. A Playwright test drives this diff on a real browser and requires less than 2 % of pixels out of tolerance. Where navigator.gpu doesn't exist — any *headless* CI browser — it skips rather than naively comparing the CPU against itself. Parity isn't a wish; it's an assertion that runs.
Degrade gracefully, never a black screen
The stack is a fallback cascade. WebGPU available? Render on the GPU. Otherwise, the worker pool. No COOP/COEP, so no SharedArrayBuffer ? The single-thread renderFrame runs on the main thread — slower, but universal. Every browser gets an image; the most capable gets 120 fps. The software renderer is never a consolation prize: it's both the foundation that runs everywhere and the oracle that keeps the GPU honest.
Hand-writing a rasterizer in 2026 has nothing nostalgic about it: it's what makes the three backends pixel-comparable . The CPU defines the truth, the GPU accelerates it, and a test refuses to let them diverge.