← Module 3/Doom and BSP
RU
Module 3 · The 3D revolution (1993–1999)

Doom and BSP rendering

How Carmack drew "3D" on a 486 with no GPU and no z-buffer: a 2D world with heights, a precomputed BSP tree and an integer column rasterizer. Taken apart to the level of "read it and you could write it yourself".
deep~25 min🏠 lab
The gist in 30 seconds
Doom draws "3D" without honest 3D geometry and without a z-buffer. The world is two-dimensional (sectors with floor and ceiling heights), and walls are vertical. The engine does not cast a ray per pixel (that is Wolfenstein 3D) — it walks a precomputed BSP tree front to back, projects the visible wall segments (segs) into vertical screen columns (height ∝ 1/z) and clips away what is occluded using column clip arrays. All the expensive work — ordering and visibility — is moved into a build-time tree; the runtime is linear and integer-only.

Context: 1993, a budget of a couple of million cycles

The target hardware is an Intel 386/486: no graphics accelerator, no hardware z-buffer, a slow FPU or none at all (the 486SX has none whatsoever). At 35 FPS a frame gets on the order of two to three million cycles for the entire render. "Honest" 3D with per-pixel depth sorting (a z-buffer) or floating point does not fit into that. Wolfenstein 3D (1992) had already shown "3D" through raycasting, but at the cost of hard constraints: walls on a uniform grid, all the same height, no slanted walls. Carmack needed more — arbitrary wall angles, rooms of different heights, stairs, windows. The answer was not to squeeze the hardware but to change the statement of the problem and move the heavy part into level compilation.

Doom's world: a 2D plan with heights

To understand the renderer you need to know what a level is made of (this lives in the WAD):

The main consequence of two-dimensionality: a point (x, y) on the plan belongs to exactly one sector — which means the map cannot have two floors stacked above one another in the same place. That is the 2.5D constraint: the world looks volumetric (thanks to heights and projection), but geometrically it is flat, and so the visibility problem reduces to a two-dimensional one. "No room over room" is not a bug but the very "can't" that makes the algorithm cheap.

Why this is not raycasting

A common confusion: "Doom casts a ray for each column and looks for a wall". That is Wolfenstein 3D: one ray per screen column, traced across a uniform grid (DDA) to the first wall → distance → column height. The grid and the single height are consequences of raycasting itself.

Doom works the other way round: it doesn't ask "what is in this column?", it takes the actual wall segments and projects them onto the screen as vertical trapezoids, clipping away what is hidden as it goes. The "who to draw first" order comes from the BSP, not from a ray. That is exactly what lifts Wolf3D's restrictions: walls at any angle, sectors of different heights, portals (openings between sectors of different heights — windows, steps).

Projection: where the 1/z comes from

The camera sits at the origin looking along an axis. A point on a wall at perpendicular distance z (depth along the view) and world height h above eye level. The projection plane (the screen) stands at distance dproj. By similar triangles (a shared angle at the camera): the ratio of screen height to dproj equals the ratio of world height to z. dproj itself is set by the horizontal field of view and the screen width W:

dproj=W/2tan(FOV/2)

In Doom the horizontal FOV is 90° and the screen is 320 pixels → dproj = 160/tan(45°) = 160. It is convenient to introduce scale — how many screen pixels one unit of world height covers at a given depth z:

scale=dprojz

Then the screen vertical coordinate of a world height hworld (relative to the eye height viewz), and the full wall height on screen:

yscreen=centery−(hworld−viewz)·scale hscreen=(zceil−zfloor)·scale=hwall·dprojz

There is your "1/distance": column height is inversely proportional to z.

Plug in numbers. A wall of height hwall = zceil − zfloor = 128 units at depth z = 256 → scale = 160/256 = 0.625 → hscreen = 128 · 0.625 = 80 px. Step back to z = 512 and the same wall is half as tall, 40 px. All the "depth" comes from one division by z.

From a column to a whole wall. One seg is a segment with its two ends at different depths z1, z2, so it has two scales: d_proj/z₁ and d_proj/z₂. The engine steps scale linearly across screen columns between the seg's edges (which is correct: scale ∝ 1/z, and 1/z for a flat wall is linear in screen space) — where the wall is nearer the columns are taller, where it is farther they are shorter. That is how vertical columns add up into the trapezoid of an angled wall, and the perspective unfolding of the horizontal texture is a direct consequence of the same 1/z step.

