· 6 min read ·

The Geometry That Makes CSS DOOM Rendering Work

Source: hackernews

There is a CSS demo that renders DOOM maps in 3D using nothing but HTML divs and CSS transforms. The result holds up better than expected, and the reason is not that CSS is secretly a game engine. It is that DOOM was never a true 3D engine either, and the geometric model both systems use turns out to be the same one.

Niels Leenheer’s article on the technique walks through how it is built. What it opens up is a longer conversation about what both systems actually are under the hood.

What DOOM’s Renderer Actually Is

DOOM (1993) is a 2.5D engine. The world is encoded as a flat 2D map of line segments called linedefs, grouped into sectors. Each sector carries a floor height, a ceiling height, and a light level. There are no freestanding 3D objects in the geometry. Walls are linedefs extruded vertically between floor and ceiling.

At render time, the engine walks a precomputed Binary Space Partition tree front-to-back from the player’s position, rasterizing wall strips into a column buffer. Because BSP traversal guarantees front-to-back ordering and walls are opaque, the renderer never needs a z-buffer. Once a screen column is fully covered, it is never revisited.

The output of that process is a set of flat rectangular quads, each with a known world-space position, orientation, and height. This is almost precisely what CSS transform-style: preserve-3d is designed to handle.

What CSS preserve-3d Actually Does

When you apply transform-style: preserve-3d to a container, the browser positions child elements in shared 3D space rather than flattening them into the parent’s 2D plane. Combined with a perspective value on the container, this constructs a projection matrix and renders each child at its computed 3D position.

.scene {
  perspective: 800px;
  perspective-origin: 50% 50%;
  transform-style: preserve-3d;
}

.wall {
  position: absolute;
  width: 64px;
  height: 128px;
  transform: translateZ(-256px) rotateY(45deg);
}

The perspective value sets the distance from the viewer to the z=0 plane. Values in the 400px to 800px range tend to produce natural-feeling first-person projection.

What CSS does not do is maintain a z-buffer. Per the CSS Transforms Level 2 specification, depth ordering in preserve-3d contexts uses a painter’s algorithm approximation: elements are sorted by the z-coordinate of their center point after transformation and painted back-to-front. For convex, non-intersecting geometry this produces correct output. For anything else, it fails.

This is structurally identical to what DOOM’s BSP tree solves, just at a different level of abstraction. BSP traversal is the correct precomputed solution to the painter’s algorithm problem for DOOM’s specific geometry. CSS applies a simpler approximation that works when geometry is well-behaved and breaks under the same conditions that would defeat a naive painter’s algorithm without BSP.

The Transform Math

Mapping a DOOM linedef to a CSS wall element means placing it at the correct 3D position, rotating it to the right orientation, and sizing it correctly. The math follows directly from DOOM’s coordinate system:

const dx = end.x - start.x;
const dz = end.y - start.y;  // DOOM uses Y for depth
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz) * (180 / Math.PI);

const wallHeight = sector.ceilingHeight - sector.floorHeight;

wall.style.width = `${length}px`;
wall.style.height = `${wallHeight}px`;
wall.style.transform = [
  `translateX(${start.x}px)`,
  `translateZ(${start.y}px)`,
  `rotateY(${angle}deg)`,
  `translateX(${length / 2}px)`
].join(' ');

CSS transform operations apply right-to-left. The centering translateX at the end shifts along the already-rotated local axis, not the original world axis. Getting this order wrong places every wall in the wrong position, and the error is the kind that is obvious in the output but not immediately obvious from the code.

The player camera is handled by transforming the scene container rather than updating individual wall elements. The world moves around a fixed viewport. This is the natural architecture CSS encourages, and it matches how DOOM conceptually operates: the renderer is the fixed origin, and the world is sampled relative to the player’s position.

Textures and Lighting

DOOM wall textures are palettized images stored in the WAD file. In the CSS renderer, they are extracted and applied as background-image on each wall div, with DOOM’s per-sidedef texture offsets mapping to background-position. The texture alignment requires careful handling of DOOM’s coordinate conventions but is architecturally straightforward.

Lighting is harder. DOOM sectors carry a light value from 0 to 255, applied as flat shading across all surfaces in the sector. The CSS translation is direct:

.wall[data-light="160"] {
  filter: brightness(0.63); /* 160 / 255 */
}

DOOM also applies distance-based darkening, so walls further from the player appear darker regardless of sector light. Replicating this requires computing screen-space depth from the transform matrix per frame and updating filter: brightness() dynamically. This adds meaningful per-frame JavaScript work and pushes the browser out of its fast compositing path, so the implementation tends to drop it or approximate it with a simpler heuristic.

GPU Architecture and Static Geometry

Every element with a 3D CSS transform gets promoted to its own compositing layer in the browser. A full DOOM level carries 200 to 400 wall segments, which means hundreds of GPU layers. On modern hardware this is tractable. On hardware from 2013, when Keith Clark built his CSS FPS experiments that established the prior art for this approach, it was not. The demo is feasible now in part because browser compositing has gotten substantially more efficient over a decade.

The key architectural decision is keeping wall elements static and moving only the scene container. If walls are static GPU layers and only the camera transform changes each frame, the per-frame cost is low: the browser composites static layers with a single matrix operation on the GPU. If individual walls are updated per frame, cost scales with wall count.

This mirrors DOOM’s approach at a conceptual level. The BSP tree is precomputed at map build time. At runtime, the engine walks and samples it; the geometry does not change. CSS handles static geometry efficiently and dynamic geometry poorly, for the same underlying reason.

Where It Falls Apart

The depth-sorting artifacts in the CSS DOOM demo are documentation of what the BSP tree buys you. Without BSP, CSS falls back to center-z painter’s algorithm sorting, which produces incorrect draw order for non-convex sectors and for player positions where wall center-points are a misleading proxy for actual depth. These are exactly the conditions DOOM’s original renderer invested computation to handle correctly.

There is no straightforward way to inject BSP ordering data into CSS’s depth sort. The spec does not expose the sorting order as a controllable variable. Workarounds involve dynamically setting z-index based on computed depth, but z-index interacts with stacking contexts in ways that break 3D rendering, and recalculating it every frame is expensive.

The demo is, among other things, a readable specification of what a BSP tree buys you. The artifact locations are a map of the problem the original engine solved.

What This Is and What It Is Not

This is not DOOM running in a browser. The DOOM engine is not executing. The WAD file is parsed in JavaScript, the geometry is extracted, and that geometry is fed into a completely separate renderer built from CSS primitives. Caniplaydoom.org tracks ports of the actual DOOM engine to unusual platforms, from pregnancy tests to ATMs. This project is different in kind: it uses DOOM’s map format as a convenient source of 2.5D geometry for a CSS renderer.

DOOM’s WAD format works well as input because it describes exactly the kind of geometry CSS preserve-3d handles: flat extruded quads in a 2D world, with per-surface attributes for texturing and lighting. Both systems converged on the same geometric model from different directions and for different reasons. DOOM arrived there because it was the richest geometry a 1993 PC could render in real time. CSS arrived there because flat rectangular elements, rotated and translated in 3D space, cover the cases that mattered for web UI animation.

The overlap was not planned, but it is not accidental either. It is the shape of the problem both systems were built to solve.

Was this interesting?