# ==Seamless chunked voxel terrain== <p class="doc-sub">// status: seedling</p> Chunking is the first architectural step from a toy [[Marching Cubes]] demo to a terrain that can be edited, streamed, and kept in memory. It is also where the most embarrassing artifacts appear: a hairline crack along every boundary, or a bright grid of lighting discontinuities that only shows up when the camera moves. The fix is not a special seam shader. It is a contract about which samples exist, who owns them, and how every chunk evaluates the same field. Once that contract is explicit, chunk meshing is mostly the same algorithm described in [[From Brush Stroke to Mesh - Real-Time Voxel Terrain Editing in C]]. ## Cells are not samples An <code>N × N × N</code> chunk of cells needs <code>(N + 1) × (N + 1) × (N + 1)</code> corner samples. A cell is the interval between two samples; it is not itself a sample. This off-by-one is the root of most first attempts at chunking. Suppose chunk A owns global cells <code>x = 0..31</code> and chunk B starts at <code>x = 32</code>. A's last cell has corners at <code>x = 31</code> and <code>x = 32</code>; B's first cell also uses a corner at <code>x = 32</code>. There is one plane of shared samples, not two planes separated by a cell. I use half-open cell ownership: ~~~text chunk origin in cell coordinates = chunk_coord * CHUNK_CELLS owned cells = [origin, origin + CHUNK_CELLS) owned sample coordinates = [origin, origin + CHUNK_CELLS] ~~~ The positive boundary sample is included in the local cache so the final cell can be meshed, but it has one canonical global coordinate. A global field function can provide that value on demand; a mutable field needs a shared store or an unambiguous owner rule. ## Two ways to keep boundary values equal ### Evaluate one deterministic field For a procedural terrain, the cleanest source is a pure function of global sample coordinates and a world seed: ~~~c float sample_world(int32_t gx, int32_t gy, int32_t gz, uint32_t seed) { Vector3 p = { gx * CELL_SIZE, gy * CELL_SIZE, gz * CELL_SIZE }; return terrain_density(p, seed); } ~~~ Every chunk asks the same function for the same <code>(gx, gy, gz)</code>. Do not pass a chunk-local coordinate to a noise function unless the chunk origin is added first. Local noise coordinates make every chunk repeat the same hill and, worse, make a boundary depend on which chunk asked the question. ### Store one mutable value Interactive edits need a place for modified samples. A dense world array is straightforward for a small scene. A larger world can use a hash map keyed by integer sample coordinates, with the procedural function as the fallback: ~~~c float world_sample(int32_t gx, int32_t gy, int32_t gz) { SampleKey key = { gx, gy, gz }; const float *edited = hashmap_get(&edits, key); return edited ? *edited : sample_world(gx, gy, gz, world.seed); } ~~~ The important part is that both chunks call <code>world_sample(32, y, z)</code>, not that the map uses any particular container. A write to the boundary updates one value and invalidates every chunk that consumes it. ## The halo is for derivatives The shared boundary fixes geometry, but smooth shading introduces another read. A central-difference gradient at a point <code>p</code> is: ~~~c vec3 g = vec3( field(p + vec3(h, 0, 0)) - field(p - vec3(h, 0, 0)), field(p + vec3(0, h, 0)) - field(p - vec3(0, h, 0)), field(p + vec3(0, 0, h)) - field(p - vec3(0, 0, h)) ); ~~~ At the positive edge of a chunk, <code>p + h</code> belongs to the neighbour. At the negative edge, <code>p - h</code> does. Cache one sample on every side (an <code>N + 3</code> cube around the <code>N + 1</code> local samples) or read through the canonical world accessor when computing the normal. The halo is redundant storage, not a second authority. For an <code>N = 32</code> chunk: ~~~text regular samples: (32 + 1)^3 = 33^3 one-sample halo: (32 + 1 + 2)^3 = 35^3 ~~~ Writing the dimensions in terms of <code>N</code> avoids a surprising number of hard-coded <code>32</code>/<code>33</code> mistakes. ~~~c enum { CELLS = 32, HALO = 1 }; enum { SAMPLE_SIDE = CELLS + 1 + 2 * HALO }; // 35 static inline size_t cache_index(int x, int y, int z) { return ((size_t)z * SAMPLE_SIDE + (size_t)y) * SAMPLE_SIDE + (size_t)x; } ~~~ When a chunk is meshed, local sample <code>(x, y, z)</code> maps to global sample <code>origin + (x - HALO, y - HALO, z - HALO)</code>. The Marching Cubes cell loop itself still starts at local <code>HALO</code> and visits exactly <code>N³</code> cells. ## Interpolation must use shared endpoints An edge vertex is placed with: ~~~c float t = (iso - value_a) / (value_b - value_a); Vector3 p = Vector3Lerp(position_a, position_b, t); ~~~ For two adjacent chunks to produce the same boundary position, they need the same endpoint values, the same endpoint positions, the same <code>iso</code>, and the same arithmetic convention. In practice: - store chunk origins as integer cell coordinates; - compute global sample positions from integers, then convert once to float; - use the same <code>&lt; iso</code> sign test and interpolation function in both jobs; - do not add a tiny chunk-local epsilon to hide a crack; - avoid independently resampling a procedural field at two different floating-point world positions. The last point is subtle. <code>noise(origin + local * cell_size)</code> can produce a different last-bit result in adjacent chunks if <code>origin</code> and <code>local * cell_size</code> are accumulated in different orders. Integer lattice coordinates or a canonical world accessor remove that source of disagreement. ## Ownership inside the mesh Within a chunk, neighbouring cells share an edge crossing. A naive emitter writes three vertices per triangle, which is wasteful but geometrically valid. An indexed emitter can key a vertex by the global grid edge it belongs to: ~~~c typedef struct { int32_t cell[3]; uint8_t axis; // 0 = x edge, 1 = y edge, 2 = z edge } EdgeKey; ~~~ Across two GPU meshes, vertices cannot be physically shared. That is fine: matching positions plus matching field-derived normals render as one continuous surface. Do not try to stitch CPU vertex indices between chunks; make their generated attributes deterministic instead. If a chunk has an empty mesh, still keep its revision and bounds. A later neighbour edit may turn it non-empty, and streaming code needs a stable state machine rather than treating “no triangles” as “chunk does not exist”. ## Dirty propagation after an edit The dirty region is larger than the brush's visible sphere: 1. Convert the brush AABB to global sample coordinates. 2. Expand by the interpolation support of the mesher (usually no extra sample beyond the cell corners). 3. Expand by the normal stencil radius (one sample for central differences). 4. Mark every chunk whose cells or normals consume that region. An edit on a face can therefore dirty two chunks. An edit at a chunk corner can dirty up to eight chunks for geometry, and up to 27 when a conservative one-sample normal halo is used. That sounds large, but it is still local; the mistake is remeshing the entire world to avoid reasoning about it. ~~~c Box dirty_samples = expand(world_to_sample_box(edit_box), NORMAL_RADIUS); for (ChunkCoord c : chunks_overlapping(dirty_samples, CELLS)) { world.chunks[c].revision++; world.chunks[c].dirty = true; } ~~~ If edits are queued faster than meshing can consume them, coalesce dirty boxes and revisions. A job should snapshot a chunk's inputs and publish its output only if the revision still matches when it finishes; see [[From Brush Stroke to Mesh - Real-Time Voxel Terrain Editing in C]] for the complete hand-off. ## A useful seam test Before tuning noise or adding LOD, test fields with known surfaces: - a plane <code>f(p) = p.x - k</code> crossing every chunk boundary; - a sphere centred exactly on a boundary plane; - a plane with a constant gradient, to test normals; - an edit whose brush centre lies on a chunk corner. For every shared face, compare the two meshes' boundary vertices after sorting by a canonical edge key. Their positions should agree within the chosen float tolerance, and their normals should agree within the normal tolerance. Also compare the source samples and gradients directly. If the samples differ, geometry debugging is a distraction. An especially useful debug view colours vertices by the chunk that emitted them. A second view displays <code>abs(normal_a - normal_b)</code> across a boundary. The first catches ownership errors; the second catches missing halos and inconsistent coordinate transforms. ## Precision and large worlds Chunk coordinates should remain integers for as long as possible. Convert to local floats for meshing, then add a camera-relative origin for drawing. This keeps a chunk near the origin of its own floating-point range even when the world coordinate is millions of cells away. ~~~c Vector3 local = sample_position * cell_size; Vector3 render_position = local + chunk_origin_relative_to_camera(world); ~~~ The renderer can rebase the camera periodically, or use a high/low split for world positions. The field store still uses integer coordinates, so rebasing does not change the procedural values or edit keys. ## Streaming and state transitions A streamed chunk has more states than “loaded” and “unloaded”: ~~~text absent → requested → samples ready → meshing → visible ↘ failed / retry visible → dirty → meshing → visible ~~~ Keep a visible old mesh while a dirty replacement is being built. For a newly streamed chunk, a temporary empty or lower-resolution mesh is preferable to a blocking load. Neighbours should know whether their boundary data is ready; otherwise a missing halo may be mistaken for air and create a temporary seam. The field and mesh lifetimes are separate. It is valid for a chunk's samples to remain in a cache after its GPU mesh is evicted. It is also valid to keep a mesh while dropping far-away editable deltas, provided the streaming policy knows how to reconstruct those samples before the chunk becomes editable again. ## Where LOD changes the problem Two chunks at the same cell size can share samples directly. A chunk at half the cell resolution has boundary vertices that do not line up with the fine chunk's vertices. No amount of same-resolution halo copying fixes that topological mismatch. Use a transition mesh such as [[Transvoxel in Practice]], constrain neighbouring levels to a 2:1 difference, or hide the gap with a skirt. This distinction is useful when debugging: a crack between equal-resolution chunks is a sampling/ownership bug; a crack between different resolutions is expected until the LOD boundary has a stitching policy. ## Things that tripped me up - **Treating <code>N</code> samples as <code>N</code> cells** — the final cell has a positive boundary corner, so an <code>N³</code> sample array silently drops a layer of terrain. - **Duplicating boundary values** — two cached copies drift after an edit unless one is canonical and the other is refreshed. Prefer a global accessor or explicit owner. - **Sharing geometry but not gradients** — positions lined up while a bright seam remained. Normals need the same neighbour samples as geometry needs boundary corners. - **Using chunk-local noise coordinates** — every chunk looked plausible in isolation and repeated the same pattern. Noise must see global lattice coordinates plus the world seed. - **Floating-point chunk origins** — adjacent chunks evaluated the same mathematical point through different arithmetic paths. Keep indices integer and evaluate positions canonically. - **Marking only the chunk under the brush** — a boundary sample is consumed by the neighbour too. Expand dirty regions and invalidate all consumers. - **Trying to solve LOD cracks with a larger halo** — a halo shares data; it does not make fine and coarse edge vertices have the same topology. That is a transition-mesh problem. - **Relying on visual inspection alone** — a seam can disappear at one camera angle. Compare boundary samples, positions, and normals numerically in a test field. ## References - [[Marching Cubes]] — corner classification, interpolation, vertex keys, and the reason chunk boundaries need shared samples. - [[From Brush Stroke to Mesh - Real-Time Voxel Terrain Editing in C]] — dirty-region tracking and asynchronous mesh hand-off. - [[Normals for Implicit Surfaces]] — central differences, gradient orientation, and stable normal fallbacks. - [[Transvoxel in Practice]] — stitching an exact 2:1 LOD boundary. - [Lorensen & Cline — Marching Cubes (1987)](https://doi.org/10.1145/37402.37422) — the original constant-density surface construction algorithm. - [[Raym - Interactive Terrain Generation with Marching Cubes|Raym]] — the concrete C/Raylib experiment that motivates these ownership rules ([source repository](https://github.com/Rydgel/raym)). --- Back to [[Notes/Index|Notes]] · see also [[Voxel rendering techniques]] · [[Marching Cubes]] · [[Transvoxel in Practice]]