· 6 min read ·

Why DOOM Maps Surprisingly Well onto CSS 3D Transforms

Source: hackernews

Niels Leenheer published a demo that renders DOOM in 3D using CSS, and the part worth dwelling on is not that someone did it, but that DOOM’s original geometry turns out to map onto CSS’s 3D model with unusual fidelity. Most “DOOM runs on X” projects are executing the original C source or a port; this one is using CSS transforms as the actual rendering primitive. That is a meaningfully different thing.

What DOOM’s Renderer Actually Does

DOOM (1993) is not a true 3D engine. id Software built what is sometimes called a 2.5D renderer: the world is defined as a flat 2D map of line segments (linedefs) partitioned into sectors, each sector carrying a floor height, ceiling height, and light level. There are no freestanding 3D objects in the map geometry. Walls are defined by those 2D linedefs extruded vertically between floor and ceiling heights.

The renderer uses a Binary Space Partition (BSP) tree precomputed at map build time. At runtime, the engine walks the BSP tree front-to-back from the player’s position, drawing wall strips into a column buffer. Because the BSP traversal guarantees front-to-back ordering and walls are opaque, you never need a z-buffer. The engine draws a wall column once and marks those screen columns as filled; subsequent walls cannot overwrite them.

What this gives you structurally: a list of flat rectangular quads (wall segments), each with a known position and orientation in 2D space, extruded to a known height. That is exactly what CSS transform-style: preserve-3d is good at managing.

The CSS 3D Transform Model

CSS 3D transforms work by placing flat elements into a three-dimensional coordinate space. The key properties are:

.scene {
  perspective: 800px;
  perspective-origin: 50% 50%;
}

.wall {
  transform-style: preserve-3d;
  position: absolute;
  width: 64px;
  height: 128px;
  transform: translateX(320px) translateZ(-256px) rotateY(45deg);
}

perspective on the container sets the distance from the viewer to the z=0 plane, controlling the strength of the projection. transform-style: preserve-3d tells the browser not to flatten children into the parent’s plane but to keep them in the shared 3D space. Then each wall element gets a transform that positions and orients it.

For a DOOM-style map, each linedef becomes a <div>. Its width matches the linedef’s 2D length. Its height is ceilHeight - floorHeight for that sector. The transform is derived from the linedef’s start point, direction, and height offset:

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);

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

The player camera position and angle drives a rotateY and translate3d on the scene container, moving the world around a fixed viewport rather than moving the viewport through the world, which is the natural inversion CSS encourages.

Where the Analogy Breaks Down: Depth Sorting

This is where things get genuinely difficult. DOOM’s BSP tree solves depth ordering for free: by walking front-to-back, walls naturally paint in the right order. CSS has no BSP tree. Its depth model for transform-style: preserve-3d uses a painter’s algorithm approximation based on the z-coordinate of each element’s center after transformation.

For a simple convex space this is fine. For anything with intersecting quads or complex concave geometry, elements will render in the wrong order. DOOM maps have plenty of non-convex sectors and wall segments that nearly intersect. The BSP tree existed precisely to make correct ordering deterministic; without it, you either recompute a depth sort every frame in JavaScript (expensive) or accept the occasional visual artifact.

The standard CSS workaround is to sort elements by their computed z-distance to the camera each frame and set z-index accordingly, but z-index interacts with stacking contexts in ways that can break the 3D rendering. Alternatively you can use the actual BSP data from the WAD file to guarantee the same traversal order as the original engine, setting z-index once at load time rather than dynamically. This works for a statically explored map but breaks the moment the player’s position changes the front-to-back ordering.

Textures and Lighting

DOOM’s wall textures are stored in the WAD as palettized 64xN pixel images. In a CSS renderer you can extract these to canvas, convert to data URIs, and apply them as background-image on each wall div with background-size: 100% 100%. Texture offsets (DOOM supports horizontal and vertical scrolling offsets per sidedef) map directly to background-position.

Light levels are more interesting. DOOM sectors have a light value from 0 to 255 that affects the brightness of all surfaces in that sector. The original engine applies this as a color multiplier during the column rendering. In CSS you get this for free with filter: brightness():

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

DOOM also does distance-based darkening, where walls further from the player appear darker regardless of sector light level. This is significantly harder in CSS because it requires per-frame recalculation of each wall’s distance. You could approximate it by computing screen-space depth from the transform matrix each frame and updating filter: brightness() dynamically, but it adds meaningful per-frame JavaScript work.

GPU Acceleration and the Compositing Cost

Every element with a 3D transform gets promoted to its own compositing layer by the browser. This is what makes 3D CSS fast for typical use cases: transforms and opacity changes happen on the GPU without triggering layout or paint. For a CSS DOOM renderer with 200-400 wall segments, you end up with hundreds of GPU layers, which has memory overhead but keeps the actual transform math on the graphics card.

The practical performance ceiling depends on how many elements move each frame. If only the camera transform changes (one transform on the scene container), the walls themselves are static GPU layers and the frame cost is low. If you start animating individual walls or updating filter brightness values per-frame, you push work back to the CPU or trigger layer recomposition.

This is the core architectural insight behind the demo: keep the walls static, move the camera. Let the GPU handle the perspective projection. The original DOOM renderer did the opposite: the camera was the fixed origin and the world was sampled relative to it, but the output is the same.

The Tradition This Belongs To

The “DOOM runs on everything” phenomenon is partly a meme and partly a genuine stress test tradition, like port scanning but for game engines. A pregnancy test, a receipt printer, an ATM: if it has a display and a processor, someone will try to run DOOM on it. Those ports are almost always either running the original C source or a derivative.

This CSS version is different in kind. It is not executing the DOOM engine; it is using DOOM’s map data as input geometry for a completely separate renderer built from CSS primitives. The original CSS Quake experiments from Keith Clark a decade ago showed the technique was feasible for simple geometry. What has changed is that modern browsers handle hundreds of preserve-3d layers with much less overhead than they once did, making a full DOOM map tractable.

The interesting question the demo raises is not whether CSS can render DOOM, but what the exercise teaches about the shape of DOOM’s geometry. The fact that a flat 2D map of extruded rectangles drops almost directly into CSS 3D transforms says something about how constrained and regular DOOM’s world actually is. It was designed to be rendered with 1993 hardware; the constraints that made it possible then turn out to be the same constraints that make it mappable to a declarative layout engine thirty years later.

The rendering artifacts, the depth sorting limitations, the approximated lighting: these are not failures of the CSS approach. They are documentation of exactly where the original DOOM renderer did the hard work that CSS has no equivalent for. The demo is, among other things, a readable specification of what a BSP tree buys you.

Was this interesting?