Skip to content

Building an open world in the browser, part 13: Terrain sculpting and the death of the math function

By Oleg Sidorkin, CTO and Co-Founder of Cinevva

New here? Use the series guide. It explains what a spike is and links all parts.

For twelve parts we built a terrain engine that you could look at. Fly over it. Admire the seams not cracking. This time we wanted to touch it.

The goal sounded simple: let a player sculpt the terrain with a brush, in real time, without breaking any of the systems we spent 24 spikes building. It took three attempts, two bugs that looked like rendering failures but were data model failures, and a fundamental rethinking of how terrain data should work.

Three false starts

Spike 25 was supposed to be the easy one. The production codebase already had a raycaster that hits terrain meshes. The placement tool uses it for dropping objects. A brush tool follows the same shape, it just modifies heightmap values instead of spawning a prefab. Simple.

Attempt one: I built it directly into the production world/client/ TypeScript code. New terrain-brush.ts, modifications to chunk.ts, protocol changes, Vue component updates. Within an hour I had a brush that sort of worked, but chunk boundaries showed visible normal discontinuities. I couldn't tell if the bug was my brush code, the existing chunk stitching, or some interaction with the full render loop. That's exactly the situation the spike methodology exists to prevent. I'd skipped the rule and paid for it immediately. Reverted everything.

Attempt two: standalone spike, but I reached for Three.js 0.170.0 and WebGL. The production code uses WebGL, so it felt natural. But Spikes 13-24 had all moved to WebGPU. Building a WebGL brush spike would prove it works on the legacy renderer, not the one we're migrating toward. Wrong direction. Started over.

Attempt three: WebGPU, WebGPURenderer, compute shader for vertex generation, matching the Spike 22 stack. This time the architecture was right. Five brush operations working: raise, lower, smooth, flatten, noise. Brush cycle P95 under 4ms on M1.

And the seam bug was still there.

Open Spike 25 in a new tab ↗ · View source

The seam bug that wouldn't die

The standard fix for cross-chunk normal computation is border overlap: each chunk stores one extra ring of data from its neighbors, so the normal calculation at the boundary can sample both sides. I implemented that. Copied neighbor edge data into an expanded buffer. The seams still cracked.

I dug into the math. At the boundary between chunk A (cx=-1) and chunk B (cx=0), both chunks need to compute the same normal at the shared vertex. Chunk A's shader sampled mix(own_col31, own_col32, 0.85). Chunk B sampled mix(neighbor_edge, own_col0, 0.85). Those are different bilinear interpolation paths through different data. Even with correct border data, the two chunks compute different normals for the same point.

This was the moment I realized the border copy wasn't the real bug. The real bug was the data model.

Every spike from 1 to 24 used a procedural math function called height_at(). Feed it world coordinates, get a height. Clean, global, stateless. The brush couldn't modify a math function, so I'd added a displacement buffer on top. The terrain was now height_at(x,z) + displacement[i]. The GPU shader embedded 30 lines of noise functions for the base terrain plus bilinear interpolation code for the displacement overlay. The flatten brush had to subtract height_at() to figure out what displacement value would produce the target height. Two systems, layered on top of each other, computing different things with different sampling strategies.

None of that is how a real game works. In production, authored terrain is sampled data stored in buffers. The procedural function was a convenient stand-in from the early spikes. It had served its purpose. Now it was actively causing bugs.

I killed it.

Each chunk now owns a heightmap Float32Array with actual height values. At creation, procedural noise fills it. After that, the noise function is never called again. The brush modifies the stored heights directly. The GPU shader reads from one buffer using one function: hm_at(i,j). Normals use grid-aligned central differences on the same data. No bilinear interpolation ambiguity. No two-system mismatch. The shader went from 90 lines to 40.

The seams fixed themselves. Both chunks at a shared edge now read the same discrete height values from their respective buffers (with correct neighbor interior points in the border overlap). Same data in, same normals out.

This wasn't a brush lesson. It was a data architecture lesson that the brush exposed.

The exploding mesh

Spike 26 was the volumetric counterpart. Modify a 64-cubed SDF volume with a brush, then re-mesh with marching cubes. Same question as Spike 25, but in 3D.

The first time I ran it, the mesh exploded. Long spikes shooting in every direction, like a sea urchin having a bad day.

Open Spike 26 in a new tab ↗ · View source

The MC case table I'd generated had 3840 entries instead of 4096. Sixteen missing rows starting at case 112. Every lookup after that index was shifted, so the case number no longer matched the triangulation data. When marching cubes reads the wrong entry, it creates edges where both endpoints are on the same side of the surface. The vertex interpolation t = -va / (vb - va) goes outside [0, 1] because vb - va is near zero or has the wrong sign. That places vertices far outside the volume. Multiply that by a few hundred wrong cells and you get a hedgehog.

The fix was stupid simple: copy the proven table from Spike 12 byte-for-byte. Lesson learned. Never regenerate a lookup table when a proven copy exists.

