# ==Normals for implicit surfaces==
<p class="doc-sub">// status: seedling</p>
A mesh normal is easy to store. An implicit surface has no vertices until the moment we extract it, so the normal has to come from the field that defines the surface. For a field <code>f(p)</code> and iso-level <code>iso</code>, the surface is <code>f(p) = iso</code>; its normal is the normalized gradient:
$
\mathbf{n}(p) = \frac{\nabla f(p)}{\lVert\nabla f(p)\rVert}
$
That one equation connects [[Ray marching and SDFs]], [[Marching Cubes]], and the lighting in [[Physically Based Rendering]]. It is also the reason a chunk can have matching geometry but a visible lighting seam: the two chunks may be using different samples to approximate the same gradient.
## What the gradient means
The gradient points in the direction where the field increases fastest. With the convention used in [[Designing Scalar Fields for Procedural Terrain|the scalar-field note]], <code>f < iso</code> is solid and <code>f > iso</code> is empty. If the field increases toward empty space, the gradient points out of the terrain.
For a true signed distance field, the gradient magnitude is close to one near a smooth surface. For a generic density field it can be any scale, so only the direction matters:
~~~c
Vector3 normalise_gradient(Vector3 g) {
float len2 = Vector3LengthSqr(g);
if (len2 < 1e-12f) return (Vector3){ 0.0f, 1.0f, 0.0f };
return Vector3Scale(g, 1.0f / sqrtf(len2));
}
~~~
The fallback is a policy. A zero gradient means the field is locally flat, noisy, or numerically under-resolved; normalising it would produce NaNs or a frame that flickers between arbitrary directions. A face normal, the previous frame's normal, or a material-specific up vector can be a better fallback than silently accepting invalid data.
## Finite differences
Most sampled fields do not have an analytic derivative. Approximate one with central differences. If <code>h</code> is measured in world units:
~~~c
Vector3 field_gradient(Vector3 p, float h) {
Vector3 dx = { h, 0.0f, 0.0f };
Vector3 dy = { 0.0f, h, 0.0f };
Vector3 dz = { 0.0f, 0.0f, h };
return (Vector3){
(field(p + dx) - field(p - dx)) / (2.0f * h),
(field(p + dy) - field(p - dy)) / (2.0f * h),
(field(p + dz) - field(p - dz)) / (2.0f * h)
};
}
Vector3 normal_at(Vector3 p, float h) {
return normalise_gradient(field_gradient(p, h));
}
~~~
Central differences cost six field evaluations and cancel the first-order error of a forward difference. A forward difference uses <code>field(p + h) - field(p)</code> and is cheaper, but it is biased toward one side. In a chunked terrain that bias often shows up as a directional lighting change at a boundary.
Choose <code>h</code> in the same units as the field sample spacing. A starting point is one cell width for a grid field, or a small fraction of the feature scale for an analytic SDF. Smaller is not always better: if the field is stored as <code>float</code>, subtracting two nearly equal values loses precision; if the field is noisy, a larger <code>h</code> acts like a little smoothing filter.
## Gradients at a Marching Cubes vertex
Marching Cubes finds a vertex along an edge by interpolating two scalar values:
~~~c
float t = (iso - value_a) / (value_b - value_a);
Vector3 p = Vector3Lerp(position_a, position_b, t);
~~~
There are two common ways to attach a normal.
### Evaluate at the final position
If <code>field(p)</code> is cheap and deterministic, evaluate the gradient at the interpolated world position. This is the closest match to the continuous implicit surface:
~~~c
Vector3 p = edge_position(a, b, iso);
Vector3 n = normal_at(p, cell_size);
mesh_add_vertex(p, n);
~~~
It needs more field reads, and a procedural noise function may be much more expensive than loading a cached sample.
### Interpolate corner gradients
Compute a gradient at each of the eight cell corners, then interpolate with the same <code>t</code> used for the edge position:
~~~c
Vector3 edge_normal(Corner a, Corner b, float t) {
Vector3 g = Vector3Lerp(a.gradient, b.gradient, t);
return normalise_gradient(g);
}
~~~
This is a good fit for a dense grid: load the scalar and gradient caches once per cell, then reuse them for all edge crossings. It is also seam-safe as long as the corners and their gradient stencils are sourced from the same global field. The result is an approximation to the gradient of the trilinear interpolant, not necessarily the gradient of the original continuous function; for voxel terrain that trade is usually worth the memory bandwidth saved.
Do not interpolate already-normalized vectors if the gradient magnitudes differ wildly. Interpolate the raw gradients, then normalise once. For nearly planar fields the distinction is invisible; around a sharp density change it matters.
## Matching normals across chunks
Two chunks do not share GPU vertices, so they cannot share an index. They can still share a normal value by following the same rules:
- use the same global sample coordinates and iso-level;
- use the same finite-difference step in world units;
- read one canonical boundary value, not two edited copies;
- provide a one-sample halo on every side, or query the neighbour through a canonical accessor;
- compute positions in the same coordinate space before applying a transform.
An edit near a boundary therefore dirties the neighbour even when no triangle in that neighbour changes position. Its gradient may change. [[Seamless Chunked Voxel Terrain]] describes the full sample and halo contract.
For a central difference with a one-sample radius, the dirty region must expand by one sample in every axis. A brush can be visually contained in chunk A while changing the normal of a boundary vertex in chunk B. That is not over-invalidation; it is the stencil's real dependency.
## Face normals: the useful fallback
The classic fallback is the triangle face normal:
~~~c
Vector3 face_normal(Vector3 a, Vector3 b, Vector3 c) {
return normalise_gradient(Vector3Cross(
Vector3Subtract(b, a),
Vector3Subtract(c, a)
));
}
~~~
It is exact for the emitted triangle but produces faceted shading when every triangle uses its own normal. Averaging adjacent face normals can make a conventional mesh smooth:
~~~c
for (Triangle tri : mesh.triangles) {
Vector3 n = face_normal(tri.a, tri.b, tri.c);
vertex_normal[tri.a] += n;
vertex_normal[tri.b] += n;
vertex_normal[tri.c] += n;
}
for (Vertex v : mesh.vertices)
v.normal = normalise_gradient(vertex_normal[v.index]);
~~~
Area-weighted accumulation (adding the unnormalised cross product) gives large triangles more influence. Angle-weighted accumulation can behave better on irregular topology. Both are mesh-derived approximations, and both can disagree across separately generated chunks if the one-ring of adjacent faces is different. Use them for a fallback or a deliberately faceted style, not as the primary normal for a smooth implicit terrain.
## The sign and the transform
If the field uses <code>f > iso</code> for solid, the outward normal is <code>-∇f</code> under the same geometric surface. It is easy to get a perfectly smooth surface lit from the inside. A single test sphere catches this: place a light above it and verify the visible hemisphere has a normal pointing toward the light.
Normals also do not transform like positions. For a model transform with a non-uniform scale, the correct world-space normal is:
$
\mathbf{n}_{world} =
\operatorname{normalize}\left((M^{-1})^{T}\mathbf{n}_{local}\right)
$
The inverse-transpose is the normal matrix. Uniform scale is the special case where a normal direction can be multiplied by the model matrix and renormalized. If the field itself is evaluated in world space, do not apply the inverse-transpose a second time; keep the space of every vector explicit.
## Discontinuities and sharp features
A gradient exists almost everywhere for a smooth field, not necessarily at a hard CSG edge. At a union seam with <code>min(a, b)</code>, the derivative changes branch; the result is continuous in value but can have a visible normal crease. That may be desirable for a carved wall. A smooth union spreads the transition and gives a more organic normal.
Marching Cubes also rounds sharp features because its vertices live on cell edges. If the intent is to preserve a corner, [[Dual Contouring]] uses Hermite positions and normals in a quadratic error function to place a vertex inside the cell. The same gradient quality matters there, but the error is more obvious: a noisy normal moves the QEF solution rather than merely changing a highlight.
Do not blur normals to hide a topology problem. A normal filter can conceal a crack for a camera angle while leaving the mesh physically open. Fix boundary sample ownership and transition geometry first.
## A compact shader normal
When the scalar field is evaluated in a shader, a central-difference normal is concise:
~~~glsl
vec3 field_normal(vec3 p, float h) {
vec2 e = vec2(h, 0.0);
float dx = scene_field(p + e.xyy) - scene_field(p - e.xyy);
float dy = scene_field(p + e.yxy) - scene_field(p - e.yxy);
float dz = scene_field(p + e.yyx) - scene_field(p - e.yyx);
return normalize(vec3(dx, dy, dz));
}
~~~
The actual SDF function may include a material ID or an expensive noise stack. Cache repeated evaluations when possible, and use a coarser normal step only deliberately. A normal epsilon that is constant in world units can become too small at far camera distances; a screen-space or distance-scaled epsilon can be more stable for pure ray-marched images, while a voxel mesh usually wants the grid spacing as its reference.
## GPU and cached gradients
For a compute mesher, a gradient cache can be filled in a separate pass:
1. load the scalar samples, including a halo;
2. compute the three finite differences;
3. store raw gradients or a compact encoding;
4. run Marching Cubes and interpolate gradients at edge vertices.
The extra buffer costs memory, but it turns six random field reads per vertex into predictable neighbour loads. A workgroup can stage a scalar tile in shared memory and calculate many gradients with one halo load; see [[Compute shaders]] for the synchronization pattern.
If memory is tighter than ALU, recompute gradients during vertex emission. Profile with the actual noise function: a texture-backed field tends to be bandwidth-bound, while a procedural hash/noise field may be ALU-bound.
## Testing normals instead of eyeballing them
Use analytic fields as ground truth:
- sphere: <code>f(p) = length(p) - r</code>, expected normal <code>normalize(p)</code>;
- plane: <code>f(p) = dot(n, p) + d</code>, expected constant <code>n</code>;
- tilted plane crossing a chunk boundary, to catch axis swaps and halo mistakes;
- a smooth union, to inspect the chosen blend behaviour.
Measure the angular error:
$
\theta = \arccos(\operatorname{clamp}(\mathbf{n}_{computed}\cdot\mathbf{n}_{expected}, -1, 1))
$
A false-colour normal view is still useful, but a numeric test distinguishes a noisy field from a wrong coordinate transform. Also test the zero-gradient fallback explicitly; invalid normals often enter through one degenerate sample rather than the general path.
## Things that tripped me up
- **Using triangle normals by default** — the mesh looked faceted even though the field was smooth. The gradient belongs to the field and survives cell adjacency.
- **Normalising before interpolation** — interpolating unit vectors can bias the result when corner gradients have different magnitudes. Blend raw gradients, then normalise.
- **Picking the wrong sign** — the surface looked correct but was lit from inside. Check a sphere and document which side is solid.
- **No derivative halo** — geometry matched across chunks while highlights exposed a grid. Central differences read one sample beyond the boundary.
- **Choosing an epsilon from habit** — an epsilon of <code>0.001</code> is not meaningful in every world scale. Tie it to cell size, feature scale, and precision.
- **Finite-differencing local coordinates** — a chunk-local field can produce a plausible normal that disagrees with its neighbour. Sample the global field.
- **Transforming a world-space gradient twice** — inverse-transpose is required for local-space normals under non-uniform scale, not for a gradient already evaluated in world space.
- **Normalising zero** — flat or cancelled gradients create NaNs. Check the squared magnitude and choose a visible fallback.
- **Smoothing over a topology crack** — filtered normals can hide the lighting symptom but do not repair missing triangles. Fix samples and transition meshes first.
## References
- [[Marching Cubes]] — gradient-based shading, edge interpolation, and the mesh extraction context.
- [[Ray marching and SDFs]] — finite-difference SDF normals and the implicit-field viewpoint.
- [[Designing Scalar Fields for Procedural Terrain]] — sign conventions, generic densities, and field composition.
- [[Seamless Chunked Voxel Terrain]] — shared samples, halos, and boundary invalidation.
- [[Dual Contouring]] — Hermite normals and why derivative quality affects sharp-feature placement.
- [Lorensen & Cline — Marching Cubes (1987)](https://doi.org/10.1145/37402.37422) — the original paper explicitly uses the normalized data gradient for shading.
- [Hart — Sphere Tracing (1996)](https://doi.org/10.1007/s003710050084) — implicit surfaces, distance bounds, and gradient normals in ray marching.
- [Ju, Losasso, Schaefer & Warren — Dual Contouring of Hermite Data (2002)](https://doi.org/10.1145/566570.566586) — Hermite intersections and normals for adaptive implicit meshes.
---
Back to [[Notes/Index|Notes]] · see also [[Marching Cubes]] · [[Ray marching and SDFs]] · [[Physically Based Rendering]]