Diagnosing NaN Coordinates in WGSL Shaders

Geometry disappears and nothing is logged. That is the signature of a NaN, and it is the least informative failure in the whole pipeline, because a NaN clip coordinate causes the rasterizer to discard the primitive rather than draw it wrongly — and a single NaN in a transform matrix makes every vertex NaN, so an entire layer vanishes at once. Spatial maths produces NaN in a small number of specific places, all of them avoidable, and detecting one takes a deliberate test because every comparison against a NaN is false. This page is where NaN comes from in projection and geometry code, how to make it visible, and the guards that stop it propagating. It is one stage of debugging and validation for spatial GPU pipelines.

How one NaN removes a whole layer Four stages left to right showing propagation. A single non-finite value enters, typically from a projection at the pole or a normalise of a zero-length vector. It contaminates the matrix or the vertex it participates in, because any arithmetic involving NaN yields NaN. Every vertex transformed by that matrix becomes NaN. The rasterizer then discards every primitive with a NaN clip coordinate, silently, so an entire layer disappears with no error anywhere. ONE NaN · WHOLE LAYER GONE One bad value pole, or normalize(0) Contaminates any arithmetic yields NaN Every vertex shared matrix Silently discarded no error at all Containing the value at the point it appears is what stops the third box from happening.
The absence of a message is the defining property. A NaN does not crash, does not warn and does not render wrongly — it renders nothing, which is why it needs a deliberate test rather than a watchful eye.

Runnable reference implementation

WGSL has no isnan, and the obvious substitute is unreliable because compilers may assume values are finite and optimise the comparison away. The dependable test is on the bit pattern.

wgsl
/// True when v is NaN. Bit-pattern test: exponent all ones, mantissa non-zero.
/// A `v != v` comparison can be optimised away under fast-math assumptions.
fn is_nan(v : f32) -> bool {
  let bits = bitcast<u32>(v);
  return (bits & 0x7F800000u) == 0x7F800000u && (bits & 0x007FFFFFu) != 0u;
}

fn is_finite(v : f32) -> bool {
  return (bitcast<u32>(v) & 0x7F800000u) != 0x7F800000u;   // excludes NaN and Inf
}

Detection alone is not useful without somewhere to report it. A single atomic counter per pass, incremented on any non-finite output, turns a silent vanish into a number.

wgsl
@group(0) @binding(0) var<storage, read_write> out       : array<vec2<f32>>;
@group(0) @binding(1) var<storage, read_write> bad_count : atomic<u32>;

@compute @workgroup_size(256)
fn transform(@builtin(global_invocation_id) gid : vec3<u32>) {
  let i = gid.x;
  if (i >= arrayLength(&out)) { return; }

  let p = compute_position(i);
  if (!is_finite(p.x) || !is_finite(p.y)) {
    atomicAdd(&bad_count, 1u);
    out[i] = vec2<f32>(0.0, 0.0);      // contain it: never propagate a NaN
    return;
  }
  out[i] = p;
}

Containing rather than propagating is the important half. Writing zero for a bad element keeps the rest of the pass meaningful, and the counter says how many were bad — which is far more actionable than a layer that disappears.

Parameter reference

Value Setting here Guidance
NaN test bit pattern v != v may be optimised away; the bit test cannot.
Containment value 0.0 or a sentinel Off-map sentinels are better in development, since they are visible; zero is safer in production.
Counter one atomic<u32> per pass Read it through the same readback ring as anything else; never inline.
Where to guard the outputs of each pass Guarding inputs too doubles the cost for information the previous pass’s counter already gave.
Build development and staging The bit test is a couple of instructions, so leaving it on is defensible.
Five sources of NaN in spatial maths A table of five sources with the operation that produces the non-finite value and the guard that prevents it. A latitude at the pole makes the Mercator logarithm diverge, guarded by clamping to plus or minus 85.051129 degrees. Normalising a zero-length vector divides by zero, guarded by a length check. A degenerate triangle divides by a zero area, guarded by an area check. Acos of a dot product slightly above one returns NaN, guarded by clamping the argument. Reading an uninitialised buffer can return arbitrary bit patterns, guarded by clearing the buffer. NaN SOURCE · GUARD Operation Guard Pole latitude log(tan(...)) diverges clamp to ±85.051129° Zero-length normalize divide by zero length check first Degenerate triangle divide by zero area area check first acos out of range |arg| slightly over 1 clamp the argument Uninitialised buffer arbitrary bit patterns clear at allocation The acos case is the sneakiest — it only fires for vectors that are very nearly parallel.
Every guard is one or two instructions and every one of them is skipped by default. Adding all five to a spatial codebase costs almost nothing and removes the entire category.

Where spatial maths produces NaN

Five sources account for nearly all of it, and each has a specific guard.

A latitude at the pole. log(tan(π/4 + lat/2)) diverges at ±90°, producing infinity, and an infinity multiplied by a zero in the matrix produces NaN. The guard is the ±85.051129° clamp described under Web Mercator projection.

