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

109 lines
14 KiB
Markdown

# 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`](../crates/renderer/src/meshing.rs) and [`crates/renderer/src/frustum.rs`](../crates/renderer/src/frustum.rs). The orchestration that glues it all together is in [`crates/client/src/mesh_pool.rs`](../crates/client/src/mesh_pool.rs) and [`crates/client/src/chunks.rs`](../crates/client/src/chunks.rs).
If you want to know where chunks *come from*, check out [`chunk_streaming.md`](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](adr/0002-half-scale-voxel-grid.md)), 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`](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.