Menu
BKZ lab log

Rendering · Tooling · Correctness

141 of 141, and the scalp was showing

A coverage test told me the hair was perfect. The render disagreed. The bug was not in the geometry or in the test — it was that both of them were wrong about the same thing, in the same direction, and so agreed.

· Brooklyn Dead asset pipeline

The setup

Characters in Brooklyn Dead get their hair from swappable shells — eleven styles, each a low-poly mass fitted to the head. Nobody models them by hand. A Python file describes each one and Blender builds it, which means they can regress silently, which means they need gating.

One of those gates is crown coverage. It fires 141 rays out of the scalp — one at the pole, then seven rings of twenty — and asks whether a hair polygon is in the way of each. A miss is a clear line of sight from skin to sky: a bald patch. It is a good test. It has caught real bugs. And for ten of the eleven styles it had been returning a perfect 141 of 141 for months.

Then I looked at the back of a head in a render, and there was a pale patch the size of a palm.

Two surfaces that were never the same

Every hair shell in the codebase is carved onto a mathematical stand-in for the skull, which the code calls the cranium ellipsoid. It was never meant to be the head. It is a hair envelope — a smooth, convenient shape defined by four constants, easy to place geometry against.

The head is built somewhere else entirely, by a different module, from a different parameter set, and it has a brow ridge and an occiput and a jaw. So the two disagree. I measured by how much, by firing rays from the envelope at the built head.

42 mmenvelope floatsoff the forehead19 mmskull outsidethe envelope≈ 4 mm at the crown poleFRONT · BROWBACK · OCCIPUTenvelopebuilt head
The two surfaces, in cross-section. They are not concentric and the error changes sign around the head: the envelope stands well in front of the forehead, and the skull stands outside the envelope at the back. Anything measured against the envelope inherits that error, whichever way it happens to point.

That would be a harmless simplification if the envelope were only used for building. Geometry has to be carved against something, and a smooth approximation is a perfectly reasonable thing to carve against.

The problem is that the test used it too.

Why nothing could see it

The coverage test started every ray on the envelope. At the back of the head that is a surface 19 mm under the skin. So the ray began inside the skull, travelled outward, and the first thing it met was the hair shell — which had itself been carved onto the envelope, and was therefore also buried inside the head. Hit found. Counted as covered. Meanwhile the real scalp, 19 mm further out, had nothing over it at all.

19mmOUTSIDE THE HEADfirst hit: the shell→ scored COVEREDthe real scalpnothing above it — barethe shell, carvedonto the enveloperay starts on the envelope —19 mm inside the skullINSIDE THE SKULL
The failure, at the occiput. The shell was carved on the envelope, so at the back of the head it sits under the skin — measured at 15 to 39 vertices per style, up to 18.6 mm deep. The ray finds it there and stops. Both the geometry and the test are wrong by the same amount, in the same direction, so they agree.

The test and the thing it was testing shared a reference frame, and the error lived in the frame. Two wrongs, in perfect agreement, reporting success.

It failed the other way as well

At the brow the envelope is 42 mm proud of the forehead, so rays there started in mid-air in front of the face. Hair that sat correctly on the head was below the ray’s own starting point and never got hit at all. It scored as bald. Hair pushed out to meet the envelope scored perfectly.

So the test was not merely blind. It had a preference. It rewarded hair standing 42 mm off the forehead and penalised hair that fitted the head — and we had been calling the result the helmet in review notes for weeks without ever connecting it to a validator.

What the numbers said

Re-basing the measurement on the built head, without changing a single piece of geometry:

styleold gatehonest gatebareverts inside skulldeepest
topknot141/141117/1412429−13.2 mm
twin_tails141/141126/1411539−15.9 mm
braid141/141130/1411133−16.9 mm
fringe_curtain141/141131/1411022−14.4 mm
ponytail141/141132/141936−18.6 mm
long_loose141/141134/141718−12.5 mm
buzz141/141135/141681−15.0 mm
shaggy141/141135/141615−12.6 mm
half_up141/141137/141429−12.4 mm
swept_back141/141139/141224−14.0 mm
short_crop126/141141/141027−6.5 mm

All ten failed, for 94 bare samples in total, and every single miss was at the back of the head — which is the signature of the mechanism rather than of ten unrelated modelling mistakes.

The last row is the control. short_crop had already been moved onto a corrected surface in an earlier pass, and it is the only style that reads worse on the old gate than the new one: 126 of 141 against 141 of 141. Same defect with its sign flipped. Because it fits the head closely at the brow, the old test’s mid-air ray starts sailed straight over it and scored a correct haircut as balding.

The fix, and the thing I checked first