A zero-length vector normalised. normalize(vec2(0,0)) is a division by zero. Line simplification, normal computation and direction vectors all hit this on duplicate consecutive vertices, which real data contains routinely.

A degenerate triangle. Barycentric coordinates and area-weighted normals both divide by an area that is zero when three vertices are collinear — again common in real geometry, particularly after simplification.

acos or asin slightly out of range. A dot product of two unit vectors can be 1.0000001 after rounding, and acos of that is NaN. Clamping the argument to [-1, 1] before the call costs one instruction and removes it entirely.

A missing tail guard reading uninitialised memory. Out-of-bounds reads return zero, which is safe, but reading a buffer that was never written returns whatever was there — and if that buffer was previously used for something else, it can contain bit patterns that are NaN.

Infinity, and why it is the more useful signal

NaN gets the attention and infinity is often the earlier symptom, because most of the sources above produce an infinity first and a NaN only when that infinity meets a zero.

That ordering is useful. A guard that tests for finiteness rather than for NaN catches the problem one step upstream, at the pass that produced the divergence rather than at the pass that consumed it — and the earlier the detection, the smaller the search. is_finite above is deliberately the broader test for exactly this reason.

It also changes what a containment value should be. Replacing an infinity with zero is usually right; replacing it with a large finite number is occasionally better, because a vertex at a plausible-but-wrong position is visible, and a vertex at the origin in the Gulf of Guinea is the recognisable signature of a coordinate that was zeroed. Which to prefer depends on whether the containment is in a development build, where visibility helps, or a production one, where an off-map artefact is worse than a missing feature.

The one case where infinity is legitimate is a deliberately unbounded value — a depth clear, an initial minimum in a reduction. Those should use a large finite sentinel instead, so that the finiteness guard stays a pure error signal rather than something with exceptions.

Failure modes

  • A whole layer disappears. A NaN in the shared transform matrix. Every vertex became NaN and every primitive was discarded.
  • A few features are missing and the rest are fine. Per-element NaN — usually a degenerate geometry case rather than a matrix problem.
  • v != v returns false for a value that is clearly NaN. The comparison was optimised away. Use the bit test.
  • The counter reports zero and geometry is still missing. The NaN is arising after the guarded pass — in the vertex shader, or in the matrix itself, rather than in the compute output.
  • Everything works in development and fails in production. A guard that is compiled out with the debug flag. Containment guards belong in every build; only the counter readback needs gating.
Narrowing a vanished layer to its source Three checks in order. First read the per-pass non-finite counter: a non-zero count names the pass and the element count, and a zero count means the problem is downstream of the guarded pass. Second substitute an identity matrix for the transform: if the geometry reappears somewhere, the NaN is in the matrix rather than in the data. Third render the raw input positions with no transform at all: if they are missing too, the data arrived non-finite and the check belongs on the server. VANISHED LAYER · THREE CHECKS 1 Read the counter non-zero names the pass zero means it is downstream 2 Substitute an identity matrix does anything appear? isolates matrix from data 3 Draw the raw inputs no transform at all still missing means bad data Do them in this order — the counter is free and answers the question about half the time.
Three checks, a few minutes, and the answer is one of three places: the pass, the matrix, or the payload. Without them the search covers the whole pipeline.

Keeping the guards affordable

A finiteness test is two instructions, and putting one on every value in every kernel would still add up. Three placement rules keep the cost negligible while catching essentially everything.

Guard outputs, not inputs. A pass whose inputs are the outputs of a guarded pass has already been checked, and checking again doubles the cost for no new information. The exception is the first pass in a chain, whose inputs came from outside the GPU and are the most likely place for bad data to enter.

Guard the vector, not each component. all(v == v) style checks over a vec2 or vec4 cost the same as one scalar test on most hardware, so testing a whole position at once is free relative to testing its parts.

Guard once per invocation, at the end. A kernel with several intermediate values needs one test on what it writes, not a test after each step. If the counter fires, the intermediate steps can be instrumented temporarily — but the standing guard should be at the boundary.

With those three rules the overhead measures at well under one percent on a typical spatial kernel, which is cheap enough that leaving it enabled in a shipped build is a defensible default rather than a debugging concession.

Backend / Python interop note

A surprising share of NaN arrives rather than being produced, and a server-side check catches it where the failing record can be named.

python
import numpy as np

def assert_finite(xs: np.ndarray, ys: np.ndarray, ids: np.ndarray) -> None:
    """Fail the build, naming the offending features."""
    bad = ~(np.isfinite(xs) & np.isfinite(ys))
    if bad.any():
        raise ValueError(f"non-finite coordinates in features: {ids[bad][:10].tolist()}")

Two upstream sources are worth knowing about. A reprojection that fails for a coordinate outside its source CRS’s area of validity returns infinity in most libraries rather than raising — pyproj does exactly this — so a dataset reprojected from a national grid and containing one point outside that grid arrives with an infinity in it. And a null in a coordinate column, once cast to f32, can become NaN depending on the null-handling of the cast.

Both are cheap to catch at build time and expensive to diagnose at render time, which is the general argument for validating a payload where the feature identifiers still exist.