Quake: real 3D — BSP, PVS and baked light
1/z under integer work (once every 16 pixels). Honest 3D, paid for up front.
Real 3D versus Doom's 2.5D
Doom and Quake both rest on a BSP — but in a different number of dimensions, and that changes everything.
| — | Doom (1993) | Quake (1996) |
|---|---|---|
| BSP | 2D: splitting lines in the plan | 3D: splitting planes in the volume |
| Geometry | sectors with floor/ceiling heights | arbitrary convex brushes |
| Room over room | impossible | possible |
| Looking around | no camera pitch (vertical autoaim) | free look up and down + movement in 3D |
| Textures | vertical stretch, no perspective correction | perspective-correct UVs |
| Lighting | brightness per sector | baked lightmaps (a texture of light) |
The data structure is the same — a binary space partitioning tree — but in 3D it cuts the volume with arbitrary planes into convex cells (the tree's leaves). One room usually fragments into several convex cells. The BSP here works on three fronts: draw order, collision and visibility storage (PVS).
PVS — precomputed visibility
Quake's main idea: visibility isn't computed in the frame, it is compiled in advance. Between adjacent cells QBSP automatically places portals — "windows" through which one cell can see another. These portals exist only during compilation. The VIS tool runs a region-visibility algorithm over them (Seth Teller, 1992; Carmack implemented it in 1996) and stores, for each cell, a bit vector: which cells are potentially visible from at least some point inside it. That is the PVS — the Potentially Visible Set (plus an analogous PAS for sound).
At runtime the engine takes the leaf the camera is in, reads its PVS bitmask and draws only the flagged cells. Instead of walking the whole map, one lookup: out of a bit vector as long as the map's leaf count (hundreds to thousands), a tight cell has a handful to a few dozen bits set. After that come ordinary frustum culling (what is in view) and the BSP order.
Baked light: lightmaps
The LIGHT tool casts rays from all the map's light sources offline and writes the result into a lightmap — a separate low-resolution texture of light (roughly one sample per large block of surface; lightmaps are packed into atlases such as 128×128 covering many surfaces). At runtime a pixel = base_texture × lightmap. This decouples detail from lighting: geometry and pattern live in a hi-res texture, light in a lo-res lightmap.
To avoid repeating the multiply every frame, Quake caches the result in a surface cache: assemble a lit surface once and reuse it while it stays in frame. LIGHT's -extra flag takes 4 samples per texel and averages them — softer shadow edges. The price of baking is that light is static: you can't move a lamp or a wall (a few dynamic lights — muzzle flashes — were layered on separately and cheaply).
Perspective-correct textures and the division trick
Honest 3D demands perspective correction: interpolating u,v linearly in screen space along a receding polygon warps the texture. The correct approach is interpolating u/z and 1/z (which are linear in screen coordinates) and then dividing:
The problem: a division on a Pentium (FDIV) takes up to ~39 cycles, which is unaffordable per pixel. Michael Abrash's solution: compute exact u,v once every 16 pixels and interpolate linearly in between (the error over such a span is invisible). And the main trick is overlap: the FDIV for the next 16-pixel span is launched at the start of the current one; while the FPU spends 30+ cycles dividing, the integer U/V pipes draw the current 16 pixels. The division comes out "free" — hidden under the drawing, ~7.5 cycles per pixel.
A bridge to Mode 7. Remember the SNES's per-line perspective? There the depth is constant along a line → one 1/z division per line. In Quake a polygon recedes and z changes along a span → the division is needed periodically (every 16 pixels). Mode 7 is the degenerate case of "N = the whole line width".
Deep end · theory: why the PVS is "potentially" and why that is safeskippable
Region visibility through portals
VIS solves not "is a point visible from a point" but "is a cell visible from any point of another cell". A line of sight has to pass through a sequence of portals; the problem reduces to whether a straight line exists that pierces every portal in the chain (via separating/clipping planes between portal edges). If even one such line exists, the target cell goes into the PVS.
Conservativeness
The PVS is conservative: it may flag extra cells (ones not actually visible from the current point or angle) — that only costs extra draws. But it never misses a genuinely visible cell — otherwise holes would appear in the world. Exact per-pixel visibility would depend on camera position and angle and would cost per-pixel work in the frame — precisely what precomputation eliminates. So a coarse-but-safe per-region approximation is stored instead of an exact per-point one.
Perspective correction: where the warping comes from
A screen coordinate ∝ x/z. Interpolating u linearly across the screen implicitly assumes z is constant — on a receding polygon that makes the texture "swim". What is linear in screen space is u/z and 1/z; those get interpolated, and the division at the end recovers the true u. The span length between exact divisions is an "error vs cost" compromise: Quake's 16 pixels are chosen so the error is invisible while the FDIV just keeps up with drawing the span.
Deep end · engineering: the map compiler and the runtime pipelineskippable
- Three tools, in sequence:
QBSP(the BSP tree + the.prtportals) →VIS(the PVS from the portals; on 1996 maps this could take hours) →LIGHT(ray-traced light into lightmaps). A map is a "compiled binary" of a level. - The runtime culling cascade: PVS (coarse, precomputed) → frustum cull (by field of view, in the frame) → BSP order (back-to-front / front-to-back with an edge list). Each layer removes its share.
- Surface cache: a lit surface (texture × lightmap) is assembled once and cached; redraws take the ready result. Memory traded against computation.
- Abrash's inner texturing loop: the next span's FDIV overlaps the integer drawing of the current one on the Pentium's paired pipes → ~7.5 cycles/pixel (that is for the 16-pixel spans on the ASM path; the default C path of the shipping build divides every 8 pixels). This doubled the frame rate over a naive version.
- Software rendering first. Quake shipped with a software rasterizer; GLQuake/VQuake (hardware acceleration) came later. A hardware GPU removed the manual FDIV trick, but PVS, BSP and lightmaps stayed (under GL the surface cache is no longer needed — texture and lightmap are combined on the fly).
Systems / databases: the PVS is a materialized view / precomputed index: the expensive answer is computed offline and the runtime is a lookup. The BSP is a spatial index, kin to the k-d tree, BVH and R-tree: partition space so you don't scan everything. Conservative visibility = "better extra than missing" (as in a compiler's alias analysis).
ML / AI: "bake offline, serve cheap" is precomputed embeddings / a feature store (heavy features computed ahead of time, inference is a lookup) and ANN indexes (HNSW/IVF) — a spatial index over embeddings, the same BSP-over-a-world: partition the space so you don't do a full scan. The KV cache is a surface cache for a transformer: the computed prefix state gets reused. A division every 16 pixels = chunking / gradient accumulation: amortize the expensive operation along an axis.
Performance: a lightmap is memoization of an expensive computation into a texture; the surface cache is reuse across frames. "Don't compute in the hot loop what doesn't change".
The principle: decide in advance everything that is invariant at runtime; let the runtime merely select from what was precomputed and amortize what it has to compute.
~). r_speeds 1 gives you counters of drawn surfaces and edges. Now r_novis 1 — you have turned the PVS off: the engine starts drawing everything in the frustum, the counters spike and the frame rate drops; r_novis 0 brings it back. Then r_fullbright 1 (needs developer 1 / cheats) — you have turned the lightmaps off: the atmospheric lighting vanishes and the world goes flat.🕹 Games to play — and what to notice
From the 2.5D predecessor to real 3D and what grew on top of it. For each: what is inside and what to play or type into the console.
A 2D BSP, sectors with heights, no camera pitch and no rooms over rooms. The same precomputation technique, but in the plane — an excellent contrast to Quake.
🎮 Play: in Doom, try to find a room directly above another — there isn't one; vertical shooting runs on autoaim. That is the 2.5D ceiling. The full analysis is in the Doom and BSP lesson.
From the camera's cell, only its PVS gets drawn. You can switch that off and see the cost.
🎮 Play: in QuakeSpasm type r_speeds 1, then r_novis 1 — the surface counter jumps several times over and the frame rate falls: the engine draws the whole potentially-in-frame world with no visibility culling. r_novis 0 and the PVS starts cutting again. You are literally toggling precomputed visibility.
Arbitrary geometry and full freedom of view — what Doom couldn't do.
🎮 Play: in Quake, find a balcony above a hall (a room over a room — impossible in Doom) and look straight up and straight down. Free camera pitch plus perspective-correct walls at any angle = real 3D, not pseudo.
All the "atmosphere" of the levels is static lightmaps over the textures.
🎮 Play: with developer 1, type r_fullbright 1 — the shadows and light gradients vanish and the world becomes uniformly bright and flat. Toggle it on and off and you'll see how much mood a single baked layer of light carries.
GoldSrc (Half-Life) and Quake II stand on the same BSP+PVS+lightmap, adding colored light and more dynamism. The architecture of visibility and baked lighting survived to the end of the 1990s almost unchanged.
🎮 Watch: Half-Life has the same BSP tree and PVS; developer 1 and the equivalent of r_speeds will show the same culling mechanics. Compare HL's colored light with Quake's monochrome lightmaps.
1/z division once per line (constant depth). Quake divides per pixel or span, because depth changes along a polygon.If the PVS already says what is visible, why also do frustum culling and a BSP traversal?
Why is the PVS "potentially" visible rather than "definitely"?
Doom uses a BSP too — what is the fundamental difference from Quake?
Why bake light if Doom already changed sector brightness "dynamically"?
Why do perspective correction every 16 pixels rather than per pixel or once per span?
- Michael Abrash, "Graphics Programming Black Book" — the Quake chapters: the surface cache, perspective texturing, the lighting model (free online).
- Fabien Sanglard, "Game Engine Black Book: Quake" + his breakdown of PVS/portals and the QBSP/VIS sources.
- Seth Teller, "Visibility Computations in Densely Occluded Polyhedral Environments" (1992) — the portal/PVS theory Carmack implemented.
- id Software — the Quake sources (GitHub): QBSP, VIS, LIGHT, the BSP traversal.
- Module 3 (
03-3d-revolution-1993-1999.md), the Quake and Lightmaps sections.