Worlds and storage
How a Voxolith world is stored as 8³ bricks, when to use a dense grid instead, how to edit voxels, and what a world costs in memory.
A world is a box of voxels, each an 8-bit palette slot (0 is empty). You choose its size when you create the renderer, and you fill it either from a dense array you keep on the CPU or directly in the renderer's brick storage. This page covers both, how to edit a world after it is built, and what it costs.
Bricks and the index
The renderer stores the world as 8³ bricks under a two-level index: blocks of 64³ voxels exist only where something is, below one top-level entry per block. A brick holding a single value keeps it in its index entry with no payload, so solid ground and open air are almost free. Mixed bricks store a 4-bit index into their own 15-entry palette, or 8 bits when they hold more values.
What that means in practice:
- An empty world of 12800 x 2048 x 12800 voxels costs its 5 MB top level. What is resident costs its surface, not its volume.
- Rays skip 64 voxels at a time through missing blocks and 8 at a time through empty bricks, so large stretches of air are cheap to look across.
BRICK_B(8) andTOP_B(64) are exported if you need to align your own edits to bricks.
A small world: a dense grid
For a level that fits comfortably in memory (a puzzle board, a diorama, a demolition stage), keep a
dense Uint8Array in x + y*sx + z*sx*sy order and pass it to createRenderer. After a change,
upload only the box that changed.
import { createRenderer } from "@voxolith/renderer";
const size = { x: 128, y: 64, z: 128 };
const data = new Uint8Array(size.x * size.y * size.z);
for (let z = 0; z < size.z; z++)
for (let x = 0; x < size.x; x++)
for (let y = 0; y < 8; y++) data[x + y * size.x + z * size.x * size.y] = 1;
const palette = new Float32Array(256 * 4);
palette.set([0.36, 0.62, 0.28, 1], 1 * 4); // slot 1: grass
const renderer = await createRenderer(gpu, { size, data, palette });
// Later: change the array, then re-upload only the box that changed.
data[64 + 8 * size.x + 64 * size.x * size.y] = 1;
renderer.updateVoxels(data, { x0: 64, y0: 8, z0: 64, x1: 64, y1: 8, z1: 64 });The dense array stays the source of truth: updateVoxels always takes the full-grid array, and the
box selects which bricks to rebuild. A dense copy is also what CPU hit tests such as voxelRaycast
read (see Picking).
A large world: bricks only
Past a few hundred voxels a side, a dense copy costs more than the world itself: the examples'
forest at 8x8 tiles would need 1.1 GB of Uint8Array purely as a staging copy of a world the GPU
holds in 159 MB of bricks. Leave data out, and write through renderer.edit, which hands you one
8³ brick at a time.
import { createRenderer } from "@voxolith/renderer";
import { blitModelToBricks, PaletteAllocator } from "@voxolith/engine";
const size = { x: 4096, y: 256, z: 4096 };
const palette = new PaletteAllocator(1);
const { base: stone } = palette.allocate([{ id: "stone", name: "Stone", color: [0.5, 0.5, 0.52] }]);
// No `data`: the world starts empty and lives only in bricks.
const renderer = await createRenderer(gpu, { size, palette: palette.buildPalette() });
renderer.setClipBounds([0, 0, 0], [size.x - 1, size.y - 1, size.z - 1]);
// `cells` is one 8³ brick (lx + ly*8 + lz*64) at world origin (ox, oy, oz).
// Mutate it and return whether you changed anything.
renderer.edit({ x0: 0, y0: 0, z0: 0, x1: size.x - 1, y1: 15, z1: size.z - 1 }, (cells, ox, oy, oz) => {
for (let ly = 0; ly < 8; ly++) {
if (oy + ly > 15) break;
cells.fill(stone, ly * 64, ly * 64 + 64);
}
return true;
});
// Stamp a model: role r of the entity becomes slot base + r - 1.
const { base } = palette.allocateFor(tree);
renderer.updatePalette(palette.buildPalette());
blitModelToBricks(renderer, tree.model, { x: 200, y: 16, z: 200 }, base);The edit functions:
edit(box, fill): every brick overlappingbox; touched bricks upload before it returns.editMany(boxes, fill): many separate boxes with one upload at the end. Use it when a frame touches scattered bricks (movers, a streamed column of ground).clear(box): frees every brick fully insidebox. Partly covered bricks are edited, so a box that is not brick-aligned never deletes a neighbour's voxels.
To carve, set cells to 0 inside your fill. To stamp models, use blitModelToBricks from
@voxolith/engine (the brick twin of blitModel, which writes into a dense array).
Costs and budgets
renderer.stats()returns residentbricks,bytesof GPU memory, anddense, what the same grid would cost as one 3D texture. Show it in a debug overlay while you tune a level.- Every edit re-encodes the bricks it touches on the CPU and uploads them. A few thousand bricks a frame is fine; a box around the whole world is not, so keep edit boxes tight.
- The ray-clip box defaults to the occupied bounds scanned at creation. A world that starts empty,
or grows, must widen it with
setClipBounds, or new voxels outside it are never drawn. - A renderer holds GPU buffers until you call
destroy(). An app that builds a fresh renderer per level must destroy the old one first.
Pitfalls
updateCoarse and OccupancyGrid appear in older examples. The brick index is now the
empty-space structure, so updateCoarse is a no-op; you can drop both from new code.
Models generated at 20, 50 or 100 voxels per metre come back sparse, with model.data empty. blitModelToBricks reads data, so draw
sparse models as instances instead.
See also
- Renderer: storage for the brick encoding and index in detail
- Streaming to build a large world in chunks around the camera
- Palettes and roles for what the slot values mean
- API:
createRenderer,Renderer,RenderScene,blitModelToBricks,blitModel
Manual
Task-by-task guide to building a game on Voxolith, from storing a world and placing models to input, lighting, weather, performance and deployment.
Palettes and roles
Share the world's 256 palette slots between entities with PaletteAllocator, give roles materials, and restyle models without regenerating them.