And a subtle point about the textures themselves: within one column the vertical sampling is affine (the whole column is one horizontal point of a vertical wall, i.e. one z), so frac += iscale suffices. Across columns z changes — and the horizontal direction requires that same 1/z correction from the scale step above.

eye d_proj z (depth) h_screen h_world screen wall
Similar triangles: h_screen / d_proj = h_world / z ⟹ on-screen height falls off as 1/z. No 3D API — just similar triangles and a division.

BSP: construction (offline)

A BSP (Binary Space Partitioning) is built by a node builder when the level is compiled. The recursion: pick a splitting line (usually along one of the segs); segs in front of it go into one branch, those behind into the other, and those crossing the line get cut in two (important: a split really does create new segs). The recursion ends when a branch holds a convex set of segs — a subsector: inside a convex region walls cannot occlude one another ambiguously, so they can be drawn in any internal order. An internal tree node stores the splitting line (a point p and a direction (dx, dy)) and each child's bbox.

Don't confuse sector and subsector. A sector is a gameplay unit (its own floor/ceiling height, lighting, a "lava/teleport" type); a subsector is a rendering unit (a convex BSP leaf). A non-convex sector gets cut by the tree into several subsectors, so a level usually has more subsectors than sectors. The player "stands in a sector", but the engine walks and draws "by subsector".

Choosing the splitter is a compromise: the "luckier" the line, the fewer splits (fewer new segs, less memory) and the more balanced the tree (shallower traversal). These two goals conflict, and the optimal BSP (minimum splits / minimum nodes) is NP-hard, so node builders use greedy heuristics (for example, minimize the number of splits while penalizing imbalance).

BSP: traversal (runtime, front to back)

Given the camera position, the tree is walked so that segs come out strictly from near to far:

BSP traversal pseudocode
render_bsp(node):
    if node is subsector:
        draw_segs(node)              # a convex leaf — internal order doesn't matter
        return
    side = point_side(view, node.partition)   # which side the camera is on (sign below)
    render_bsp(node.child[side])              # the NEAR side first
    if bbox_visible(node.child[1 - side]):    # the far side — only if not yet occluded
        render_bsp(node.child[1 - side])

The side test is the sign of a 2D cross product (the signed area): for a splitter with point p and direction (dx, dy) and a camera at v

s=(vx−px)·dy−(vy−py)·dx

s > 0 means the camera is on one side, s < 0 the other. We descend into the near branch first → near walls come out before far ones. Since leaves are convex and the splitting planes are consistent, this order matches true depth for any camera position (the proof is in the deep end).

Clipping the occluded: why the traversal is linear

Front-to-back only pays off together with cheap rejection of what is already covered. Doom keeps two structures of screen coverage:

So every screen pixel is written at most once (no overdraw — critical, fill rate was the bottleneck), and as soon as solidsegs covers the whole screen the BSP traversal can be cut short. The result: O(n) in the number of segs, with no sorting and no per-pixel depth.

Level plan → convex BSP leaves 1 2 3 4 player traversal: 1 → 2 → 3 → 4 (near before far) — — dashed: the splitting hyperplanes
The level is recursively cut by lines into convex subsectors; given the player's position, the tree is walked from the nearest leaf to the farthest — the exact order in a single pass.

🕹 Games to play — and what to notice

One question — "how do you draw 3D on a CPU with no GPU" — with five different answers under different constraints, from a grid of rays to real 3D. For each: how it was done and what to play to see it with your hands (ordered simple to complex).

Wolfenstein 3D 1992 · raycasting · the predecessor

One ray per screen column, traced across a uniform grid (DDA) to the first wall → distance → column height. Hence the hard "can'ts": walls only on the grid, all the same height, at right angles, no slopes and no elevation changes. The renderer is straightforward, but the ceiling on scene complexity is low. Exactly the formulation Carmack left behind for the BSP.

🎮 Play: run ECWolf plus the free shareware episode. Notice: every wall is the same height, junctions are strictly on a 90° grid, and there are no steps, no windows and no looking up or down. "3D" in which the world is a flat maze of identical blocks.

Doom 1993 · BSP · 2.5D · this very lesson

Geometry is projected through a precomputed BSP tree (the whole analysis above): arbitrary wall angles, sectors of different heights, portal windows — everything Wolf3D couldn't do. But the world is still 2.5D: a point on the plan → one sector, there is no room above a room, and you can't honestly look up or down.

🎮 Play: install DSDA-Doom or GZDoom plus the free Freedoom. Try looking up and down — source ports have "mlook", but it is y-shearing (shifting the image), not real pitch → there is your 2.5D. Circle around an imp — it stays flat, swapping sprite frames (billboarding). Press Tab and you'll see the 2D plan that the BSP cuts up. The FPS counter will hit 35 (the vanilla cap, everything on the CPU).

