# ==From brush stroke to mesh==
<p class="doc-sub">// status: seedling</p>
The satisfying part of [[Raym - Interactive Terrain Generation with Marching Cubes|Raym]] is not the first mesh. It is the loop that happens after the mesh exists: point at a hill, press the mouse, change a few hundred numbers, and see the hill become a cave or a path. The rendering code is conventional. The interesting engineering is keeping the scalar field, the chunk meshes, and the frame loop in agreement while an edit is still happening.
This note follows one stroke from the camera to the GPU. It assumes the basic [[Marching Cubes]] case table is already familiar; the emphasis here is the data ownership and update strategy that make an interactive implementation feel immediate.
## The important distinction: field first, mesh second
The triangles are a cache. The editable world is the scalar field beneath them. If `f(p) < iso` means "solid", then the terrain surface is the set of points where `f(p) == iso`. Marching Cubes samples that field, creates a mesh for a region, and throws the temporary cell decisions away. A brush therefore edits `f`, not a vertex buffer.
That distinction pays off in several ways:
- a new mesh can be regenerated after a save/load without trying to reverse-engineer edits from triangles;
- the same field can feed collision, picking, a minimap, or a different mesher such as [[Dual Contouring]];
- an edit only has to invalidate the cells whose input samples changed.
Raym uses the deliberately uncomplicated version: a dense, chunked array of `float` samples, a Marching Cubes pass over dirty chunks, and a raylib mesh upload when the CPU result is ready. It is a good shape for learning because each stage is visible.
## Coordinates and ownership
There are three coordinate spaces in play:
1. **World space** — metres used by the camera and brush.
2. **Voxel space** — integer sample coordinates in the global field.
3. **Chunk-local space** — indices into one chunk's sample array.
Do the integer conversion once and keep it explicit. A chunk with `N` cells along an axis contains `N + 1` samples along that axis. The cells are the intervals between samples, so the chunk at `(cx, cy, cz)` owns cells in the half-open range `[cx*N, (cx+1)*N)` and samples at its endpoints. Neighbouring chunks must agree about the endpoint value; they must not invent two slightly different copies.
One possible C representation is:
```c
enum { CHUNK_CELLS = 32 };
typedef struct {
int32_t coord[3]; // chunk coordinate, not a world-space float
float cell_size;
float iso_level;
float *samples; // (CHUNK_CELLS + 1)^3 values, plus optional halo
uint32_t revision; // incremented after every accepted edit
bool dirty;
} VoxelChunk;
static inline size_t sample_index(int x, int y, int z) {
const size_t side = CHUNK_CELLS + 1;
return ((size_t)z * side + (size_t)y) * side + (size_t)x;
}
```
That array is enough for cell classification, but not always enough for normals. A central-difference gradient at a boundary sample needs one value on the other side. The simple solution is a one-sample halo, filled from the canonical global field or a neighbour chunk. [[Seamless Chunked Voxel Terrain]] goes deeper into that boundary contract.
## Picking a point to edit
The mouse position is two-dimensional; the brush needs a stable 3D point. raylib exposes `GetScreenToWorldRay` to turn the cursor and camera into a world-space ray. The ray then needs a hit against the current terrain.
There are two useful paths:
- **Mesh picking** — call `GetRayCollisionMesh` against the visible chunk meshes. This is simple and gives a precise triangle hit, but it can be stale for one frame while an edit is being rebuilt.
- **Field picking** — intersect the ray with a chunk AABB and sample or sphere-trace the scalar field until the sign changes. This keeps picking tied to the source of truth and works before a mesh exists, but it needs a step policy and an adequate field bound.
For a small Raym-style scene, mesh picking is a good first pass. A production editor usually keeps the AABB broadphase and uses a field query for the final point, so an old render mesh cannot make a brush jump.
```c
Ray ray = GetScreenToWorldRay(GetMousePosition(), camera);
RayHit hit = field_hit(ray, world_bounds, field, iso_level);
if (!hit.found) {
return; // do not edit the sky
}
Brush brush = {
.centre = hit.position,
.radius = 2.5f,
.strength = 0.8f,
.mode = BRUSH_ADD
};
apply_brush(&world, brush);
```
The exact `field_hit` implementation depends on whether the field is a true distance bound. [[Ray marching and SDFs]] explains why stepping by an arbitrary density is unsafe; for a generic sampled field, use a conservative step, a coarse-to-fine search, or a ray/mesh collision followed by a field refinement.
## A brush is a local field operation
For an SDF-like field with negative values inside, a spherical brush has:
```c
float brush_sdf(Vector3 p, Vector3 centre, float radius) {
return Vector3Distance(p, centre) - radius;
}
```
Unioning a solid brush is `min(terrain, brush)`. Subtracting it is `max(terrain, -brush)`. That is exact for distance fields, but an editable terrain is often a density field with a cheaper, author-friendly convention. In that case an additive brush can simply lower the density near its centre and a subtractive brush can raise it.
```c
static float clamp01(float x) {
return x < 0.0f ? 0.0f : (x > 1.0f ? 1.0f : x);
}
static float smooth_falloff(float distance, float radius) {
// 1 at the centre, 0 at the boundary; C's smoothstep is written out
// here so the curve and its endpoint behaviour are obvious.
float t = 1.0f - clamp01(distance / radius);
return t * t * (3.0f - 2.0f * t);
}
void edit_sample(float *value, Vector3 sample, Brush brush, float cell_size) {
float d = Vector3Distance(sample, brush.centre);
if (d >= brush.radius) return;
float amount = brush.strength * smooth_falloff(d, brush.radius);
amount *= cell_size; // strength is expressed in world units
*value += brush.mode == BRUSH_CARVE ? amount : -amount;
}
```
The `cell_size` multiplication is a policy, not a mathematical requirement. The useful rule is that changing resolution should not silently make a brush ten times stronger. Keep brush strength in world units and test it at two voxel resolutions.
For a true SDF, preserve the field operation instead:
```c
float old_d = sample_field(p);
float tool_d = brush_sdf(p, centre, radius);
float new_d = mode == BRUSH_ADD ? fminf(old_d, tool_d)
: fmaxf(old_d, -tool_d);
store_field(p, new_d);
```
Do not mix these two conventions accidentally. A subtractive edit that looks correct in one sign convention can fill a hole in the other.
## Finding the dirty region
A brush only changes samples inside its axis-aligned bounding box. Convert the world-space box to global sample coordinates, expand by one sample for gradients, and map the result to chunk coordinates. The expansion matters even when a brush does not cross a chunk boundary: a normal at a nearby surface vertex may read a sample just outside the brush's geometric support.
```c
Box edit_box = box_from_sphere(brush.centre, brush.radius);
Box sample_box = world_to_sample_box(edit_box, cell_size);
sample_box = expand(sample_box, 1); // central differences / halo
for (ChunkCoord c : chunks_overlapping(sample_box, CHUNK_CELLS)) {
mark_dirty(&world, c);
}
```
If the edit touches a boundary sample, mark both owners. If the implementation stores only one canonical copy of a boundary sample, the neighbour still needs a remesh because its cells consume that value. In practice, marking the 26 neighbouring chunks around the expanded box is simpler and rarely expensive; a tighter face/edge/corner classification can come later.
## Remeshing a dirty chunk
The remesh job is a small pipeline:
1. Snapshot the chunk revision and read its samples, including the halo.
2. Walk the `N³` cells and build each eight-bit Marching Cubes case.
3. Interpolate edge crossings and emit indexed triangles.
4. Compute normals from the field gradient (see [[Normals for Implicit Surfaces]]).
5. Return a CPU mesh plus the revision it was built from.
6. On the render thread, install it only if the revision is still current.
That last check prevents an old background job from winning a race against a newer brush stroke. It is the same idea as a tiny optimistic transaction: the job reads version `17`; if the chunk is already at version `18`, discard the result and rebuild from a fresh snapshot.
The cell traversal itself is ordinary Marching Cubes:
```c
for (int z = 0; z < CHUNK_CELLS; ++z)
for (int y = 0; y < CHUNK_CELLS; ++y)
for (int x = 0; x < CHUNK_CELLS; ++x) {
Corner c[8] = load_cell_corners(chunk, x, y, z);
int mask = 0;
for (int i = 0; i < 8; ++i)
if (c[i].value < iso_level) mask |= 1 << i;
const int *tri = tri_table[mask];
for (int i = 0; tri[i] != -1; i += 3) {
Vertex a = edge_vertex(c, edge_table[mask], tri[i]);
Vertex b = edge_vertex(c, edge_table[mask], tri[i + 1]);
Vertex d = edge_vertex(c, edge_table[mask], tri[i + 2]);
mesh_add_triangle(&mesh, a, b, d);
}
}
```
An initial implementation can emit three vertices per triangle. Once the update loop is stable, deduplicate by a canonical edge key `(global cell coordinate, edge axis)` as described in [[Marching Cubes]]. That reduces memory and avoids three slightly different normals for one geometric vertex.
## Handing the result to raylib
Mesh generation can run away from the render thread; OpenGL resource calls generally cannot. Keep the boundary between the two explicit:
```c
// Worker thread: CPU-only data.
MeshData *next = build_chunk_mesh(snapshot);
queue_mesh_result(chunk_id, snapshot.revision, next);
// Render thread: ownership and GPU calls.
while (pop_mesh_result(&result)) {
if (result.revision != chunk_revision(result.chunk_id)) {
free_mesh_data(result.mesh); // stale job
continue;
}
unload_chunk_mesh(result.chunk_id);
upload_chunk_mesh(result.chunk_id, result.mesh); // UploadMesh / buffers
}
```
raylib's `UploadMesh` and `UpdateMeshBuffer` are useful here, but variable triangle counts make a full replacement easier to reason about than in-place updates at first. Keep the old mesh visible until the new upload succeeds; a sculpt stroke should not flash a hole simply because the CPU job is one frame late.
## Keeping the frame responsive
The cost of an edit has two independent parts:
- **Field cost** — `O(r³)` samples for a radius `r` brush. Limit the brush AABB and do not scan the entire world.
- **Mesh cost** — `O(N³)` for every dirty chunk with a straightforward Marching Cubes pass. Chunk size is therefore a responsiveness knob, not merely a streaming unit.
A few practical rules help:
- coalesce mouse events while a stroke is held, then submit one unioned dirty box per frame;
- cap the number of remesh jobs started per frame and keep old meshes for the rest;
- prioritise chunks near the camera or reticle;
- use a count/scan/emit GPU path only after profiling CPU meshing and upload bandwidth (see [[Compute shaders]]);
- store positions as chunk-local floats and add an integer world origin at draw time to reduce large-world precision loss.
The first useful metric is not raw triangles per second. Measure brush-to-visible-mesh latency, dirty sample count, CPU meshing time, upload time, and the number of stale jobs discarded. Those numbers tell me which part of the loop is actually failing.
## Persistence: edits as data
If the procedural field is reproducible from a seed, edits can be stored as a small operation log instead of a whole world-sized array:
```c
typedef struct {
Vector3 centre;
float radius;
float strength;
uint8_t mode;
uint32_t sequence;
} BrushOp;
```
On load, evaluate the base field and replay operations in sequence. This is attractive for experiments and undo/redo, but replay time grows with the log. A hybrid stores recent operations and periodically bakes them into chunk deltas. The important invariant is that the same operation order produces the same samples, including at chunk boundaries.
## Things that tripped me up
- **Editing triangles instead of samples** — it works for one frame and becomes impossible to reconcile with neighbouring chunks, collision, or a reload. The field owns the edit.
- **A ray hit on the old mesh** — after a stroke, the rendered triangle and the field can disagree briefly. Refine the hit against the field or deliberately show the one-frame latency.
- **No halo for gradients** — geometry was seamless, but every chunk had a slightly different lighting border. A normal needs neighbour samples too.
- **Brush strength depended on voxel size** — a brush tuned at one resolution became a crater at another. Express radius and strength in world units.
- **Stale worker results** — asynchronous jobs can finish out of order. A monotonically increasing chunk revision is cheap insurance.
- **Uploading from a worker** — CPU mesh generation is easy to move off the frame thread; graphics-resource ownership is not. Queue data, then upload where the graphics context lives.
- **Equality at the iso-level** — exact `value == iso` corners can make neighbouring cells choose different topology if tie-breaking is inconsistent. Pick one comparison (`< iso` here), handle endpoint interpolation deliberately, and test a flat plane.
- **Forgetting neighbouring dirty chunks** — a changed boundary sample affects every cell that reads it. Expand the region and mark all consumers, including the normal halo.
## References
- [[Raym - Interactive Terrain Generation with Marching Cubes|Raym]] — the small C/Raylib project that motivated this loop ([source repository](https://github.com/Rydgel/raym)).
- [[Marching Cubes]] — the case table, interpolation, ambiguity, and indexed vertex discussion.
- [raylib header](https://github.com/raysan5/raylib/blob/master/src/raylib.h) — official signatures for `GetScreenToWorldRay`, `GetRayCollisionMesh`, `UploadMesh`, and `UpdateMeshBuffer`.
- [Lorensen & Cline — Marching Cubes (1987)](https://doi.org/10.1145/37402.37422) — the original iso-surface extraction paper.
- [[Seamless Chunked Voxel Terrain]] — boundary sampling and dirty-neighbour rules.
- [[Normals for Implicit Surfaces]] — gradients, finite differences, and chunk-stable shading.
- [[Compute shaders]] — the GPU count/scan/emit route once CPU meshing is understood.
---
Back to [[Notes/Index|Notes]] · see also [[Raym - Interactive Terrain Generation with Marching Cubes|Raym]] · [[Marching Cubes]] · [[Seamless Chunked Voxel Terrain]]