synvael/docs/meshing.md
Serkyo cae434d227
Some checks are pending
CI / Rust Check & Lint (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Lua Lint & Format (push) Waiting to run
CI / LFS Pointer Guard (push) Waiting to run
docs(workspace): rewrite the subsystem notes and ADRs
2026-08-06 22:52:57 +02:00

14 KiB

Chunk meshing and visibility

This covers how we turn a dense voxel chunk into drawn triangles. It explains the greedy mesher, the neighbour-awareness it needs, the background worker pool that keeps it off the main thread, and the frustum culling that decides what actually gets submitted.

The mesher and cull live in crates/renderer/src/meshing.rs and crates/renderer/src/frustum.rs. The orchestration that glues it all together is in crates/client/src/mesh_pool.rs and crates/client/src/chunks.rs.

If you want to know where chunks come from, check out chunk_streaming.md. This note picks up the story right after a chunk becomes resident on the client.

Crate ownership

The mesher lives in the renderer crate, not client and definitely not shared.

We put it right next to renderer::vertex::Vertex because the mesher's output format is literally the renderer's vertex format (the module was renamed from mesh specifically to free up the name). Keeping them in the same crate means we never have to treat that vertex format as a cross-crate contract. It also keeps geometry generation entirely out of the client crate, honoring the architectural rule that client should only handle input, windowing, and presentation glue. We used to keep a copy of the mesher in the client, but we deleted it.

This setup does not turn renderer into a dependency solely for drawing. The generate_mesh function is completely pure CPU code; it has no device handles, no GPU state, and we can unit test it exactly as is.

The greedy mesher

The generate_mesh(chunk, neighbors) function takes a chunk and returns (Vec<Vertex>, Vec<u32>). It is a pure function with no GPU handles, no device state, and no I/O.

Naively, a solid voxel would emit six quads, meaning a single chunk could emit up to 6 x 32³ quads, almost all of which would be hidden inside the interior. Our mesher avoids this entirely by only emitting the visible surface, and merging the quads together.

It sweeps through each of the three axes slice by slice. For a single slice, it builds a 2D mask of exposed faces over the two perpendicular axes, and then merges that mask into rectangles. It extends a run along the first axis as long as the key matches, and then extends along the second axis as long as every single cell of the candidate row matches. The result is the largest axis-aligned quad possible for that specific key. This turns a flat plane of a single material from thousands of individual quads into just one.

Because we use a half-scale voxel grid (see ADR-0002), this greedy merging is absolutely load-bearing, not just a neat optimization. Flat terrain on a half-scale grid costs roughly 8x the faces of a standard metre-grid world, and this merging completely collapses the exact runs that scaling creates.

The merge key: We use a FaceKey that combines the block ID with the signed face direction. The sign isn't just decoration here. Consider a top face, and the bottom face of the voxel directly above it; they are perfectly coplanar and share a material, but if we merged them, we would fuse two surfaces that face opposite directions and need to shade differently. Therefore, FaceDir explicitly distinguishes PosY from NegY, and does the same for the other axes.

Face colour: Currently, face colour is determined solely by the face direction, acting as a stand-in for real lighting until our materials system lands. It is implicitly part of the merge key since the direction is already there.

Vertex extents (a shared convention)

The mesher places block i on the exact interval [i, i + 1). The near face sits exactly at coord(i) and the far face at coord(i + 1). This perfectly matches the floor()-based coordinate-to-block mapping we use everywhere else in the engine (where position.floor() yields the block index). This ensures that a raycast or cursor highlight resolves to the exact same physical cell that the mesher drew.

Because of this, a chunk's geometry spans exactly [offset, offset + CHUNK_SIZE]. The frustum culler builds each chunk's bounding box from this exact same origin. If the mesher's extents ever change, the culler's box must change with them, otherwise chunks will get culled while still partially on screen, or drawn when completely hidden. We've noted this tight coupling at both call sites, so treat it as a hard invariant between these two files.

Neighbour-aware boundary culling

We only emit a face when the voxel directly next to it is air. For interior voxels, this test is purely local. But for the 32² faces on each of the chunk's six boundary sides, the adjoining voxel technically lives in another chunk entirely. This is exactly why we pass Neighbors<'a> into the function; it carries borrowed handles to the six face-adjacent chunks so we can check those edges.

If a neighbour is absent (None), it means the chunk isn't resident yet. When this happens, we treat the boundary as exposed and emit the faces. If we treated an absent neighbour as solid, we would cull those faces, leaving massive, visible holes along the chunk border as the player moves around. Yes, emitting them costs extra geometry that will eventually be re-meshed away once the neighbour arrives, but that is the correct trade-off. A transient over-draw is always better than a transient hole in the world.

The direct consequence of this is that a chunk's mesh is a function of seven chunks, not one. Any change to a chunk's residency invalidates the meshes of everything adjacent to it, which is why we need the staleness protocol discussed below. This is also why the client holds onto chunk voxels even after uploading geometry; when a neighbour finally arrives, it needs this chunk's boundary voxels to re-mesh against. That retention is a real memory cost, and we've logged it as a follow-up inside crates/client/src/chunks.rs.

The mesh worker pool

Meshing a chunk is far too computationally expensive to run on the winit thread. Instead, MeshPool owns a dedicated set of worker threads fed by a crossbeam-channel. We use crossbeam because it supports multiple consumers, unlike std::sync::mpsc, mirroring how the server's generation pool works.

Jobs are submitted as owned snapshots. A MeshJob carries the chunk and its six neighbours as Arc handles, so dispatching a job is just a quick refcount bump rather than copying a full 64 KiB volume. The worker borrows absolutely nothing from the manager. We snapshot the neighbours at dispatch time, providing the exact, stable state that the resulting mesh will be correct for.

Staleness and the generation protocol

Between dispatching a job and the worker finally returning the mesh, the world can change. The chunk itself might have been evicted, or a neighbour might have loaded or dropped. If that happens, the in-flight mesh is instantly wrong before it even arrives, and applying it would upload geometry that doesn't match the resident voxels.

To fix this, we stamp every single dispatch with a JobGen, which is a monotonic token drawn from a single global counter. We use one global counter, not one per position, to ensure no two dispatches ever share the same token. The manager records the latest generation for every in-flight position, and a returned mesh is only applied if both of these conditions hold:

  1. The position is still wanted (it is still resident).
  2. The generation recorded as in-flight for this position perfectly matches the mesh's own generation token.

If the entry is missing entirely, the position was evicted. If the tokens mismatch, a newer job has already superseded this one. In both cases, we just discard the result instead of uploading it. This follows the exact same logic as the eviction race in server-side chunk residency: we make late arrivals harmless rather than trying to prevent them entirely. This is great because re-dispatching becomes practically free, costing nothing but the superseded worker's wasted effort.

JobGen::next simply wraps around instead of panicking on overflow. To actually trigger a collision, you would need exactly 2⁶⁴ dispatches in a single play session, and the wrapped-to job would still need to be outstanding when the collision happened.

Per-frame budgets

The ChunkManager::update function runs three bounded phases every frame. This ensures that a massive burst of chunk deliveries only degrades frame pacing rather than causing a complete stall:

  • LOADS_PER_UPDATE (4): Bounds how many chunk deliveries we materialize per frame. Any excess just stays queued in the transport layer and gets picked up on the next frame. Drops do not count against this budget because removing a mesh is extremely cheap, and delaying it just wastes memory unnecessarily.
  • MESHES_PER_UPDATE (16): Bounds how many mesh jobs we dispatch per frame. We drain these from a pending re-mesh set, which inherently deduplicates work. A burst of deliveries will re-mesh an affected neighbour exactly once, not once per delivery. This budget is much higher than LOADS_PER_UPDATE because a single delivery can enqueue up to seven jobs (the chunk itself, plus its six neighbours).
  • Ingesting finished meshes is unbounded. Uploading geometry that has already been computed is incredibly cheap compared to computing it in the first place, and artificially throttling it would just cause completed work to pile up for no reason.

The MeshSink boundary

The chunk manager never actually names the Renderer. Instead, it uploads everything through a MeshSink trait (which just requires insert and remove), implemented for renderer::Renderer on the client side. Because of this, the chunk manager is a pure orchestration state machine that we can test against a recording fake without ever touching a Vulkan device. This is precisely what makes our residency, budget, and staleness logic easily unit-testable. Check out crates/client/src/tests/chunks.rs to see this in action.

Frustum culling

Just because geometry is uploaded doesn't mean we draw it unconditionally. Every frame, Frustum::from_view_proj extracts six world-space planes from the combined view-projection matrix (using the Gribb-Hartmann method), and we test every chunk mesh with intersects_aabb before recording its draw call.

There are two specific details here that are easy to mess up, so we pin them tightly with tests:

  • Vulkan depth range: Clip space in Vulkan is [0, 1]. Because of this, the near plane is calculated from the third matrix row alone (r2), instead of r3 + r2 which is the OpenGL [-1, 1] convention. If you use the OpenGL form, you will cull geometry directly in front of the camera. We document the broader clip-space conventions in rendering.md.
  • Row versus column: glam stores matrices in column-major order, but our derivation operates on the rows of the combined matrix, so we have to read the rows explicitly.

For the bounding box test, we use the positive vertex. For each plane, we select the box corner that sits farthest along that specific plane's normal per axis. If even that farthest corner sits behind the plane, we know the entire box is behind it. This test is intentionally conservative. A box that straddles the outside of two planes without actually being inside the frustum can sometimes pass the test. This is exactly the bias we want for culling: a false visible just costs a slightly wasted draw call, but a false hidden results in a glaring visual glitch.

We normalize all planes during construction so that plane evaluation returns true signed distances. This ensures the test remains usable for distance-based decisions (like LOD selection) down the line.

Debug render modes

We inspect the mesher's output using render modes, which are layered over two distinct concepts:

  • RasterPass is a GPU-level primitive that maps exactly one-to-one onto a compiled pipeline. We have to do this because polygon mode and depth-compare state are baked directly into Vulkan pipelines and cannot be set dynamically via a command. All our passes share a single pipeline layout and only differ in that specific baked state.
  • RenderMode composes these passes into an ordered list for presentation. Filled is a single pass, whereas FilledWireframe draws the solid terrain and then draws the edges over it. This keeps the surface readable while letting us see the exact size and shape of the quads the greedy mesher emitted.

To add a new mode, you just add one variant and one arm in RenderMode::passes. You only need to create a brand new pass if the mode requires rasterization state that no existing pass currently provides. RasterPass::ALL has its length strictly pinned to RasterPass::COUNT at compile time, meaning if you forget to list a variant, the code will fail to build rather than silently indexing into the wrong pipeline at runtime.

We size and tint debug passes entirely in the vertex shader rather than generating separate geometry, so we don't need to upload any extra vertex data to support them. You can find the chords that select these modes inside crates/client/src/debug.rs; we put them behind an F1 modifier so you don't accidentally trigger them while moving around.

Testing

The mesher and the frustum are pure algorithmic code, which fits exactly with the testing policy laid out in DEVELOPMENT.md.

  • crates/renderer/src/tests/meshing.rs: Tests merging behavior, face-direction keying (ensuring opposing coplanar faces don't merge), boundary culling against both present and absent neighbours, and handles both empty and full chunks.
  • crates/renderer/src/tests/frustum.rs: Tests plane extraction under the Vulkan depth range, and verifies the AABB test against inside, outside, and straddling boxes.
  • crates/client/src/tests/chunks.rs: Tests residency, per-frame budgets, and the generation protocol by driving everything against a MeshSink fake.

We verify the actual Vulkan submission path (pipeline creation, command recording, presentation) by running the client manually, rather than writing brittle unit tests for it.