Comparing normals to an axis mis-reports winding; the signed volume of a closed mesh does not
Fact
The obvious check for "are my faces wound outwards" — take each face's centroid, take its normal, and see whether they point the same way from the model's axis — is not a test. It is only valid for a star-shaped hull, and almost no real mesh is one. Eyeballs, fins, jaws and any other part attached off-axis are correctly oriented while pointing back toward the axis, so they read as failures.
On a procedurally built fish that heuristic reported 70% of faces inverted when the only genuinely inverted surface was the body loft.
The signed volume is exact and needs no assumptions:
let v = 0;
for (const [a, b, c] of triangles) v += dot(a, cross(b, c)) / 6;
// closed mesh, outward winding => v > 0
It is one pass, it has no tuning, and it gives one number per mesh.
Why it matters
A wrong-way mesh is invisible until it is rendered with backface culling, and then it looks hollow rather than broken — which sends the search into the shading, the normals or the light rig instead of the vertex order. Meanwhile the heuristic that was supposed to catch it is producing a large, meaningless failure count that hides the one real inversion inside it.
How to apply
- Assert
signedVolume(mesh) > 0per closed mesh in a build-time check. Run it on every primitive builder, not just the finished model. - Open surfaces (a ground sheet, an unclosed hull) have no meaningful signed volume; check those by asserting the sign of the normal component you expect — every seabed face has
n.y > 0. - Double-sided geometry sums to exactly zero. Treat
v == 0as "not closed", not as a failure. - When the volume is negative, look at the sequence direction first: a loft helper that stitches ring
ito ringi+1bakes in an assumption about which way the rings advance, and handing it a reversed chain flips every face while each individual ring still looks right.