Synvael/docs/meshing.md

105 lines
12 KiB
Markdown

# Chunk meshing & visibility
How a dense voxel chunk becomes drawn triangles: the greedy mesher, the neighbour-awareness it requires, the background worker pool that keeps it off the winit thread, and the frustum cull that decides what is 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 in [`crates/client/src/mesh_pool.rs`](../crates/client/src/mesh_pool.rs) and [`crates/client/src/chunks.rs`](../crates/client/src/chunks.rs).
Where chunks *come from* is [`chunk_streaming.md`](chunk_streaming.md); this note picks up once a chunk is resident on the client.
## Crate ownership
The mesher lives in `renderer`, not in `client` and not in `shared`.
It sits next to `renderer::vertex::Vertex` (the module was renamed from `mesh` to free the name) because the mesher's output format *is* the renderer's vertex format. Keeping them in one crate means that format never has to become a cross-crate contract, and it keeps geometry generation out of the crate that `AGENTS.md` wants confined to input, windowing, and presentation glue. The client previously held its own copy; that copy is gone.
Nothing about this makes `renderer` a dependency for drawing alone: `generate_mesh` is a pure CPU function, with no device handles and no GPU state, and it is unit-testable as such.
## The greedy mesher
`generate_mesh(chunk, neighbors)` returns `(Vec<Vertex>, Vec<u32>)` and is a pure function: no GPU handles, no device state, no I/O. Naively, a solid voxel emits six quads and a chunk emits up to `6 x 32³` of them, almost all interior and immediately hidden. The mesher instead emits the *visible surface*, merged.
Each of the three axes is swept slice by slice. For one slice, a 2D mask of exposed faces is built over the two perpendicular axes, and the mask is then merged into rectangles: a run is extended along the first axis while the key matches, then the run is extended along the second axis while every cell of the candidate row matches. The result is the largest axis-aligned quad available for that key, and a flat plane of one material collapses from thousands of quads to one.
The half-scale voxel grid ([ADR-0002](adr/0002-half-scale-voxel-grid.md)) makes this load-bearing rather than an optimisation: flat terrain costs roughly 8x the faces of a metre-grid world, and merging collapses exactly the runs that scaling creates.
**The merge key** (`FaceKey`) is the block id plus the *signed* face direction. The sign is not decoration: a top face and the bottom face of the voxel directly above it are coplanar and share a material, and merging them would fuse two surfaces that face opposite ways and shade differently. `FaceDir` therefore distinguishes `PosY` from `NegY`, and likewise on the other axes.
**Face colour** is currently a function of face direction alone, standing in for lighting until materials land. It is part of the key only implicitly, since direction already is.
### Vertex extents: a shared convention
The mesher places block `i` on the interval `[i, i + 1)`: its near face sits at `coord(i)` and its far face at `coord(i + 1)`. This matches the `floor()`-based coordinate-to-block mapping used by the rest of the engine (`position.floor()` yields the block index), so a raycast or cursor highlight that floors a hit point resolves to the same cell the mesher drew.
A chunk's geometry therefore spans `[offset, offset + CHUNK_SIZE]`. The frustum cull builds each chunk's bounding box from the same origin; if the mesher's extents ever change, the cull's box must change with them, or chunks will be culled while still partially on screen (or drawn while fully off it). The coupling is noted at both sites; treat it as an invariant of this file pair.
## Neighbour-aware boundary culling
A face is emitted only when the voxel adjoining it is air. For interior voxels that test is local, but for the `32²` faces on each of a chunk's six sides the adjoining voxel lives in another chunk. `Neighbors<'a>` carries borrowed handles to the six face-adjacent chunks for exactly this test.
An **absent** neighbour (`None`) means "not resident", and the boundary is treated as **exposed**, so its faces are emitted. The alternative, treating absence as solid, would cull those faces and leave visible holes along the load frontier as the player moves. Emitting them costs geometry that will be re-meshed away once the neighbour arrives, which is the correct trade: a transient over-draw beats a transient hole.
The consequence is that **a chunk's mesh is a function of seven chunks, not one**. Any change to residency invalidates the meshes of everything adjacent to it, which is what makes the staleness protocol below necessary. It is also why the client retains chunk voxels after uploading geometry: a neighbour arriving later needs this chunk's boundary voxels to re-mesh against. That retention is a real memory cost, recorded as a follow-up in `crates/client/src/chunks.rs`.
## The mesh worker pool
Meshing a chunk is far too expensive to run on the winit thread, so `MeshPool` owns a set of worker threads fed by `crossbeam-channel` (multi-consumer, unlike `std::sync::mpsc`), mirroring the server's generation pool.
Jobs are **owned snapshots**: a `MeshJob` carries the chunk and its six neighbours as `Arc` handles, so dispatch is a refcount bump rather than a copy of a 64 KiB volume, and the worker borrows nothing from the manager. Neighbours are snapshotted *at dispatch time*, which is precisely the state the resulting mesh will be correct for.
### Staleness: the generation protocol
Between dispatching a job for a position and the worker returning it, the world can have moved on: the chunk may have been evicted, or a neighbour may have loaded or dropped, making the in-flight mesh wrong before it arrives. Applying it would upload geometry that does not match the resident voxels.
Every dispatch is therefore stamped with a `JobGen`, a monotonic token drawn from a single global counter (not one counter per position, so no two dispatches ever share a token). The manager records the latest generation per in-flight position, and a returned mesh is applied only when **both** hold:
1. the position is still wanted (still resident), and
2. the generation recorded as in-flight for it still equals the mesh's own generation.
A missing entry means the position was evicted; a mismatch means a newer job superseded this one. Either way the result is discarded rather than uploaded. This is the same shape as the eviction race in server-side chunk residency: a late arrival is made *harmless* rather than prevented. It generalises, too, because re-dispatch is then free, costing nothing but the superseded worker's wasted effort.
`JobGen::next` wraps rather than panics on overflow. Wrapping requires 2⁶⁴ dispatches in one session, and a collision would additionally require the wrapped-to job to still be outstanding.
### Per-frame budgets
`ChunkManager::update` runs three bounded phases per frame, so a burst of deliveries degrades frame *pacing* rather than causing a stall:
- **`LOADS_PER_UPDATE`** (4) bounds chunk deliveries materialised per frame. Excess stays queued in the transport and is picked up next frame. Drops are not charged against this budget, since removing a mesh is cheap and delaying it only wastes memory.
- **`MESHES_PER_UPDATE`** (16) bounds mesh jobs dispatched per frame, drained from a pending re-mesh *set*. The set deduplicates: a burst of deliveries re-meshes each affected neighbour once, not once per delivery. The budget exceeds `LOADS_PER_UPDATE` because one delivery can enqueue up to seven jobs, itself plus six neighbours.
- **Ingesting finished meshes is unbounded.** Uploading already-computed geometry is cheap next to computing it, and throttling it would only let completed work pile up.
### The `MeshSink` boundary
The manager never names `Renderer`. It uploads through a `MeshSink` trait (insert, remove), implemented for `renderer::Renderer` in the client. The chunk manager is thus a pure orchestration state machine, testable against a recording fake with no Vulkan device involved, which is what makes the residency, budget, and staleness logic unit-testable at all. See `crates/client/src/tests/chunks.rs`.
## Frustum culling
Uploaded geometry is not unconditionally drawn. Each frame, `Frustum::from_view_proj` extracts six world-space planes from the combined view-projection matrix (Gribb-Hartmann), and every chunk mesh is tested with `intersects_aabb` before its draw call is recorded.
Two details are easy to get wrong and are pinned by tests:
- **Vulkan depth range.** Clip space here is `[0, 1]`, so the near plane is the third matrix row alone (`r2`), not `r3 + r2` as in OpenGL's `[-1, 1]` convention. The OpenGL form culls geometry directly ahead of the camera. See [`rendering.md`](rendering.md) for the broader clip-space conventions.
- **Row versus column.** `glam` stores matrices column-major while the derivation operates on rows of the combined matrix, so rows are read explicitly.
The box test uses the **positive vertex**: for each plane, the box corner farthest along that plane's normal is selected per axis. If even that corner lies behind the plane, the whole box does. The test is conservative, since a box straddling two planes' outsides without being inside the frustum can pass, which is the correct bias for culling: a false *visible* costs a wasted draw, a false *hidden* costs a visible artefact.
Planes are normalised at construction so plane evaluation returns true signed distances, which keeps the test usable for distance-based decisions (LOD selection) later.
## Debug render modes
The mesher's output is inspected through render modes, layered over two concepts:
- **`RasterPass`** is the GPU-level primitive and maps one-to-one onto a compiled pipeline, because polygon mode and depth-compare state are baked into a pipeline and cannot be set by a command. All passes share one pipeline layout and differ only in that state.
- **`RenderMode`** composes passes into what is presented, as an ordered list. `Filled` is one pass; `FilledWireframe` draws the terrain and then overlays edges, keeping the surface readable while showing the size and shape of the quads the greedy mesher actually emitted.
Adding a mode is one variant plus one arm in `RenderMode::passes`, and needs a new pass only if it requires rasterisation state no existing pass provides. `RasterPass::ALL` has its length pinned to `RasterPass::COUNT` at compile time, so a variant that is not listed fails to build rather than silently indexing the wrong pipeline.
Debug passes are sized and tinted in the vertex shader rather than through separate geometry, so no extra vertex data is uploaded to support them. The chords that select these modes are documented in `crates/client/src/debug.rs`; they sit behind an F1 modifier so they cannot collide with movement keys.
## Testing
The mesher and the frustum are pure algorithmic code, which is where the testing policy in `AGENTS.md` directs effort:
- `crates/renderer/src/tests/meshing.rs`: merge behaviour, face-direction keying (opposing coplanar faces must not merge), boundary culling against present and absent neighbours, empty and full chunks.
- `crates/renderer/src/tests/frustum.rs`: plane extraction under the Vulkan depth range, and the AABB test on inside, outside, and straddling boxes.
- `crates/client/src/tests/chunks.rs`: residency, per-frame budgets, and the generation protocol, driven against a `MeshSink` fake.
The Vulkan submission path itself (pipeline creation, command recording, presentation) is verified by running the client, not by unit tests.