Marathon 1994 · portal rendering · "5D space"

Bungie, a Mac exclusive, roughly a year after Doom (Dec 1994 vs Dec 1993). The renderer is not BSP-based but portal-based — and that allowed what Doom couldn't represent: room-over-room and even overlapping regions, where two or more polygons occupy the same (x, y) ("5D space" — used to build geometry impossible in plan). There is no vertical autoaim: you aim up and down by hand (in Doom it is the reverse — you can't look vertically, but there is autoaim across heights).

🎮 Play: run Aleph One (the open engine) plus the free Marathon trilogy. Notice: to hit an enemy above or below you, you aim vertically yourself — there is no autoaim (in Doom you never think about it). Look for maps with "5D space" where the geometry physically overlaps — you couldn't encode that in Doom.

Build / Duke Nukem 3D 1996 · portals without a BSP · slopes

Ken Silverman's engine: also portals, but with no offline preprocessing — the map isn't baked into a BSP, so walls move at runtime (destructibility, moving sectors) plus sloped floors and ceilings. The price is that there is no cheap front-to-back BSP order. Room-over-room here isn't a native feature but a trick: underwater sections teleport the player to another part of the map that mimics "the floor below". True ROR (TROR) only arrived in EDuke32 (2011), where sectors really do stack in the data.

🎮 Play: run EDuke32 plus shareware Duke3D. Notice the sloped surfaces and free look up and down (neither of which Doom has), then blow up a wall — dynamic geometry, impossible with a precomputed BSP. Dive underwater — that is the teleport trick standing in for a room over a room.

Quake 1996 · a real 3D BSP

The end of the arc: id throws out 2.5D. Geometry is polygonal in 3D, the BSP now partitions 3D space, and on top of it sits PVS (the Potentially Visible Set, precomputed visibility from each leaf). A room over a room, honest looking in every direction, sloped surfaces — all free, because the world is finally real 3D. The "a point → one sector" restriction is gone.

🎮 Play: run vkQuake or QuakeSpasm plus shareware Quake. Look freely up and down, find a room above a room, jump into a vertical shaft — everything physically unrepresentable in Doom. This is the line past which the 2.5D era ends.

Deep end · theory: why front-to-back is correct and what cycles have to do with itskippable

A splitting plane defines a strict order

A plane H divides space into two open half-spaces H⁺ and H⁻. For a camera in H⁺, nothing in H⁺ can be occluded by anything in H⁻ (a separating plane lies between them). So "the near side first, then the far side" is a correct partial order at that node. Recursively down the tree this gives a total strict depth order for any viewpoint. The convexity of a leaf guarantees there is no mutual occlusion inside it, so the internal order of segs doesn't matter.

Why the naive painter's algorithm breaks

The painter's algorithm sorts polygons by depth and draws far ones under near ones. But the relation "A occludes B" is not transitive: cyclic overlaps happen — A occludes B, B occludes C, C occludes A (three long polygons overlapping in a ring). Then no global sort exists — any order produces an artifact. A BSP cuts the cycle physically: the splitting plane slices the offenders into pieces, and for the pieces the order is already unambiguous. That is the original motivation for BSP (Schumacker 1969; Fuchs, Kedem, Naylor 1980).

Complexity

Splits multiply the number of segs: pathologically, up to O(n²) fragments. Good node builders produce a tree of size on the order of O(n log n) (Paterson–Yao), but construction itself, enumerating candidate splitters, is usually ~O(n²), and the optimal BSP (minimum splits/nodes) is NP-hard — hence the heuristics. Runtime traversal, though, is O(n) in the number of segs (plus a cheap bbox reject and solidsegs). All the asymptotic weight is at build time; the player never pays it.

Deep end · engineering: fixed-point, tables and the inner column loopskippable

The BSP gives the order, but Doom "flies on a 486" thanks to an integer implementation — there is no FPU to use here.

  • Fixed-point 16.16. All coordinates and steps are 32-bit integers where the top 16 bits are the integer part and the bottom 16 the fraction. The division by z for scale is integer; multiplication produces a 64-bit intermediate with a shift. No float anywhere.
  • Trigonometry tables. The angular resolution is FINEANGLES = 8192; sines and tangents aren't computed but read out of an array by an angle index (BAM, binary angle). The tables themselves are longer: finesine has 10240 entries (so that finecosine can be read from the same array at an offset), finetangent 4096. Angle to the wall → index → the value, ready-made.
  • The inner column loop (R_DrawColumn): draws a vertical strip of texture with an integer step through the texel coordinate —
