blog / calum

How Quantum draws a cottage without drawing 333,804 triangles

Oct 26, 2026 · renderer notes

Quantum is the voxel editor I'm building. It was called Voxelite until very recently, which is why the code paths below still say voxelite. The part I keep poking at is the renderer, because an editor with a stuttering viewport is just a sad spreadsheet.

My test scene is a small autumn cottage with a tree, a fence and a chimney I'm unreasonably proud of. The example file in the repo has 27,817 voxels.

Isometric render of a voxel cottage with an orange autumn tree
A render from the CPU rasteriser in the repo, because the box I wrote this on has no GPU. It is slow but deterministic, which matters at the end. The real viewport is wgpu on Metal.

This is not a tutorial. There are plenty of those and they hold your hand better than I do. It's the handful of things that moved the needle, in the order I hit them, with real numbers where I have them and a plain note where I don't.

The version everyone writes first

One cube per voxel. Eight corners, twelve triangles, done.

not from the repo, this is the one everyone writes on day onefor voxel in scene.voxels() {
    // 12 triangles, whether or not anyone can see them
    push_cube(&mut mesh, voxel.pos, voxel.color);
}

For the cottage that's 27,817 × 12 = 333,804 triangles, most of them inside walls or under grass. A modern GPU would probably shrug at that and I never timed it, so I won't pretend I did. The trouble is the other scenes I test with. A million voxels is 12.5 million triangles. Ten million voxels is 120 million triangles of dirt, and I'd like to still see 120 frames a second.

Don't draw faces nobody can see

Put two cubes next to each other and the faces that touch are inside solid matter. Nobody will ever look at them. In a 3×3×3 block that's 162 faces, and only the 54 on the outside can ever show up on screen.

Hidden faces on a block of voxelsA three by three cross section. Left shows every face of every cube. Right keeps only the faces on the outside.every face of every cubeonly faces that touch air36 edges in 2D12 edges in 2D→
Red edges touch another voxel, blue edges touch air. A 2D cross section keeps the picture readable.

The check is one hash lookup per face. If there's a voxel on the other side, skip it.

crates/voxelite-render/src/chunk_mesh.rs, trimmedlet Some(&slot) = cells.get(&p) else { continue };
let mut neighbor = p;
neighbor[axis] += if positive { 1 } else { -1 };
if cells.contains_key(&neighbor) {
    continue; // solid on the other side, nobody will ever see this face
}

On the cottage that takes 333,804 triangles down to 42,564, which is 21,282 exposed faces. About 87 percent of the model was never going to be seen.

Then merge what's left

Look at a flat stretch of grass. Hundreds of identical little squares, all facing up, all the same colour. Greedy meshing walks each layer, finds a square, grows it as far right as it can, then as far down as it can, and emits one big rectangle instead.

Greedy merging on a real layer of the cottageTop faces of the ground layer of the cottage, 2198 unit faces on the left, 734 merged rectangles on the right.2,198 unit faces734 quadsreal top faces of the ground layer in the example file, colours from its palette
The top faces of the ground layer of the cottage, straight out of the real mesher. 2198 unit faces on the left, 734 rectangles on the right.

There's one catch. I bake ambient occlusion into the vertices, so two neighbouring faces with the same colour but different corner darkness can't share a quad or the shading smears across it. The merge only happens when all four corners agree.

same file, the grow steplet flat = face.ao.iter().all(|a| *a == face.ao[0]);
let mut w = 1;
let mut h = 1;
if flat {
    while x + w < 16 && mask[y * 16 + x + w] == face { w += 1; }
    while y + h < 16 && (0..w).all(|dx| mask[(y + h) * 16 + x + dx] == face) { h += 1; }
}
Triangles for the cottage after each trickBars: 333,804 triangles as cubes, 42,564 after culling hidden faces, 19,634 after greedy merging, 34,402 drawn by the GPU path.cube per voxel333,804internal faces culled42,564greedy merged (CPU mesher)19,634what the GPU path draws34,402bars are linear. GPU row is a different mesher, see the text
Triangles for the cottage after each step. The first three rows are the plain CPU mesher run on the example file. The last row is the mesher the GPU actually uses, from the baseline run.

