voxolith

Storage

How the renderer stores voxels: 8³ bricks with per-brick palettes, uniform bricks, a two-level index in one buffer, and editing a brick at a time.

A dense grid pays for air. A forest scene is about a tenth solid, so nine tenths of a dense 3D texture of it would hold nothing, and at fine voxel sizes a dense valley does not fit in memory at all. The renderer therefore keeps the world as bricks: 8³ blocks of voxels, stored only where something is. This is the standard GPU voxel layout (the leaf size of both GVDB and NanoVDB).

Voxel values

A world voxel is 8 bits: 0 is empty and 1 to 255 are palette slots. The world's palette is 256 RGBA entries (WORLD_SLOTS), passed as a Float32Array of 1024 floats in RenderScene.palette and replaced with renderer.updatePalette(palette). Instances draw from palettes of their own after these 256 (see Instances).

Dense arrays, where they exist, are indexed x + y * sx + z * sx * sy, Y-up.

Bricks

4-bit bricks. Each brick carries its own small palette of 15 entries, and each voxel is a 4-bit index into it (nibble 0 means empty). Measured over a 4 × 4 forest, no 8³ brick anywhere held more than 16 distinct values and 98% held 8 or fewer, so 4 bits is almost always enough, and halving the voxel payload is worth more than the palette costs. A brick is 64 words of voxels plus 8 words of palette.

8-bit bricks. Content that needs more than 15 values in one brick (a detailed .vox model, a Minecraft region) falls back to a second tier that stores 8 bits per voxel and no palette, 128 words a brick. This is the same idea as Minecraft's chunk sections, which grow bits per block as a section's palette fills. stats().wide counts them.

Uniform bricks. A brick holding one value throughout (solid ground, the inside of a wall) is kept in its index entry alone, with no payload at all.

A voxel read on the GPU is: top level, index block, brick entry, 4-bit local index, the brick's palette, a global palette slot. A uniform brick answers at the entry.

The two-level index

A flat per-brick index costs 4 bytes per 512 voxels whether they hold anything or not, which at centimetre voxels is gigabytes for a valley. So bricks are grouped into blocks of 8³ brick entries (64³ voxels, TOP_B), and a top level holds one entry per block. Only blocks with something in them exist.

An empty world of 12800 × 2048 × 12800 voxels costs its top level, about 5 MB; what is resident costs its surface. The nulls double as the empty-space skip for rays: a null top entry (with no instances in it) skips 64 voxels, a null brick 8. Ray walks look a brick up once as they enter it and reuse it for every step inside.

Every index table sits in one storage buffer, in regions: the top levels of the world and of each model, index blocks, the per-cell instance lists, instances, models, posed parts and the sub-cell tables of static instance cells. That keeps the pass within the default limit of 8 storage buffers per shader stage. The world's top level is mirrored in a small 3D texture as well, since every ray step starts there and texture reads cache better.

Keep the constants in sync

BRICK_B (8) and TOP_B (64) in src/brick.ts are duplicated as the WGSL constants COARSE_B and TOP_B in src/shaders/grid.wesl. Change both or neither.

Filling and editing the world

The renderer's bricks are the source of truth. There are two ways to put voxels in.

From a dense array

Pass data in the RenderScene and the renderer builds its bricks from it. After changing the array, call updateVoxels(data, box?): with a DirtyBox (inclusive voxel coordinates) only the bricks overlapping it are rebuilt and uploaded. data is always the full-grid array; the box selects a region of it.

data[x + y * size.x + z * size.x * size.y] = 5;
renderer.updateVoxels(data, { x0: x, y0: y, z0: z, x1: x, y1: y, z1: z });

This suits small scenes, effects and the viewer. A dense copy of a large world is exactly what the brick layout exists to avoid.

A brick at a time

Omit data for an empty world and write it through renderer.edit(box, fill). fill receives each overlapping brick's current 512 voxels (local index lx + ly * 8 + lz * 64) and its world origin, mutates what it wants and returns whether it changed anything. Touched bricks are re-encoded and uploaded before edit returns. Nothing reads back from a dense array, so a world can be built and edited without one ever existing.

const renderer = await createRenderer(gpu, { size: SIZE, palette: allocator.buildPalette() });
renderer.setClipBounds([0, 0, 0], [SIZE.x - 1, SIZE.y - 1, SIZE.z - 1]);

// Fill the ground from a generator, one brick at a time.
renderer.edit(
  { x0: 0, y0: 0, z0: 0, x1: SIZE.x - 1, y1: terrain.maxY(), z1: SIZE.z - 1 },
  (cells, ox, oy, oz) => terrain.fillBrick(cells, ox, oy, oz, groundBase),
);
  • editMany(boxes, fill) edits many separate boxes (typically single bricks) with one upload at the end. It is what moving things need: a crowd touches a few thousand scattered bricks a frame, and one box around all of them would visit most of the world.
  • clear(box) drops every brick fully inside box and returns its memory to the pool; bricks only partly covered are edited, so a box off brick boundaries cannot delete a neighbour's content. This is how a streamed chunk leaves the resident set.