# the texture column in fixed-point 16.16
frac = texturemid + (y0 - centery) * iscale
for y in y0 .. y1:
    pix[y] = src[ (frac >> 16) & mask ]   # the integer part as an index
    frac += iscale                          # step through the texture, no float

That is the engine's hot loop — simple, predictable, cache-friendly (a sequential column write). Floors and ceilings are drawn by its twin R_DrawSpan in horizontal spans (visplanes). The whole pattern: a precomputed structure (the BSP) + tables + fixed-point + a tight integer loop = a software renderer on a CPU with not one floating-point operation and no special hardware. Zero ML, zero GPU — algorithms and careful engineering.

Deep end · design: how the 2.5D constraint shaped the language of levelsskippable

The technical constraint didn't "spoil" the design — it set the aesthetic. Since there can be no room over a room and no honest looking up or down, Doom's levels are built in a particular spatial language: mazes in plan, floor and ceiling height changes instead of storeys, sections legible "from the map above", portal windows between sectors of different heights. That recognizable grammar is a direct consequence of 2.5D.

The second effect is speed. A cheap renderer gave a high, steady frame rate, and that shaped the game feel: headlong movement, dashing around arenas, dodging projectiles. Slow "honest" 3D would have made a soggier game. The constraint here is a creative driver: it narrowed the palette and handed over the signature tempo.

Analogy
A BSP tree is a decision tree of "who occludes whom", built ahead of time. The hard visibility question was answered once, when the level was "printed". In the game, from any viewing angle you don't sort the scene — you simply walk down a ready-made tree, answering the cheap question "am I left or right of this line?" at every fork — and the walls fall out in the right order immediately.
Why it matters
Doom is the archetype of "change the problem statement + precompute a structure > raw power". For an engineer building AI in games the moral is direct and practical: before reaching for a model or a GPU, ask whether there is a constraint that makes the problem cheap, and a structure you can build in advance. Carmack didn't speed 3D up — he redefined what "3D" meant until it fit the budget. That is the engineering judgment this whole course is for.
🔁 Beyond games — where this transfers
Doom's main move is "precompute the structure offline → the runtime is cheap and deterministic" (plus Carmack's meta-lesson: a well-chosen constraint unlocks a cheap algorithm). This is a general engineering pattern:

Backend / databases: indexes (B-tree, inverted), materialized views, a cached query plan — the heavy part is built once, the query is cheap.

Infrastructure / web: CDNs and caching (put it closer / compute it in advance → instant delivery); static site generation, like this course.

ML / AI: the KV cache in LLM inference is literally the Doom move: the prefix is computed once and reused (see LLM NPCs). Same family: precomputed embeddings and indexes (FAISS) for retrieval; graph compilation (torch.compile / XLA): expensive at startup, fast at runtime.

The principle: find the invariant and lift it off the hot path; and remember — a well-chosen "can't" often unlocks a cheap "how".