The repair is to stop measuring against the stand-in: build the actual head, put it in a bounding-volume hierarchy, and raycast that.

An earlier pass had cached this correction as a baked 72×40 grid, on the entirely reasonable assumption that building a head was too expensive to do inside a gate. I measured that assumption before inheriting it. Building the head and its BVH costs 7 milliseconds. The cache was buying nothing, and it carried a real liability: it could go stale against a module it did not own. So the correction is computed live, and the grid is now only a record of what the field was fitted against.

One wrinkle earned its own paragraph. Hair is authored once and shared, while the character has two head shapes. So the correction cannot be “the head” — it has to be the outer envelope of both. That is not a theoretical nicety: the second frame stands up to 9.6 mm proud of the first at the temple, so a correction fitted to one head sinks hair into the other.

And the part I care most about: nothing was loosened. Both close-fit ceilings stayed exactly where they were, at 30 mm and 50 mm. The “buried in the skull” check got roughly three times stricter — it used to fire at about 47 mm, expressed as a fraction of an ellipse radius, and now fires at 15 mm measured against the real head. When a gate has been lying to you, the fix is never to move the bar.

What it actually looked like

Numbers are not the deliverable here; a face is. Same style, same camera, same lighting — only the surface the shell was carved onto has changed.

The topknot style rendered from behind, before the fixbefore
The topknot style rendered from behind, after the fixafter
topknot, from behind. The worst of the set at 24 bare samples, and the one that sent me looking. The gate had been calling this fully covered.
The fringe_curtain style rendered from behind, before the fixbefore
The fringe_curtain style rendered from behind, after the fixafter
fringe_curtain, from behind. Ten samples — the same patch, in the same place, on a completely unrelated style. That is what a shared-cause bug looks like from the outside, and it is why ten separate modelling mistakes was never a plausible explanation.

The one I did not expect was buzz, from the front. It has the least hair of any style in the set, so it had the least to hide behind — and it turns out to be where the old test’s preference shows up most plainly.

The buzz style rendered from the front, before the fixbefore
The buzz style rendered from the front, after the fixafter
buzz, from the front. The helmet was the ellipsoid. On the left, a smooth dome that swallows the head and hides the forehead completely — geometry pushed out to meet a surface 42 mm off the brow, which is precisely what the old gate rewarded. On the right, the same script with the shell seated on the real skull: a close crop, hairline on the actual forehead, temples visible. No art direction changed between these two images. Only the surface the shell was carved onto.

The ten, as shipped

topknot, showing the bare patch the gate was reporting as coveredtopknot24 bare
twin_tails, showing the bare patch the gate was reporting as coveredtwin_tails15 bare
braid, showing the bare patch the gate was reporting as coveredbraid11 bare
fringe_curtain, showing the bare patch the gate was reporting as coveredfringe_curtain10 bare
ponytail, showing the bare patch the gate was reporting as coveredponytail9 bare
long_loose, showing the bare patch the gate was reporting as coveredlong_loose7 bare
buzz, showing the bare patch the gate was reporting as coveredbuzz6 bare
shaggy, showing the bare patch the gate was reporting as coveredshaggy6 bare
half_up, showing the bare patch the gate was reporting as coveredhalf_up4 bare
swept_back, showing the bare patch the gate was reporting as coveredswept_back2 bare
Every pale patch above is scalp that the coverage gate was reporting as covered, on the same run in which it printed 141 of 141.

What I deliberately left alone

The shells ride the real skull now. The locks — the individual carved chunks that stand proud of the shell and carry the silhouette — still ride the envelope, which means at the brow they are up to 42 mm from the surface they are supposed to be lying against.

That is not an oversight and it is not a second bug to quietly fold in. Moving the locks changes how every style looks rather than whether it is correct, and the change is not small: it would pull the front of the silhouette in by somewhere between 23 and 42 mm. buzz is the preview, because on that style the shell is the silhouette and it has already moved. A change of that size deserves a decision and a set of renders, not a paragraph at the end of a bug fix.

The general version

If you write a validator, ask what frame it measures in, and whether the thing it validates was built in that same frame. If the answer is yes, it cannot see errors in the frame itself — and worse, it will quietly select for whatever geometry satisfies the error, because that geometry is what scores well. A test like that does not just fail to catch the bug. It applies pressure in favour of it.

The tell had been in the build log the whole time. A test that has never failed is not necessarily a test that is passing.

Blender 5.2 · Python · glTF → Godot 4 · renders in Cycles at 40 samples

The change: 247 insertions, 115 deletions, one file. Triangle counts identical across all eleven styles — the correction moves vertices, not topology.

Back to the pipeline