So the GPU mesher is a worse greedy mesher than the plain one, on purpose. 17,201 quads (34,402 triangles) against 9,817 quads (19,634 triangles). I'm paying quads for nicer shading. One more oddity: the scene in my baseline run has 27,897 voxels and the file in the repo has 27,817. I don't know where 80 voxels went, I'm choosing to blame the fence.

Words only go so far, so here's the cottage. It's the real mesher output for the example file, drawn on this page by a small script: flat shaded, one fixed camera, no AO, no shadows. It isn't my renderer. Pick a mode, turn the wireframe on, then go look at the roof. The picture never changes between modes. Only the number of pieces it took does.

This one needs JavaScript. The numbers it shows are 166,902 faces for a cube per voxel, 21,282 after culling and 9,817 after greedy meshing, and the ladder above has the triangle counts.
The wireframe draws the faces each mode would hand to the GPU, front facing ones only, so the cube mode shows half of its 166,902. The fill is the same in every mode. Face counts are computed in the page from the 27,817 voxels and 9,817 quads that came out of the real mesher, and the triangle counts are twice the face counts. Greedy only merges neighbours that share a palette colour, and this cottage is speckled on purpose, so zoom in on the roof and you will see most quads stay small.

Chunks, so an edit is cheap

The mesh isn't one giant blob. It's cut into 16×16×16 chunks, each with its own buffer and its own draw call (the cottage is 30 draw calls). Move the camera and nothing gets rebuilt. Change one voxel and only the chunk it lives in gets remeshed.

Almost only. Culling and AO both look at neighbours, so an edit near a chunk border changes the faces on the other side too. I reach two cells in every direction and rebuild whatever chunks that touches.

same file, sync_validated// Cull and AO dependencies extend two cells across chunk borders,
// including diagonals. Interior edits only invalidate one chunk.
let lo = p.map(|v| (v - 2).div_euclid(16));
let hi = p.map(|v| (v + 2).div_euclid(16));
for z in lo[2]..=hi[2] {
    for y in lo[1]..=hi[1] {
        for x in lo[0]..=hi[0] { dirty.insert([x, y, z]); }
    }
}
An edit only rebuilds nearby chunksA grid of chunks in 2D. A voxel edit in the middle of a chunk dirties one chunk. An edit near a corner dirties four.16-cell chunks, toy 2D version1 chunk and 4 chunks rebuilt for two one-voxel edits. Dashed box is the 2-cell halo.
Toy 2D version. The real one is 3D, so a corner edit can dirty eight chunks.

Here's what that buys on the M5 Pro. A one-voxel edit on the 10 million voxel terrain shows up in 26.8 ms. Changing a palette colour takes 3,116 ms, because colours are baked into the vertices and a palette change remeshes everything. Yes, that's a bit embarrassing, and it's on the list (palette index in the vertex plus a palette buffer in the shader, so a recolour is a buffer write).

Cost of a one-voxel edit versus a palette changeLog-scale bars, milliseconds from edit to next presented frame, render side only, on the M5 Pro.1 ms10 ms100 ms1,000 ms10,000 msAutumn8.0 ms, one voxel37.4 ms, recolourTerrain 1M16.6 ms, one voxel259 ms, recolourTerrain 10M26.8 ms, one voxel3,116 ms, recolouredit to next presented frame, render side only, log scale
Baseline run, M5 Pro, median of 20 frames. Render side only.

These numbers start when the renderer gets the edit. The document engine is separate and sits on top. A one-voxel edit costs about 2 ms of engine time on the cottage, 83 ms at 1M voxels and 0.84 s at 10M. So at 10M the renderer is the smaller problem, which is a funny thing to learn after doing the renderer work.

All three tricks together, against the cube-per-voxel triangle count for each baseline scene:

