Custom Normals Shade

examples/custom-normals-shade/

A jerry can prop shaded three ways to prove the post-4.1 shading contract: hard edges are mesh data, landing exactly where the dihedral crosses. Face smooth flags plus a sharp_edge attribute, verified against an independently recomputed dihedral test, and per-loop custom normals surviving depsgraph evaluation within their int16 storage quantization (3.904e-05 over 8196 loops, not float-exact).

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

witnesses use_auto_smooth, use_custom_normals and calc_normals are AttributeError on BOTH 4.5 LTS and 5.1 - AI code still emits them. The legacy shade_auto_smooth operator CANCELS headless on 4.5 (asset load never finishes; mesh untouched; no exception) while 5.1 FINISHES with the Smooth by Angle NODES modifier. By-angle sharp sets match the independent dihedral recompute exactly (1304 of 4738 edges over 3 meshes).

tags mesh attributes

runs on 4.5 LTS · 5.1 · 5.2 LTS

blender --background --python examples/custom-normals-shade/custom_normals_shade.py --

falsify it Breaks the contract on purpose and must exit 5

blender --background --python examples/custom-normals-shade/custom_normals_shade.py -- --mismatch-angle

A runnable example that builds a jerry can prop — a pressed-steel shell whose front and back carry four raised rounded-triangle panels (the X is the channel between them), a weld-seam bead round the side band, a triple carry handle on welded feet, and a spout with red seal, domed cap, cam lever, hinge and locking pin; olive paint chipped to steel on exposed edges by a wear point attribute — and verifies the shading contract a game prop's silhouette depends on: which edges read hard and which read smooth is mesh DATA, carried since Blender 4.1 by face smooth flags plus a sharp_edge attribute, following mesh-editing-and-bmesh.

Pipeline arc neighbor: collision in collision-hull-proxy (the pair partner — same prop-pipeline audience), tangent space in triangulate-tangents — normal maps are baked against exactly this shading, and engines harden or soften the same edges at ingest.

Scope: this witnesses the bpy-level contract a prop pipeline relies on. It is not an engine exporter and does not claim engine/FiveM compatibility — it proves the mesh-data properties such an asset's shading must have.

What it witnesses:

What each check catches on failure: author/audit threshold drift (probe: sharp marks applied at 20° but audited at 30°, exit 5, 48 extra edges on the shell); the two halves of the contract out of sync (probe: sharp_edge set but face smooth flags lost, exit 6, smooth-edge loops split by 3.902e-01); float-exactness assumed of custom normals (probe: tolerance 1e-6, exit 7, measured 3.904e-05); and any future version that resurrects the legacy API or changes the operator's headless behavior (exit 3/8). The render path adds the Layer 1 gates: framing (exit 10) and asset quality (exit 11).

Version witness: every value above is identical on Blender 4.5.11 LTS, 5.1.2 and 5.2.1 LTS except the shade_auto_smooth operator behavior, which is asserted per version (CANCELLED + untouched on 4.5, FINISHED + NODES modifier on 5.1 and 5.2).

The render shows three fresh builds of the same can, one per shading treatment. Left, flat: the side-band corners, cap and handle break into facets. Middle, smooth-everywhere (the shade-smooth-and-forget AI habit): the flat fields smear into gradients and the pressed panels go pillowy. Right, by-angle (the contract): crisp creases and smooth rounds, with the sharp_edge attribute read back from the mesh and traced as thin amber drafting lines (low emission, no bloom halo), so the viewer sees exactly which edges the data marked hard. A low strip light makes the highlight shapes diverge between the three.

Run#

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

# Falsifier: mark sharp at 20° while auditing 30°. Must exit non-zero.
blender --background --python custom_normals_shade.py -- --mismatch-angle

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

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it.

CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Legacy shading API present, or modern path missing
4Non-manifold edges (dihedral test undefined)
5Sharp set ≠ independent dihedral (--mismatch-angle lands here)
6Evaluated loop normals not welded/split as the sharp set promises
7Custom split normals lost or dequantized in evaluation
8shade_auto_smooth operator behavior drifted from the version split
9--output produced no file
10Render path: Layer 1 framing violation (gallery_framing)
11Render path: asset-quality floor violation (gallery_asset_quality)

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 --mismatch-angle (expects exit 5).

Source

examples/custom-normals-shade/custom_normals_shade.py View the script on GitHub →
Three olive jerry cans with X-pressed panels, triple handles and red-sealed spouts: faceted flat, glossy-smeared smooth-everywhere, and crisp by-angle with its sharp edges traced in thin amber lines.