🏠 Lab — get a feel for Doom's renderer
An interactive lab with no code: drag the sliders and move the player, watch the lesson's formulas come alive — the 1/z projection, the front-to-back BSP order, the cost of overdraw. It runs right in the browser (and on a phone). Open the lab →
The definitive line-by-line dissection of the engine is Fabien Sanglard's "Game Engine Black Book: DOOM". What to play and how to get inside the engine are in the "🕹" and "🔧" sections below.
🔧 Run it and poke at it — on your home machine
What to play and what to notice is above (🕹). Here — for those who want to get inside the engine itself:
🔧 Poke at it (debug) ~1 h, needs a C toolchain
Clone chocolate-doom (a faithful copy of the original) or the linuxdoom sources and build them. Set breakpoints in R_RenderBSPNode and R_DrawColumn and step through a single frame — you'll see the tree traversal and the column fills from the lesson live. Turn on noclip and fly through walls while watching the automap — you'll feel what "the player stands in a sector" means.
🧪 Test it (QA eyes) ~15 min
Load a vicious slaughtermap (nuts.wad, say): thousands of monsters and huge open spaces tank the frame rate and hit the engine's limits — a visplane overflow on vanilla, that very ceiling from the lesson. Look for the seams: sprites popping as you turn, the impossibility of aiming vertically.
Checklist: watched R_RenderBSPNode traverse and R_DrawColumn fill in a debug build; tanked the frame rate with a slaughtermap (visplane overflow). The definitive line-by-line breakdown is Fabien Sanglard's "Game Engine Black Book: DOOM".
Connections
next
ECS and data-oriented design — the same line: performance is solved by data layout and a tight loop (like R_DrawColumn), not by "magic". Doom's renderer is cache-friendly for exactly these reasons.
intersection
Classical vs ML — Doom as the benchmark case of "classical is the right answer": a deterministic algorithm with provable correctness where a model would be both more expensive and worse.
intersection
Pathfinding — a BSP and a NavMesh are relatives: both cut the world into regions convenient to traverse. The BSP is for rendering and visibility, the NavMesh for pathfinding; the same spatial partitioning technique.
Questions worth asking
How does Doom draw see-through grates and dirty glass (middle textures) — and why is that a separate pass?
A solid wall writes its columns and closes them (solidsegs). But the "middle" texture of a two-sided linedef has holes: you can see farther through the bars of a grate. So it can't be treated as solid (it doesn't close the columns) and can't be drawn during the traversal — the far walls behind it haven't been drawn yet. The solution is the same as for sprites: during traversal such a seg is remembered as a masked drawseg and drawn in a separate late pass (R_DrawMasked) over all the solid geometry — with per-column transparency checks and clipping against the boundaries already written. The engine's general principle: everything "holey" (transparent walls, sprites) goes in a late pass on top of the solid stuff.
A BSP gives you the order anyway — why front-to-back rather than back-to-front (the classic painter)?
Back-to-front is correct, but it draws everything with overdraw: far walls are laid into the frame and then painted over by nearer ones. On a 486 the bottleneck was fill rate (writing pixels), so redundant writes were an unaffordable luxury. Front-to-back plus clip arrays gives early rejection: covered columns aren't drawn at all, every pixel is written at most once, and the traversal stops once the screen is covered. The same BSP order, just inverted, saves the most expensive thing.
How many segs can there be after the BSP is built, and why isn't that a paradox?
In the worst case splits multiply the primitives to O(n²) segs (each splitter can cut many walls). That sounds like a loss, but it happens once at build time and is paid in WAD memory, not frame time. The node builder greedily minimizes splits (the optimum is NP-hard), so in practice the growth is modest. At runtime what matters is not the total length but the O(n) traversal with clipping — the player only pays for what is visible.
Why is texture sampling affine down a column but not across?
A vertical screen column corresponds to a single horizontal point of a vertical wall, i.e. one z; at constant z the screen coordinate is linear in world height → the texture can be walked affinely (just frac += iscale). Across columns, scanning the wall left to right, z changes, and equal steps on screen are unequal steps along the wall (perspective compression toward the edges) → you need 1/z correction. Doom computes the horizontal texel coordinate through the angle and the scale (perspective-correct in effect), leaving cheap affine stepping to the vertical only.
What exactly makes "a room over a room" unrepresentable, and how did Build (Duke3D) get around it?
In Doom a plan point (x, y) → exactly one sector with a single (floor, ceiling) pair. Two spaces overlapping vertically at the same (x, y) simply have nowhere to be encoded — the BSP partitions the 2D plane, and a point lands in one subsector. The Build engine (Duke Nukem 3D) worked around it with "room-over-room" hacks on portals and teleport sectors (visually stacking sectors), but honest vertical topology only arrived with real 3D — Quake (1996), where geometry is polygonal and the BSP partitions 3D space.
Monsters and doors aren't in the BSP — how are they drawn and depth-sorted?
The dynamic stuff is separated from the geometry. Sprites (things: monsters, items) are collected during the BSP traversal — for every visible subsector its things are added to a list; the sprites are then depth-sorted separately and drawn after the walls, clipped against the drawsegs and clip arrays already written. Doors and lifts are animations of a sector's floor/ceiling height, and the plan doesn't change, so the BSP stays valid and is never rebuilt. The key trick: only the third coordinate (height) moves, while the planar structure of the splits is static.
BSP rendering is obsolete — why should you know the idea anyway?
For rendering, BSP is gone: a hardware z-buffer made per-pixel depth cheap and there is no need to sort geometry with a tree. But spatial partitioning lives on everywhere: BVH and kd-trees in ray tracing and collision, octrees and grids in culling, the NavMesh in navigation, PVS (the Potentially Visible Set, a layer over BSP leaves in Quake) in precomputed visibility. The specific technique goes stale; the pattern "partition space in advance so the runtime is cheap" outlives the hardware.
Further reading