# ==Designing scalar fields for procedural terrain==
<p class="doc-sub">// status: seedling</p>
A procedural terrain is not really a mesh generator. It is a function, sampled on a grid, followed by a mesher. The quality of the final triangles is often decided before [[Marching Cubes]] sees its first cell: by the sign convention, the frequency of the smallest feature, the continuity of the noise, and whether the field means a distance or merely a useful inside/outside score.
This is the authoring side of [[Raym - Interactive Terrain Generation with Marching Cubes|Raym]]. The goal is a field that is expressive enough for hills, cliffs, and caves, but disciplined enough to sample consistently across [[Seamless Chunked Voxel Terrain|chunk boundaries]], derive stable [[Normals for Implicit Surfaces|normals]], and survive interactive edits.
## Start with one contract
Let <code>f(p)</code> be a scalar value at world position <code>p</code>, and let <code>iso</code> be the surface threshold. I use:
~~~text
f(p) < iso → solid
f(p) = iso → surface
f(p) > iso → empty
~~~
The surface orientation follows from this choice. If <code>f</code> increases toward empty space, <code>∇f</code> points outward. Flip the sign and the geometry is the same, but the normal direction and the meaning of add/carve operations flip too. Write the convention at the top of the implementation and test it with a plane before adding noise.
There are two related but different kinds of fields:
- **Signed distance function (SDF)** — the magnitude approximates the nearest-surface distance and the gradient has a useful bound. Sphere tracing in [[Ray marching and SDFs]] relies on that guarantee.
- **Implicit density/value field** — only the sign or threshold crossing is meaningful. Layered noise terrain usually lives here; it can still be meshed beautifully, but stepping by its value as if it were a distance is unsafe.
Calling every procedural field an SDF makes later bugs mysterious. Name the stronger promise when it is actually true.
## A terrain starts as a height field
A heightfield is a useful baseline because its intent is obvious:
~~~c
float terrain_density(Vector3 p) {
float h = base_height(p.x, p.z);
return p.y - h; // below h is solid: f < 0
}
~~~
With the convention above, the surface is where <code>p.y == h(x,z)</code>, and the solid is below it. A small example using a few octaves of noise:
~~~c
float base_height(float x, float z) {
float n = 0.0f;
float amplitude = 1.0f;
float frequency = 0.004f;
for (int octave = 0; octave < 5; ++octave) {
n += amplitude * noise2(x * frequency, z * frequency);
frequency *= 2.0f; // lacunarity
amplitude *= 0.5f; // gain
}
return SEA_LEVEL + HEIGHT_SCALE * n;
}
~~~
The exact noise implementation is less important than the units. If one world unit is one metre, <code>frequency = 0.004</code> means a feature scale of roughly 250 metres. Use names such as <code>hill_scale</code> and <code>cave_radius</code> rather than scattering unexplained constants through the function.
A height field cannot overhang. That limitation is useful at first: it makes the relationship between a scalar value and the visible surface easy to inspect. Add volumetric features only after this baseline has a stable histogram and seam test.
## fBm: detail at several scales
Fractional Brownian motion (fBm) is a sum of noise octaves:
$
N(p) = \sum_{i=0}^{k-1} a_i\,n(f_i p), \qquad
f_{i+1} = \lambda f_i,\quad a_{i+1} = g a_i
$
where <code>λ</code> is lacunarity, <code>g</code> is gain, and <code>n</code> is a bounded noise function. More octaves do not automatically mean more realism. They add smaller features, and those features must be sampled often enough to survive the voxel grid.
A good authoring pass has three knobs:
- **Macro shape** — low frequency, large amplitude, controls continents and major hills.
- **Medium shape** — ridges, valleys, and plateaus that give the terrain a readable silhouette.
- **Micro detail** — small amplitude, high frequency, useful only if the chosen cell size can represent it.
If the cell spacing is <code>s</code>, a feature much smaller than two or three samples across will alias or disappear. A terrain can look detailed in a scalar slice and turn into noisy triangles after extraction because the grid is undersampling the field. Either lower that octave's frequency, prefilter it, or use a finer LOD where it matters.
## Caves are composition, not a second renderer
Once a height field works, introduce a cave volume. Let <code>c(p)</code> be a cave SDF where negative means “inside the empty cave”. To subtract that cave from solid terrain, use the SDF difference:
~~~glsl
float subtract(float a, float b) {
return max(a, -b);
}
~~~
For an approximate terrain density this is still a useful mental model:
~~~c
float terrain_field(Vector3 p) {
float ground = p.y - base_height(p.x, p.z);
float cave = cave_field(p); // negative inside the void
float with_caves = fmaxf(ground, -cave);
return with_caves;
}
~~~
A cave field can be a warped noise volume, a capsule, or a union of authored tunnels. Keep the coordinate systems explicit: a cave frequency should be expressed in world units, not silently tied to the number of samples in one chunk.
For a cave network made from several primitives, hard <code>min</code>/<code>max</code> operations create a mathematically sharp transition. That may be exactly what a carved tunnel wants. A smooth blend makes more organic joins:
~~~glsl
float smooth_min(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
float cave_union(float a, float b, float blend_width) {
return smooth_min(a, b, blend_width);
}
~~~
The blend width has world units. If it is smaller than a voxel cell, the field contains a feature the mesh cannot represent reliably.
## Domain warping and ridged terrain
Domain warping changes where the noise is evaluated rather than adding noise directly:
~~~c
Vector3 warp(Vector3 p) {
return (Vector3){
noise3(p * 0.008f + (Vector3){ 11.0f, 0.0f, 0.0f }),
noise3(p * 0.008f + (Vector3){ 0.0f, 17.0f, 0.0f }),
noise3(p * 0.008f + (Vector3){ 0.0f, 0.0f, 29.0f })
} * WARP_DISTANCE;
}
float warped_height(float x, float z) {
Vector3 p = { x, 0.0f, z };
Vector3 w = warp(p);
return base_height(x + w.x, z + w.z);
}
~~~
Warping is powerful because a regular pattern becomes folded and less obviously procedural. It also raises the field's effective frequency and can make gradients steep. Start with a bounded warp distance, inspect the gradient magnitude, and reduce the warp before increasing the octave count.
A ridged signal is often built from <code>1 - abs(noise)</code>, then squared or remapped. It emphasises crests instead of broad undulations:
~~~c
float ridged(float x, float z) {
float n = noise2(x, z);
n = 1.0f - fabsf(n);
return n * n;
}
~~~
This is an authoring primitive, not a universal recipe. A few distinct signals with explicit roles are easier to art-direct than one enormous fBm expression.
## CSG and material channels
The field can carry more than one number. Geometry needs a scalar; shading often needs a material ID or a blend weight. One approach is to evaluate the winning primitive and carry its material along:
~~~c
typedef struct {
float value;
uint16_t material;
} FieldSample;
FieldSample choose_union(FieldSample a, FieldSample b) {
return a.value < b.value ? a : b;
}
~~~
For a smooth union, the material should blend with the same interpolation factor used by the distance/value blend. Otherwise geometry transitions gradually while the albedo snaps at the seam. Store a continuous weight when a hard ID is too abrupt, then quantise it only in the material pass.
CSG operations are easiest to reason about with SDF signs:
~~~glsl
float op_union(float a, float b) { return min(a, b); }
float op_subtract(float a, float b) { return max(a, -b); }
float op_intersect(float a, float b) { return max(a, b); }
~~~
For generic densities, equivalent-looking arithmetic may not preserve distance magnitudes or material semantics. Treat the formulas as topology operations first; only rely on distance bounds when the inputs and transforms preserve them.
## Editing a procedural field
A procedural base is immutable in concept; an edit is a delta layered on top. There are two useful representations:
- **Sample delta** — store changed sample values or additive deltas in the chunk. Fast to evaluate after the first edit, larger to persist.
- **Operation log** — store brush centre/radius/strength/mode and replay over the base field. Compact and natural for undo/redo, but replay cost grows.
A generic additive edit with solid below zero is:
~~~c
float apply_brush_delta(float base, float distance,
float radius, float strength, BrushMode mode) {
if (distance >= radius) return base;
float t = 1.0f - clamp01(distance / radius);
float falloff = t * t * (3.0f - 2.0f * t);
float delta = strength * falloff;
return mode == BRUSH_CARVE ? base + delta : base - delta;
}
~~~
For a true SDF, prefer <code>min</code>/<code>max</code> union and subtraction with a brush SDF, as described in [[From Brush Stroke to Mesh - Real-Time Voxel Terrain Editing in C]]. Repeated arbitrary deltas gradually destroy the distance property; that is acceptable if the field is used only for contouring, but not if a ray marcher later assumes a safe step.
## Keep the field sampleable
A field function is part of the runtime contract, not merely a generator script. These rules keep it practical:
- **Deterministic** — same seed and global sample coordinate produce the same value.
- **Continuous enough** — avoid discontinuous <code>if</code> branches at scales the mesh should show; use smooth blends where a hard transition is not intended.
- **Bounded** — know the approximate range so an iso-level is meaningful and edits cannot overflow a narrow storage type.
- **Resolution-aware** — do not add features finer than the current cell size unless the field is filtered or the mesh LOD guarantees enough samples.
- **Chunk-independent** — always use global coordinates and a shared seed; see [[Seamless Chunked Voxel Terrain]].
- **Inspectable** — expose slices, histograms, min/max, and gradient magnitude in a debug view.
A cheap diagnostic is to render a horizontal slice of <code>f</code> as grayscale with the iso contour overlaid. If the contour jumps between adjacent slices, the field has a discontinuity or the sampling step is too large. If the histogram puts almost every sample on one side of the iso, the mesh will be mostly empty or solid no matter how good the noise looks.
## Iso-level is an authoring control
There is no universal “correct” iso-level. If the field is <code>p.y - h</code>, zero is natural. If it is a sum of noise and cave terms, choose an iso after looking at the value distribution. Shifting the iso moves the surface through the same field; shifting amplitudes changes the shape itself.
Treat the pair <code>(field scale, iso)</code> as a unit. Changing one without revisiting the other is a common reason a terrain suddenly disappears. Save both with the world parameters so a regenerated mesh is reproducible.
## Things that tripped me up
- **Calling a density an SDF** — a scalar crossing is enough for Marching Cubes, but sphere tracing needs a conservative distance bound. The name changes the safety assumptions.
- **Inverting the solid convention halfway through** — the mesh still appeared, but add/carve, normals, and cave subtraction all felt backwards. Test <code>f(p) = p.y - h</code> first.
- **Noise units with no scale** — “frequency 2” means nothing until world units and cell size are explicit. Name feature scales and derive frequency from them.
- **Adding high-frequency detail to a coarse grid** — the field looked rich in a slice and aliasing turned it into unstable triangles. Respect the sampling theorem in spirit: keep a few samples across the smallest feature.
- **Warping without a bound** — domain warping amplified gradients and made both meshing and ray marching less predictable. Keep warp distance and octave count measurable.
- **Using local chunk coordinates** — every chunk repeated a pattern or disagreed at its boundary. Noise keys must be global integer lattice coordinates plus a seed.
- **Smooth geometry, hard material** — <code>smooth_min</code> blended the shape while the material ID switched abruptly. Blend material weights with the same factor.
- **Letting deltas accumulate forever** — additive brush edits are not an exact SDF operation. Bake, clamp, or accept that the field is now a generic implicit density.
- **Changing iso-level as a patch** — it can hide a bad amplitude balance but does not fix the underlying field range. Instrument min/max and choose the parameter intentionally.
## References
- [[Marching Cubes]] — extracting a mesh from a sampled scalar field.
- [[Ray marching and SDFs]] — the distance-bound distinction, CSG, domain warping, and gradient-based normals.
- [[From Brush Stroke to Mesh - Real-Time Voxel Terrain Editing in C]] — applying a brush and scheduling dirty chunk remeshes.
- [[Seamless Chunked Voxel Terrain]] — deterministic global sampling and boundary halos.
- [[Normals for Implicit Surfaces]] — deriving normals from a scalar field.
- [Ken Perlin — An Image Synthesizer (SIGGRAPH 1985)](https://doi.org/10.1145/325334.325247) — the original procedural noise paper.
- [Ken Perlin — Improving Noise (2002)](https://mrl.cs.nyu.edu/~perlin/paper445.pdf) — the author's improved gradient-noise formulation.
- [Hart — Sphere Tracing (1996)](https://doi.org/10.1007/s003710050084) — why a valid distance bound matters when marching rays.
- [Inigo Quilez — distance functions](https://iquilezles.org/articles/distfunctions/) — practical SDF primitives and composition patterns.
---
Back to [[Notes/Index|Notes]] · see also [[Raym - Interactive Terrain Generation with Marching Cubes|Raym]] · [[Marching Cubes]] · [[Ray marching and SDFs]]