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.
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.
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:
| style | old gate | honest gate | bare | verts inside skull | deepest |
|---|---|---|---|---|---|
| topknot | 141/141 | 117/141 | 24 | 29 | −13.2 mm |
| twin_tails | 141/141 | 126/141 | 15 | 39 | −15.9 mm |
| braid | 141/141 | 130/141 | 11 | 33 | −16.9 mm |
| fringe_curtain | 141/141 | 131/141 | 10 | 22 | −14.4 mm |
| ponytail | 141/141 | 132/141 | 9 | 36 | −18.6 mm |
| long_loose | 141/141 | 134/141 | 7 | 18 | −12.5 mm |
| buzz | 141/141 | 135/141 | 6 | 81 | −15.0 mm |
| shaggy | 141/141 | 135/141 | 6 | 15 | −12.6 mm |
| half_up | 141/141 | 137/141 | 4 | 29 | −12.4 mm |
| swept_back | 141/141 | 139/141 | 2 | 24 | −14.0 mm |
| short_crop | 126/141 | 141/141 | 0 | 27 | −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.
before
after
before
afterThe 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.
before
afterThe ten, as shipped
topknot24 bare
twin_tails15 bare
braid11 bare
fringe_curtain10 bare
ponytail9 bare
long_loose7 bare
buzz6 bare
shaggy6 bare
half_up4 bare
swept_back2 bareWhat 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.