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.
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.
/// 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.
@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. |
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 != vreturns 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.
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.
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.
Related
- Debugging and validation for spatial GPU pipelines — the topic this page belongs to.
- Web Mercator projection in a WGSL vertex shader — the pole clamp, and why it is not optional.
- Using error scopes to localize GPU validation failures — the failures that do produce a message.
- Labeling GPU objects for readable spatial pipeline errors — making the counter’s report attributable.
- Coordinate precision and projection on the GPU — the maths that produces most of these cases.