The brick-atlas page table
Per-chunk LOD · single-pass GPU volume raymarch
§0Read me
A volume too large or too detailed to hold at full resolution is stored as a pyramid of levels, each cut into fixed-size chunks (here 32³ voxels). A view wants different chunks at different levels — fine up close, coarse far away. The renderer must sample all of them in one raymarch. It does that with three cooperating textures. Everything below is a 2D slice of what are really 3D structures, drawn small so the whole mapping fits on screen; the algorithm shown is exactly the one in the shader.
Selection
What the view asked for — a quadtree cut of the finest grid into chunks at mixed levels. Solid = brick resident; hatched = still loading.
Page table
One RGBA8 texel per finest cell (pageDims³). Each stores the atlas slot + level of the brick covering it. Tinted by the level it currently resolves to.
Atlas
Resident bricks packed into fixed slots of B³ voxels (slotsPerAxis³ of them). Filled as loads land; freed by LRU. Tinted by the level of the brick held.
Hover any cell in A, B, or C to trace the mapping. Drag the timeline left to watch bricks stream in — coarse stand-ins sharpen as finer data lands.
§1The three structures
The pyramid (panel A) is the source data: level 0 is full resolution, each coarser level halves each axis. A chunk at level L covers 2ᴸ finest cells per axis — its footprint on the finest grid. A view's selection is a quadtree cut: a set of chunks at mixed levels that tiles the finest grid exactly once.
The atlas (panel C) is one big 3D texture divided into a grid of equal slots. Every resident brick — whatever its level — is upscaled to a full B³ and copied into one slot with copyTextureToTexture. Levels don't get their own textures; they all live together, packed. A free-list hands out slots; an LRU frees them under pressure.
The page table (panel B) is the index that ties them together: a dense low-res texture with one texel per finest cell. Each texel answers a single question the ray needs at every step — which atlas slot, at which level, covers this position?
§2The texel encoding
Each page-table texel is four bytes — it fits the answer in an RGBA8:
(0 = empty)
Storing level + 1 means a zeroed texel reads as empty for free — no sentinel needed. The level is what lets one slot serve cells of different sizes: from it the shader recovers how many finest cells share the brick. (In these 2D panels the slot is drawn as (col, row); the real atlas is 3D and uses all of R, G, B.)
§3The lookup, per ray step
The volume is marched once, in normalised array space where the box is axis-aligned. At each sample position p ∈ [0,1]³:
// 1. read the page table at p — one texel = one finest cell let pt = texture3D(pageTex, p); let mask = pt.a * 255 >= 1; // occupied? (0 ⇒ empty) let level = pt.a * 255 - 1; // the resolved brick's level let slot = pt.rgb * 255; // its atlas slot // 2. recover the brick-local coord from the level let cells = exp2(level); // finest cells this brick spans let base = floor(floor(p * pageDims) / cells) * cells; let local = (p * pageDims - base) / cells; // 0..1 inside the brick // 3. sample the atlas — the single texture read that returns density let density = texture3D(atlasTex, (slot + local) / slotsPerAxis).r * mask;
The elegance is in step 2: cells = 2ᴸ. A coarse brick covers many finest cells, so many adjacent texels point at the same slot; the level tells the shader to spread the brick's 0..1 range across all of them. A fine brick has cells = 1 and maps one-to-one. Because the local coordinate is derived from the stored level, a coarse brick standing in for a fine cell is sampled exactly — which is precisely what makes the fallback below correct.
§4Mixed LOD in one pass
Because every level lives in the same atlas and is addressed the same way, a selection that mixes levels (panel A: fine in one corner, coarser elsewhere) needs no per-level passes and no blending of overlapping bricks. rebuildPageTable() walks the selected chunks and, for each, stamps its finest-cell footprint with one brick's (slot, level). The ray reads whichever texel it lands on and samples once. Cost is one texture-lookup pair per step, regardless of how many levels are on screen — no overdraw, and a trivially correct depth write (one fragment per pixel).
§5The fallback walk-up
Streaming is where a naïve page table fails: a not-yet-loaded chunk is a transparent hole. The fix is a walk up the pyramid. When filling a selected cell, residentOrAncestor() takes the finest resident brick covering it — the chunk itself, or the coarsest resident ancestor:
while (level < levelCount) if (resident.has(key(level, x, y, z))) return that brick; level++; x >>= 1; y >>= 1; z >>= 1; // climb one level
Its (slot, level) go into the fine cell's texel — and because step 2 derives the local coordinate from that level, the coarse ancestor is sampled at the right sub-region. The result is blur that sharpens: a region shows up coarse immediately and refines in place as finer bricks arrive, never blinking through a hole. Drag the timeline left to see it — the top-left quadrant falls back to the coarse floor, then snaps to full detail as its level-0 bricks land.
- The coarse floor never leaves. The coarsest full-chunk-grid level is always requested and never evicted, so the walk-up is guaranteed to find something — a hole is impossible once the floor is resident.
- Retain-on-refine, for free. Eviction protects any brick the page table currently references — including an ancestor standing in for a refining region — so a coarse stand-in survives exactly until its finer replacement lands.
- The floor is never drawn directly. The rebuild iterates only the selected chunks; the floor is reached solely through the walk-up, so it can't overwrite a finer resident chunk.
§6How it scales
The design is bounded by the view, not the volume. Every structure here is sized to the working set the camera can see, so cost tracks the visible frustum — not the whole image, which may be far larger than memory. The demo runs a single fixed ceiling; below are the walls it meets at very large sizes and the planned way through each. Crucially, the fallback walk-up (§5) means overflowing any of them degrades quality, never correctness: the image gets coarser, never holed or wrong.
Today — a fixed 12³ = 1728-slot atlas (a 384³ R8 texture, ≈ 54 MB). A whole-volume view at full detail that needs more bricks simply thrashes the LRU; the LOD fallback keeps it correct, only blurrier.
Plan — hold the resident set to the frustum working set (via the resource ceiling below) rather than the whole volume, and grow to a larger / dynamically-sized atlas.
Today — select() returns every in-frustum chunk at the desired level. A whole-view-at-finest demands one chunk per finest cell — tens of thousands at 2048³ and up — which then drives every downstream cost.
Plan — the resource ceiling: selectWithinBudget() raises a global level floor and retries until the working set fits (degrade-to-fit, ADR-0008 §5), reporting “out of memory” only as an honest floor. The renderer doesn't drive this yet — it leans on the fallback to stay correct past capacity.
Today — one texel per finest cell across the whole grid, re-uploaded whole on every change. Its size grows with the finest resolution: 2048³ ⁄ 32³ = 64³ ≈ 1 MB, but 8192³ → 256³ ≈ 67 MB, where the whole-texture re-upload starts to dominate.
Plan — a sparse / hierarchical (multi-resolution) page table that addresses only resident regions, so its size tracks the working set rather than the whole finest grid.
Today — rebuildPageTable() clears and re-walks the whole selection on every selection change and every brick commit. A fresh stream of N bricks is therefore ~O(N²) — fine at demo scale, quadratic at large N.
Plan — coalesce commits per frame and update only the dirty cells. Paired with the sparse table above, that removes both the whole-texture re-upload and the per-commit full walk.
None of these is a correctness limit. Because a missing fine brick resolves to its coarsest resident ancestor, the honest failure mode under pressure is a coarser image that sharpens as capacity frees — the same “blur that sharpens” the timeline above shows. Turning that graceful degradation from best-effort into bounded memory is exactly what the resource ceiling adds.