The second bug was more subtle. The smooth brush was supposed to soften terrain features. Instead it created sharp creases. The problem: I was pulling every SDF value toward zero (the isosurface). That sounds like it should smooth things, but it collapses the distance field. Voxels above and below the surface both rush toward zero, flattening everything in the brush radius. At the boundary, smoothed voxels meet unsmoothed ones with a hard step. The "smooth" brush was a crease generator.

The fix was proper Laplacian smoothing: read the 6 direct neighbors, average them, pull toward that average. This smooths the surface shape by averaging nearby geometry instead of destroying the distance field gradient.

Everything at once

Spike 27 was the integration gate. Take Spike 24's full pipeline (heightmap patches, MC chunks, Transvoxel seams, geomorph LOD) and combine it with the sampled data model from Spike 25 and both brush types.

Open Spike 27 in a new tab ↗ · View source

The first thing I did was rip height_at() out of every shader. All three compute shaders (SDF fill, heightmap patch, Transvoxel seam) now bind the same 129x129 heightmap GPU buffer and use the same hm_sample() bilinear interpolation function through a shared WGSL preamble. One data source, multiple consumers. The procedural noise functions that had lived in every shader since Spike 1 were gone.

Then the interesting problems started.

When an SDF brush locks a chunk into MC mode, the Transvoxel seam between that chunk and its heightmap neighbor needs to sample from the SDF volume, not the heightmap. I extended the seam shader with additional storage buffer bindings and per-chunk MC flags. Four boundary combinations to handle: HM-HM, HM-MC, MC-HM, MC-MC.

LOD was another puzzle. In earlier spikes, switching an MC chunk to a lower LOD meant re-filling the SDF at a coarser resolution. I replaced that with stride-based sampling: SDF data stays at full resolution (65 grid points). The MC shader computes a stride from the ratio of grid size to cell count. At LOD0, stride is 1. At LOD1, stride is 2, sampling every other voxel. Chunks can change LOD freely without touching their SDF data.

The most satisfying fix was dynamic vertical chunk spawning. Sculpt upward past the top of a chunk, and a new MC-only chunk appears above it with its SDF initialized from the boundary face of the chunk below. Sculpt downward, same thing. The world grows to fit the edits.

The last gotcha was the heightmap brush silently doing nothing to MC-locked chunks. The HM brush modifies heightmapCPU and reuploads it. MC chunks don't read from the heightmap anymore because their SDF was filled from it and then diverged. I added syncHeightmapToSdf(): after the heightmap changes, re-derive the SDF columns for any MC chunks in the brush radius and upload the new values. Both brush types now work on both chunk types.

What we actually learned

The brush spikes were supposed to answer a performance question: can sculpting run within frame budget? It can. That was the easy part.

The hard part was discovering that 24 spikes of using height_at() as the terrain truth had created an invisible dependency that broke the moment we tried to edit anything. The procedural function was clean and global and stateless, right up until it wasn't the terrain anymore.

Rules we wrote down and won't forget:

  1. Terrain height comes from sampled data. Chunks own their buffers.
  2. Procedural generation fills initial data. It is not the runtime truth.
  3. The brush modifies chunk data directly. No displacement overlays.
  4. Normals come from the same data via grid-aligned central differences.
  5. Border overlap (1 cell from neighbor interior) handles cross-chunk normals.
  6. Never regenerate a lookup table when a proven copy exists.

In part 14 we stop sculpting debug geometry and start making it look and feel like an actual place.

Technology referenced in this chapter

Sampled heightmap architecture. Terrain stored as owned data per chunk rather than evaluated from a procedural function at runtime. Each chunk holds a Float32Array of actual height values. Procedural noise fills initial data at creation time, then the function is never called again. This eliminates the dual-system mismatch between math-based terrain and edit overlays, simplifies brush operations (edit stored values directly), and makes the GPU shader trivially simple (read from buffer, compute normals via grid-aligned central differences). For open world with streaming, per-chunk ownership with 1-cell border overlap from neighbors is the standard approach. See our landscape generation guide.

SDF brush operations. Modifying a signed distance field to sculpt terrain. Add (inflate) uses smooth-step falloff around a sphere. Subtract (carve) uses the same shape negated. Smooth uses Laplacian averaging: read the 6 direct neighbors, compute their mean, pull toward the mean. The naive approach of pulling toward zero collapses the distance field and creates sharp edges. Laplacian smoothing preserves the field gradient while softening features. See SDF terrain representation.

Transvoxel with mixed data sources. Transition cells at the boundary between an MC chunk and a heightmap chunk need to sample different data on each side. The seam shader carries per-chunk flags and buffer bindings to handle all four combinations (HM-HM, HM-MC, MC-HM, MC-MC). When one side is MC-locked, the shader trilinearly interpolates the SDF buffer instead of sampling the heightmap.

Stride-based LOD for marching cubes. SDF data stored at full resolution regardless of the chunk's current LOD level. The MC shader computes a sampling stride from the ratio of SDF grid points to MC cells. At full resolution the stride is 1, at half resolution the stride is 2. This decouples SDF data from LOD changes, so chunks can transition LOD freely without rebuilding the SDF.


Part 13 of 14. Previous: Part 12 - Rings, sky fog, and what we would do again Next: Part 14 - The world comes alive Series guide: /blog/2026-02-25-open-world-browser-series-guide