The engine builds on these: blitModelToBricks stamps an entity through edit, makeBrickStamper moves things through editMany and restores what they covered, and the chunk streamer loads and frees through edit and clear. See Stamping and crowds and Placement and worlds.

The clip box

Rays are clipped to an axis-aligned box. With data, it defaults to the occupied bounds scanned at construction; without, to the whole grid. A scene whose occupied region grows, or that starts empty and fills through edit, should call setClipBounds(min, max) with the full extent.

Growth and limits

Brick pools are allocated with headroom and reallocated (and rebound) when content stamped or added after construction outgrows them. They grow in 1.5x steps, not doubling: at forest scale the headroom is hundreds of megabytes of otherwise idle GPU memory, and a storage binding has a hard ceiling that doubling walks straight into. The headroom is capped at the device's limits, so a scene that fits still fits.

  • The GPU side is copied on the GPU. A step makes bigger buffers and fills them with a copyBufferToBuffer of the claimed slots, submitted at once; later uploads land on top of the copy. The main thread sends no brick data, which takes it to 1 ms or less, where re-uploading the pools from the CPU cost 150 to 270 ms at a few hundred MB (measured). The old buffers are destroyed right after the submit, so both exist in GPU memory only until the copy has run.
  • The CPU mirror grows the same way. The renderer keeps a CPU copy of the pools, which also grows by 1.5x steps and is capped at the device's buffer limits, so it never grows past what the GPU could hold (nightwood at 100 voxels per metre used to ask for 2 GiB and crash). Its copy on growth is what a step still costs the main thread.
  • Too big throws. A binding larger than the device allows would not fail at creation: it makes the bind group invalid, and rendering silently stops. So the renderer checks the pools against gpu.limits.maxStorageBufferBindingSize and maxBufferSize and throws with a message that says which limit to raise, which is why initGpu asks for generous limits.

renderer.reserveBricks(want) grows the pools once, before the content that will fill them, so the steps happen while the pools are still small. It takes a batch of encoded models (room for all their bricks, ahead of their addEncodedModel calls), or brick counts, { bricks4, bricks8 } (BrickReserve), for content that is not encoded yet, such as the ground a streamed world is about to edit in. Only the pools' spare capacity differs from not reserving: ids, the index and everything drawn are the same. A reservation that fits exactly succeeds, one that does not fit throws, and one the pools already have room for does nothing.

// In development, after a comparable scene has loaded: what the pools hold.
const { slots4, slots8, cap4, cap8 } = renderer.stats();
console.log(`4-bit ${slots4} of ${cap4}, 8-bit ${slots8} of ${cap8}`);

// Next time, before the ground streams in: grow once, while the pools are small.
renderer.reserveBricks({ bricks4: 400_000, bricks8: 20_000 });

renderer.stats() reports resident voxel memory: bricks in use, how many are 8-bit, the GPU bytes held, and dense, what the same grid would have cost as one 3D texture. slots4 and slots8 are the 4-bit and 8-bit pool slots claimed (the models' included), the counts reserveBricks makes room for, and cap4 and cap8 how many the GPU pools hold now. A brick of at most 15 distinct values takes a 4-bit slot, one with more an 8-bit slot, and a brick of one value throughout none.

CPU-side volumes

Sparse models

SparseVoxels is the CPU form of a model too large to hold densely: a Map of 8³ bricks keyed bx + by * bdx + bz * bdx * bdy, each 512 values. A tree at 100 voxels per metre is over a gigabyte dense and a few percent of that as bricks. addModel accepts one directly, and the engine's EntityModel.sparse carries one in place of dense data.

functiondoes
makeSparse(size)an empty volume
sparseGet(s, x, y, z), sparseSet(s, x, y, z, v)read and write a voxel; a brick is created on first write
sparseFromDense(size, data), sparseToDense(s)convert (the dense copy is for small models and tests)
sparseDims(size), sparseCount(s)brick dimensions, solid voxel count

BrickGrid and BrickPool

BrickGrid and BrickPool are the CPU mirror the renderer uploads from, exported for tools and tests: several grids (the world and every instanced model) share one pool, which is what lets one GPU buffer serve them all. BrickGrid.get(x, y, z) reads a voxel back from the sparse form.

OccupancyGrid and GridStamper

OccupancyGrid is a CPU occupancy grid of COARSE_B³ (4³) cells over a dense array, for game logic that wants a coarse "is anything here". The shader no longer uses it: the brick index is the empty-space structure, and renderer.updateCoarse is kept as a no-op for older callers.

GridStamper composites moving voxels over a dense base grid: each stamp(voxels) restores the previously stamped region from the base, writes the new voxels and returns the union dirty box to pass to updateVoxels. Batch every mover into one stamp call per frame; writeBase makes permanent edits (carving, rubble) without a full copy. For a world held only as bricks, use the engine's makeBrickStamper instead.

On this page