Triangles: one cube per voxel versus what Quantum drawsLog-scale bars for four scenes. Cube per voxel is twelve triangles per voxel; the drawn count is from the baseline run.100,0001,000,00010,000,000100,000,000Autumn cottage334,764 cubes34,402 drawn (10x fewer)Terrain 1M12,480,000 cubes149,192 drawn (84x fewer)Sponge 1M12,480,000 cubes912,356 drawn (14x fewer)Terrain 10M120,000,000 cubes875,968 drawn (137x fewer)triangles per frame, log scale
The cube-per-voxel count is voxels × 12, nothing measured. The drawn count is from the baseline run at the fit camera.

Shadows that are actually right

Most real-time shadows use a shadow map: render the scene from the sun, then compare depths. It's fast and it's fuzzy, with bias to tune, resolution to tune, and the occasional floating-object look. I wanted voxel shadows to be exactly the voxel shape, so every pixel shoots a ray at the sun through the grid and asks whether it hits a voxel on the way out.

no shadowstraced shadows
Close up of the cottage roof with ambient occlusion and no shadows
AO, no shadows
Close up of the cottage roof with ambient occlusion and shadows
AO + traced shadows
These are CPU renders of the example file, the same trace the GPU does in the fragment shader. Shadows off against shadows on, so this is the cost of shadows. It is not a before and after of a speed-up, because that hasn't landed. The pixel difference view works out, in your browser, which pixels changed between the two images and how much.

That's a per-pixel loop, so it had better be smart about empty space. The occupancy grid is stored as 8×8×8 bricks: 512 bits is 16 words per brick, plus one more bitmask with a single bit per brick that says whether it has anything in it at all. An empty brick gets crossed in one jump.

gpu/viewport.wgsl, trace_shadow, trimmed (the full loop runs up to 768 times)let brick = b.x + occupancy.bricks.x * (b.y + occupancy.bricks.y * b.z);
let mask  = occupancy.bricks.w + brick / 32u;
if (occupancy.words[mask] & (1u << (brick % 32u))) == 0u {
    // empty brick: jump straight to where the ray leaves it
} else {
    if (occupancy.words[word] & (1u << (bit % 32u))) != 0u { return true; }
    // inside a busy brick: walk one cell along the closest axis
}
A shadow ray with and without brick skippingToy 2D grid. The same ray hits the same cell. Walking cell by cell takes 10 steps, skipping empty bricks takes 4.toy 2D grid, bricks 4x4 (the real ones are 8x8x8)amber dots: step cell by cell, 10 stepsblue dots: jump over empty bricks, 4 steps. Same hit.
A toy ray on a toy grid. Stepping cell by cell takes 10 steps and skipping empty bricks takes 4, and both land on the same red cell. The step counts are computed from this toy, not from the renderer.

That works, and it's still the most expensive thing in the frame by a distance. I turned it off to see how much, one change at a time on the real shader, 60 frames per phase.

Where the GPU time goes on the M2Grouped bars of GPU milliseconds per frame for four scene and camera combinations, with everything on, one-times supersample, and shadows off.02040608018.68.26.6Autumn, fit44.313.57.4Autumn, close18.47.94.6Terrain 1M, fit77.023.77.4Terrain 1M, closeeverything on1x supersampleshadows offGPU ms per frame, Mac mini M2, lower is better
Baseline run on the Mac mini M2. GPU time from timestamp queries, median of 60 frames. Tap a group for its numbers, and switch bars off to compare two.

Read this chart carefully, because it isn't a before and after. It's what shadows cost, which is the ceiling on what any shadow speed-up could ever win. At the fit camera on Autumn that's 18.6 ms down to 6.6. Zoomed in on the 1M terrain it's 77.0 ms down to 7.4. I haven't sped the trace up yet, so there is no real after number to show you.

The other ablations were more interesting than I expected. Removing the extra geometry attachment write changed nothing measurable (18.5 ms against 18.6 on Autumn at the fit camera). The sponge scene has 912,356 triangles and the terrain has 149,192, and the sponge renders three times faster close up. Triangle count isn't the problem. Pixels times ray length is. Rendering at 1x instead of 2x supersample also helps a lot (18.6 ms down to 8.2 on Autumn) but I'm not going to take it, because the whole rule here is that the picture doesn't get worse. It's a measurement, not a plan.

