Skip to content

The spatial grid

Scene-level raymarching needs the distance to the nearest surface at every step of every ray. Evaluating every shape at every step is O(N) in shape count, paid per step — so cost grows with the scene even where nothing is nearby.

The grid makes that lookup O(1): a shape only needs to be compared against the shapes registered in the cell the sample point is in, and a cell holds at most 15. The bound is fixed regardless of how many shapes the scene contains.

Two compute passes run before the frame draws: one clears the grid, one inserts each shape into the cells its bounding box covers.

Each shape computes a world-space bounding box, then writes its index into every cell that box touches.

The envelope is per primitive. A shape’s size means something different for every type, so the insert pass has an explicit case for each one — a sphere’s envelope is its radius on all three axes, a capsule’s is radius by half-height plus radius, a torus’s is major plus minor. Box and box-frame add their rounding; 2D shapes read the modifier mask to know whether extrude or revolve is active and size their Z extent accordingly.

Rotation is handled by bounding the rotated shape. The eight corners of the local envelope box are rotated into world space and reduced to a min and max. That over-covers a rotated shape rather than under-covering it, which is the safe direction — a shape in too many cells is slower, a shape in too few is missed.

A shape with no envelope case falls back to using size directly. For a primitive whose true extent is larger than its size that is too small, and it will be missed by scene-level effects while still drawing correctly.

  • 32 × 32 × 32 cells. GRID_RES is 32u.
  • Each cell holds a count and 15 shape indices — 16 unsigned ints, 64 bytes.
  • Cell index is x + y * GRID_RES + z * GRID_RES².

The grid covers a fixed world volume, not the scene’s bounds:

AxisRangeCell size
X−32 to 322.0
Y−32 to 322.0
Z−96 to 324.0

Z is deeper and coarser because scenes extend away from the camera.

Insertion and lookup behave differently outside those bounds, and the difference matters.

Inserting clamps. A shape whose bounding box extends past the domain is filed into the edge cells, so it is still found — it just piles into the boundary.

Looking up does not clamp. When the sample point falls outside the domain, sceneSDF takes an explicit fallback and evaluates every object in the scene:

} else {
// Fallback: Check all objects if outside grid
let n = u32(uni.params4.w);
for (var i = 0u; i < n; i++) { … }
}

That is correct but it is the pre-grid O(N) walk, paid per raymarch step. A ray travelling outside the domain loses the optimisation entirely.

This is the grid’s sharp edge: results stay right, so nothing looks broken — the scene just gets slower in proportion to shape count. If a scene degrades when the camera pulls back or shapes move far out, check whether the marched region has left the domain.

The 15 slots are what make the lookup O(1), and the cap is enforced on write:

let slot = atomicAdd(&grid.cells[cellIdx].count, 1u);
if (slot < 15u) {
grid.cells[cellIdx].indices[slot] = iid;
}

The counter keeps incrementing past 15 while only the first 15 slots are written, so a cell’s count can exceed the number of indices it actually holds. That is why the read side clamps with min(count, 15u) — without it the lookup would read uninitialised slots.

The consequence: when more than 15 shapes overlap one cell, the surplus is not visited from it.

Unlike the domain fallback, this one changes results rather than cost. It needs an unusually dense cluster in a single 2×2×4 region. If shapes stop participating in scene-level effects while still drawing normally, check this cap first.

A lookup skips the shape it was called for, so a shape never merges or occludes against itself.

The grid shader is loaded on the first SDF mount, not at boot. A scene with no SDF shapes never fetches it.

The emitted grid chunk is this compute shader. It is not related to the <Grid> particle component, which is part of the particle system.

The grid shader declares its own copy of the shape record so it can read positions and sizes. That declaration must match the main shader’s stride exactly, including padding it never reads.

If the two disagree, the grid indexes into the wrong shape and writes shapes into the wrong cells — which shows up as effects that behave as though shapes were somewhere else, not as a crash. Any change to the shape record must be mirrored in both.