Vertex Color AO

examples/vertex-color-ao/

A stone well carrying baked ambient occlusion in a colour attribute, with the bake held to the closed-form hemisphere integral rather than to a captured value.

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

witnesses Integrator matches 1 - 0.5(1 - 1/sqrt(1+k^2)) to 6.760e-04, unoccluded plate exactly 1.0, FLOAT_COLOR exact vs BYTE_COLOR sRGB-quantised (model agreement 3.189e-07), evaluated deviation 0.0.

tags mesh attributes game-pipeline rendering

runs on 4.5 LTS · 5.1 · 5.2 LTS

blender --background --python examples/vertex-color-ao/vertex_color_ao.py --

falsify it Breaks the contract on purpose and must exit 3

blender --background --python examples/vertex-color-ao/vertex_color_ao.py -- --uniform-hemisphere

A runnable example baking ambient occlusion into a colour attribute — the cheap contact shadows an engine gets for free if it reads vertex colour. Unlike most bakes this one can be checked against a formula rather than against a previous run's numbers: the hemisphere visibility integral has closed forms.

For a point on the floor a distance d from an infinitely wide wall of height H, integrating the cosine-weighted hemisphere over the directions the wall blocks gives (the φ integral collapses to π / √(1+k²)):

A(k) = ½ (1 − 1/√(1+k²)),   k = H/d        AO = 1 − A(k)

AO depends only on the ratio H/d and increases strictly with d — which is exactly "a concave corner darkens monotonically with depth", as an equation. For an unoccluded flat surface the integral is exactly 1.

The asset, for reuse: Well.Stone, a 2.5 m stone village well — three courses of 16-17 chamfered masonry blocks of irregular width on a 16-slab paved apron, a 15-slab coping ring, a lined bore, two braced timber posts with a crossbeam, an iron-banded windlass with a crank, and a hanging bucket. 11 parts, 11 materials, 15678 vertices. Origin at the ground contact centre (z == 0 is where it rests), identity transforms, datablocks under Well.Stone.*. Every part ships with the AO baked into two colour attributes, and default_color_name points at the linear one so an engine reads the right channel.

Pipeline arc neighbours: attribute domains in attribute-domain-shear and color-attribute-wheel, the second UV set for texture-baked lighting in lightmap-uv-channel, topology gates in mesh-hygiene-audit.

What it witnesses (all closed form or independently re-derived):

Hazards found while authoring#

What each check catches on failure (every one probed, with the measured error): an integrator sampling the full sphere rather than the hemisphere — AO 0.752686 against a closed form of 0.508332, error 2.444e-01 (exit 3); uniform-solid-angle weighting instead of cosine — error 1.202e-02 (exit 3); rays cast with no normal offset so they self-hit the surface they start on — unoccluded plate bakes to 0.160644531 instead of 1.0 (exit 4); a bake collapsed to a constant — spread 0.000000 (exit 5); the sRGB storage model swapped for naive linear 8-bit quantisation — 4.577e-03 disagreement with the real readback (exit 6); a Subdivision modifier resampling the attribute — 3270 evaluated elements against 840 authored (exit 7); a part sunk below the ground plane — -1.200e-02 (exit 8); a default Cube datablock name (exit 8).

The exit-7 probe earned its keep twice: the depsgraph check originally compared zip(src.data, eva.data), which stops at the shorter sequence, so a resampled attribute reported a clean 0.0 deviation and the probe exited 0. The length guard was added because the falsification pass failed to fail.

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same 15678 vertices, same closed-form errors to every printed digit, same storage deviations.

Render as proof: the well on the dark stage, lit only by the studio rig, with the baked AO multiplied into base colour — the masonry joints, the shaft mouth, the coping undersides and the apron contact all darken from the attribute, not from the lights. Every block, coping slab and flagstone is chamfered top and bottom, so neighbours meet in a V groove the bake can see into. The shader raises the AO to the power 2.4 before multiplying it in, so a joint that is 70 % visible reads clearly darker; stone gets a per-block Voronoi tone and a chisel bump, timber a streaked grain. The stored attribute is untouched by any of this. The falsification variant (--falsify) writes the same bake inverted: sky-facing coping and apron tops go grimy while the recesses and post bases glow, occlusion turned inside out.

The studio rig is presentation only. Its fill and rim used to be strongly blue, which lit the whole stage cold (stage luma 0.243, warmth +0.01 against the calibration set). Its wedge grazed floor the camera barely saw and left a hard-edged patch on the back wall. Both are now near neutral, and the wedge pools warm light on the floor just behind the well from the camera's side: luma 0.274, warmth +0.023. The bake, the checks and the exit codes are unchanged.

Run#

blender --background --python vertex_color_ao.py --
blender --background --python vertex_color_ao.py -- --uniform-hemisphere   # falsifier: uniform instead of cosine-weighted hemisphere; calibration exits 3
blender --background --python vertex_color_ao.py -- --output well.png
blender --background --python vertex_color_ao.py -- --falsify inverted.png

The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.839y, margins 0.241/0.238/0.072/0.089, no edge touched) and the asset floors via examples/gallery_asset_quality.py (11 materials, edge90 0.152, no default names).

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 is the shared asset-quality helper.

CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Calibration AO off closed form (--uniform-hemisphere lands here)
4Unoccluded plate is not 1.0, or AO is not strictly increasing with distance
5Bake empty, out of range, or spread too small
6FLOAT_COLOR / BYTE_COLOR round-trip contract
7Colour attributes missing, retyped, or drifted under the depsgraph
8Asset part count, unapplied scale, namespace, render attribute, or ground plane
9--output produced no file
10Gallery framing violation
11Gallery asset-quality violation

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 or --falsify. Its catalog falsifier is --uniform-hemisphere (expect exit 3): sampling uniformly in solid angle instead of cosine-weighted misses the closed form the calibration holds.

Source

examples/vertex-color-ao/vertex_color_ao.py View the script on GitHub →
A round well of chamfered, irregular stone blocks with a timber windlass frame, every masonry joint and the shaft darkened by baked vertex-colour ambient occlusion.