Same idea for speed. This is every scene in the baseline on both machines. Slide to a scene or tap the chart, and switch lines on and off. The scenes are not a controlled sweep (a sponge is not a terrain), so read each line as four separate measurements joined up so you can see them, not as a curve.

fewer voxelsmore voxels
Frame time on the M5 ProBars of wall milliseconds per frame at fit and close camera for three scenes with a line at 8.3 milliseconds for 120 hertz.0102030407.814.3Autumn7.823.1Terrain 1M15.631.8Terrain 10M120 Hz8.3 msfit cameraclose camerawall ms per frame, M5 Pro, one run, median
Baseline run, wall time per frame, median of 60 frames. The M5 Pro GPU timestamp came back zero, so its numbers are wall times, which equal GPU wait because the pipeline is GPU-bound. The M2 mini was under CI load when I ran it, so treat its lines as rough. Fit is the camera framing the whole scene, close is zoomed in, orbit is the camera swinging round.

So on the M5 Pro the cottage holds 120 Hz at the fit camera (7.8 ms against an 8.3 budget) and doesn't once you zoom in. Ten million voxels doesn't hold it anywhere.

The plan for the trace is a second occupancy level, one bit per 32-cell super-brick, so rays cross big empty areas in a few steps. Plus clipping each ray to the occupied bounds and to the highest occupied cell along the sun direction. The image should come out identical. There's one trap: the loop stops at 768 steps, and a ray that used to run out of steps and give up (no shadow) could now finish its walk and find something. A few pixels could change. I want that to show up in a diff, not slip through.

That change hasn't landed. When it does, the numbers get added here with a proper after chart. If the result is boring I'll say so.

The part that keeps me from lying

I don't trust myself to eyeball a shadow change. So the plan is two small tools that share the same scenes and camera paths. One measures speed, the other measures whether the picture moved.

benchvisual
What it doesDrives the real Metal renderer headless along scripted camera and edit paths at the baseline sizeSame scenes and paths at a small size, compared against golden PNGs
Fails whenMedian of 3 runs is worse than the baseline by more than 5 percent and more than 0.3 msAny pixel differs (exact mode, used for every optimisation)
Leaves behindJSON per run and a before and after tableActual, golden, a log-scaled diff heatmap and a side by side

There's a looser tolerance mode for changes I actually want, like tweaking the look: at most 0.05 percent of pixels off by more than 6/255, and no blob of failing pixels bigger than 32 px. Goldens are pinned to one chip, the Mac mini, because shadow and AO output can differ in the last bits between GPUs, and every other machine gets a polite skip rather than a failure. Before goldens are trusted, every frame is rendered twice and has to match itself. Changing a golden on purpose takes a command that wants a reason and writes it to a changelog.

Here is roughly what the diff output looks like. This one is the cottage with shadows on against shadows off, from the CPU renderer. It isn't a regression, I just needed a real pair of images that differ.

Log-scaled difference heatmap, black where the images match
Black means identical. 25,140 of 655,360 pixels differ (3.84 percent), 22,394 of them by more than 6/255 in at least one channel, biggest channel difference 55. The tolerance mode would reject this and exact mode would reject it harder, which is the point.

Status: the bench and the visual tool are being written as I type this and aren't merged yet. Nothing is gated by them today, and the speed numbers above come from a one-off instrumented run, not from the finished tool.

That's where it is. The second brick level is next.

Where the numbers come from

All speed numbers come from a baseline run of the production Metal worker driven headless: 4128×3232 pixel target, 4x MSAA, 2x supersample, exact voxel shadows, commit d81e670, on 2026-10-26. The M2 mini was busy running CI during the runs (load average 11 to 66), so treat those timings as rough. The M5 Pro was in normal use and its numbers are from a single run. The raw per-frame data for the ablation and the triangle counts is in data/ in this site's repo, along with the script that draws every chart on this page.