Triangulate Tangents

examples/triangulate-tangents/

A machined buckler verifying the tangent-space contract a game engine's normal mapping depends on. Deterministic triangulation, unit orthogonal tangent frames, and the edge/UV-delta formula matching mikktspace within welding tolerance.

Rendered headless by the example itself. Select it to enlarge.

witnesses calc_tangents aborts on any ngon; planar UVs on a cylindrical wall collapse tangents onto normals; a MeshUVLoopLayer handle held across calc_tangents dangles on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on both versions.

tags mesh uv

runs on 4.5 LTS · 5.1 · 5.2 LTS

blender --background --python examples/triangulate-tangents/triangulate_tangents.py --

falsify it Breaks the contract on purpose and must exit 4

blender --background --python examples/triangulate-tangents/triangulate_tangents.py -- --zero-uv

A runnable example that builds a machined buckler — one closed lathe mesh: turned boss, flange, domed face and rolled rim with polar UVs, unwrapped rim and underside strips, fanned back and apex caps — and verifies the tangent-space contract a game engine's normal mapping depends on, following mesh-editing-and-bmesh.

Pipeline arc neighbor: weighting in vertex-weight-limit, export in gltf-export-roundtrip — tangent space is what the exported normal maps are baked against.

What it witnesses: the mikktspace contract engines implement, and the hazards around it.

What each check catches on failure: a remeshed asset breaking the triangulation count (probe: dropped profile ring, exit 3, 624 vs 720); a wrong tangent formula in the independent derivation (probe: swapped du/dv, exit 7, weld 1.460); and the stale-handle read (probe: no re-fetch — corrupt measurements on 4.5, correct values on 5.1/5.2 by luck).

Version witness: mikktspace output is identical on Blender 4.5.11 LTS, 5.1.2 and 5.2.1 LTS (same weld deviation, same seam flips). The calc_tangents(uvmap=) signature is stable. The lifetime hazard above is the only behavioral split, and it corrupts measurements rather than raising.

The render shows the buckler turned three-quarter on a walnut stand: a pointed brass boss on a riveted blued-steel flange, a turned-steel dome, and a brass rolled rim ringed with steel rivets. Materials are assigned per lathe ring. The dome's lathe grooves are a bump read straight off UV v (the radius), so they are exact circles, with no procedural distortion. Its anisotropic highlight follows the renderer's own UV-map tangent, so it fans out radially from the boss. This is a numeric-only contract (docs/VISUAL-STYLE.md): the renderer derives its tangents from the UVs, so a break in the calc_tangents() loop data the check reads does not move pixels. The staging is derived from the posed mesh: the stand rests on the floor, the rim's lowest vertex sits 1 cm into its top, and a kickstand strut runs from the buckler's back (found by a local-space ray) to the floor behind it, angled away from the camera.

Run#

# Cheap correctness check (no render) — the CI check:
blender --background --python triangulate_tangents.py --

# Falsifier: every UV at (0, 0). Must exit non-zero (authored UV closed form).
blender --background --python triangulate_tangents.py -- --zero-uv

# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python triangulate_tangents.py -- --output buckler.png
blender --background --python triangulate_tangents.py -- --output buckler.png --engine cycles

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper, 11 the shared asset-quality helper.

CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Loop-triangle count ≠ closed form
4Re-fetched UV layer drifted from the polar field (--zero-uv lands here)
5Tangent basis not orthonormal
6Bitangent ≠ sign × (n × t)
7Tangents deviate from the edge/UV closed form
8Flipped tangent inside a clean triangle
10Gallery framing violation (--output only; gallery_framing)
11Asset-quality floor violation (--output only; shared helper)
12--output produced no file

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifier is --zero-uv (expects exit 4).

Source

examples/triangulate-tangents/triangulate_tangents.py View the script on GitHub →
A steel buckler on a walnut stand, turned three-quarter: pointed brass boss, domed face cut with two bands of concentric lathe grooves, brass rim ringed with steel rivets.