Ozan's hair system rebinds Quaternius styles (100% rigid to one Head bone) onto
whichever body loads, corrected by three hand-tuned constants in
CharacterBuildData.HeadFit. Those constants are only valid against the skull they
were tuned on, and nothing re-checks them when a body is rebaked. Both ship bodies
are currently wrong, in opposite directions.
tools/measure_head_shell.py:
--fit solve HeadScale/HairOffset from two cranium landmarks. A scale-about-
pivot plus a translate has exactly two degrees of freedom per axis, so
the crown + widest-cranium slice determine both outright -- no bed pass.
--sweep score every style against the clearance it was AUTHORED with on its own
stock rig (a mohawk stands proud, a beard hangs at the chin -- absolute
crown height is not the test).
default full Head-weighted shell AND the cranium band. The full shell is what
MakoHeadFit quotes; it is inflated several cm by jaw/ear/neck weight
bleed and is not the surface a cap sits on.
Measured against the shipped GLBs:
Lena cranium needs Y 0.960, carries 1.431 (+49%) -- every style floats 4.4-7.3cm
Mako cranium needs Y 1.186, carries 1.346 (+13%) -- every style sinks, buzz cut
2.2cm into his skull, moustache 8.0cm low
Solved constants take Mako to 0.00cm worst error over 10 styles, Lena to 0.29cm mean.
docs/characters/head-features-contract.md records the system, the measurements, and
what a 3-constant fit still cannot reach: Lena needs 1.613 in X but 1.311 in Z, a
23% anisotropy that stretches an authored cap sideways and drags ear cutouts along a
diagonal. That is Ozan's logged residual and wants a per-body geometry bake. Mako's
anisotropy is only 3%, so constants alone should suffice for him.
Also noted: the whole hair set shares just two base textures, so "authored per-colour
hair textures" -- the fix the wiki writes off in favour of the /0.55 swatch hack --
is 16 PNGs.
Crown placement is rest-pose arithmetic; X/Z and the in-bed look are unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
Hair fit — what Ozan's system needs, why it is wrong on both ship bodies, and the pipeline fix
Written 2026-08-12. Measurements in §2 were taken today with
tools/measure_head_shell.py against the currently
shipped GLBs. Eyebrows and eye colour are covered briefly in §6 — hair is the subject.
Game-side sources: src/Character/OutfitSystem.cs,
src/Character/HeadAccessoryBindMath.cs, src/Data/CharacterBuildData.cs,
src/Viewer/PlayerController.cs,
assets/quaternius/derived-bodies/hair_fit_quatskin_female.json.
1. How the hair system actually works
Sixteen Quaternius pieces — 6 female styles, 7 male styles, 3 facial-hair — live in
assets/quaternius/source-hair/. Each is a small mesh — 466 to 3,501 verts — and every
one is 100 % rigidly weighted to the single Head bone. Verified across six styles:
one joint, weight 1.0, no blending anywhere.
That single fact governs everything below. A hairstyle is not a skinned garment. It is a
rigid prop parented to one bone, authored in the rest-pose world space of the stock
Quaternius body — female styles on Superhero_Female (Head pivot Y 1.5496), male styles
on Superhero_Male (Head pivot Y 1.5998).
At runtime PlayerController attaches the style to whatever body loaded and corrects it with
three constants from CharacterBuildData.HeadFit:
| constant | effect |
|---|---|
HeadScale |
diagonal scale of the binds about the Head rest pivot |
HairOffset |
bind-space translation |
HairPitchDeg |
pitch about the same pivot |
Colour is ApplyHairColor → StandardMaterial3D.AlbedoColor = swatch, i.e. a multiply
against the hair card.
So the entire per-body fit is a 3-parameter diagonal scale plus a translate, hand-tuned in
head_test_bed, stored as C# constants.
2. Measured: both ship bodies are wrong, in opposite directions
Cranium band = Head-weighted verts within 8 cm of the skull top — the surface a cap actually
sits on. (The full Head-weighted shell, which is the measure quoted in MakoHeadFit's
comment, is inflated several cm by jaw/ear/neck weight bleed. Use the band.)
| body | Head pivot Y | cranium w | cranium d | skull top above pivot | pivot above chin |
|---|---|---|---|---|---|
stock Superhero_Female (female hair authoring rig) |
1.5496 | 14.84 | 20.11 | 21.69 | 1.2 |
stock Superhero_Male (male hair authoring rig) |
1.5998 | 14.79 | 19.91 | 21.02 | 2.3 |
Ariki_Female_QuatSkin.glb (Lena, ship) |
1.5687 | 21.44 | 25.96 | 20.83 | 12.8 |
Ariki_Male_Mako.glb (Mako, ship) |
1.5687 | 18.79 | 24.03 | 24.93 | 7.3 |
Ratio the cranium against the same gender's authoring rig, and compare to the constant each body actually carries:
| needed | carried | error | |
|---|---|---|---|
| Lena X | 1.445 | 1.545 | +7 % |
| Lena Y | 0.960 | 1.431 | +49 % |
| Lena Z | 1.291 | 1.426 | +10 % |
| Mako X | 1.270 | 1.316 | +4 % |
| Mako Y | 1.186 | 1.346 | +13 % |
| Mako Z | 1.207 | 1.327 | +10 % |
Push a scalp-hugging style through the real runtime chain
(HeadAccessoryBindMath.RestToWorldStepwise) and the consequence is concrete:
Hair_BuzzedFemale authored crown 22.13 cm above its pivot
on Lena, scale 1.431 / offset -0.060 -> lands Y 1.8254 her skull top 1.7770
crown vs skull +4.84 cm FLOATING
Hair_Buzzed (male) authored crown 21.37 cm above its pivot
on Mako, scale 1.346 / offset -0.057 -> lands Y 1.7994 his skull top 1.8180
crown vs skull -1.87 cm BURIED
A buzz cut floating ~5 cm over Lena's head, and one sunk ~2 cm into Mako's. Both from the same three constants.
Neither is a mystery. The code says so itself. Lena's constants carry a stale-marker: they were tuned against the pre-swap body, whose skull top was 26.5 cm above the pivot where the AccuRig body that replaced it on 2026-08-11 measures 20.83 cm. Her vertical correction should now be ≈ identity; she is carrying a 43 % stretch plus a 6 cm drop that were fitted to a skull 5.7 cm taller. Mako's were never bed-tuned at all — the comment calls them "STARTING VALUES", derived by ratio off the inflated full-shell measure.
Not yet eye-verified in head_test_bed. These are rest-pose numbers from the bind chain;
someone should confirm the look before acting on them. But the direction and rough magnitude
are arithmetic, not opinion.
2b. The constants are derivable, not bed-guessable
A scale-about-pivot plus a translate has exactly two degrees of freedom per axis, so two
matched cranium landmarks determine both constants outright — no bed session. Take the
crown and the widest cranium slice (the temple/ear line) on the stock authoring rig and on the
ship body, solve, done. That is measure_head_shell.py --fit.
Scored against each style's authored clearance on its own stock rig — the correct test, since a mohawk stands proud and a beard hangs at the chin by design:
| body | constants | worst error | mean error |
|---|---|---|---|
| Mako, 10 styles | carried (1.316,1.346,1.327) / −0.057 |
8.04 cm | 3.17 cm |
| Mako, 10 styles | derived (1.363,1.000,1.322) / +0.0388,+0.0242 |
0.00 cm | 0.00 cm |
| Lena, 6 styles | carried (1.545,1.431,1.426) / −0.060 |
7.29 cm | 5.09 cm |
| Lena, 6 styles | derived (1.613,1.148,1.311) / −0.0411,−0.0094 |
1.04 cm | 0.29 cm |
Mako lands exactly because his fit comes out as a pure translate (Y scale 1.000), which
preserves every relative distance. Lena's residual is Hair_LongDreads, whose crown is far
from both fit landmarks.
This changes the calculus in §3. "Retuning goes stale on the next rebake" stops being an argument once retuning is one command — so re-derive in the ship gate and it can never go stale silently again.
What the fit still cannot do is the reason §4 survives: it fixes crown height, not shape. Lena's cranium needs 1.613 in X but 1.311 in Z — a 23 % anisotropy that visibly stretches an authored cap sideways, and moves ear cutouts along a diagonal rather than onto her actual ears. That is Ozan's logged residual, and no 3-constant fit reaches it.
Verification status: rest-pose arithmetic on the crown only. X/Z placement — the ear
cutout, the forehead line — is untested by this metric, and nothing here has been looked at in
head_test_bed.
3. Why retuning the constants is not the whole fix
Look at the last column of the table in §2: the Head pivot lands at a different anatomical place on every body. On the stock rig it sits at the jaw line, 1–2 cm above the chin. On Lena it sits 12.8 cm above her chin — mid-face. On Mako, 7.3 cm.
A diagonal scale about that pivot therefore scales about a different landmark per body. It can be made to fit the crown, or the ear cutouts, or the forehead line — not all three. That is exactly the residual Ozan logged in the canonical fit record:
"Buzzed-style ear cutout still slightly off vs her ear position (ear sits further back+lower than stock). Translation gets close, not perfect — a true fix is a Blender-authored hair fit pass."
He named the fix. It is a Blender geometry pass — which is this repo's lane, not the game's.
And the tuning loop has a structural flaw: the constants are C# literals validated by eye against one body build. Every rebake invalidates them silently. It has already happened once (Lena, 2026-08-11) and shipped once un-noticed (Mako, 2026-08-12). Lena's v02/v03 rebake will do it a third time. Retuning now buys a few weeks and re-enters the same loop.
4. The fix: bake a per-body hair set in our lane
Because hair is 100 % rigid to one bone, refitting it is a pure geometry problem with no rigging component. Deform the verts once, offline, and the runtime needs no constants at all.
Proposed stage — characters/hair/hair_refit.py
Inputs: a shipped body GLB, its gender's stock authoring body, that gender's style files.
- Extract the scalp surface of both heads (Head-weighted, above the brow line).
- Build a smooth displacement field stock scalp → ship scalp, anchored on a handful of
landmarks — skull top, ear centres, temples, brow centre, nape — then relaxed. A few
hundred correspondences under a thin-plate/RBF fit is ample. Full NRICP is unnecessary:
hair sits outside the surface, so millimetre face accuracy is irrelevant. (The NRICP
head-standardization lane proved the harder version of this registration; it was never
wired into
tools/, and we do not need it here.) - Per hair vert: closest point on the stock scalp → (surface point, signed normal distance). Reconstruct as warped surface point + the same normal distance along the ship normal, so a cap authored 4 mm off the stock scalp stays 4 mm off ours. Verts outside the scalp region — ponytails, dreads, beard fringes — fall off to the rigid part of the field and keep their authored shape while riding the new skull.
- Recompute normals. Topology, UVs, materials and the single Head weight are untouched — that is what makes this safe to ship.
- Export
Hair_<style>.<bodyid>.glbas a per-body set.
QC gates (the lane already works this way — gate it or it ships wrong again):
- penetration — no cap-region vert inside the ship scalp.
- clearance — cap-region verts within the stock clearance distribution (roughly 1–8 mm).
- ear cutout — for Buzzed/Bob, cutout rim to ear landmark within tolerance. This is the residual Ozan logged, and it becomes a measurable gate rather than an eyeball call.
- crown — the §2 prediction, run per style per body, must read
ok. - silhouette renders per style per body, into
preview/like every other ship artifact.
Game-side change is small. GetHairPath(hairId, gender) → GetHairPath(hairId, bodyId)
with fallback to the stock path when no baked variant exists; HeadScale / HairOffset /
HairPitchDeg go to identity for bodies that have a baked set. Slots, catalogue, colour and
the builder are untouched. Nothing gets deleted until a baked style is accepted.
Cost: 6 baked styles for Lena, 10 for Mako — 16 GLBs of 466–3,501 verts, a few hundred KB each. Noise next to an 8.5 MB body.
5. Hair colour — the "real fix" is 16 PNGs
ApplyHairColor sets AlbedoColor, which Godot multiplies against the card texture. The card
is ~0.55 neutral grey, so dark swatches land near-black. DefaultHairSwatches[7] stores
target / 0.55 to work around it — the wiki correctly calls that "a hack on one swatch, not a
system", and lists authored per-colour textures as the real fix.
That fix is far cheaper than it sounds. The entire hair set — all 13 styles, the stock
eyebrow meshes and all three facial-hair pieces — uses exactly two base textures,
T_Hair_1_BaseColor.png and T_Hair_2_BaseColor.png.
So "authored per-colour hair textures" is 2 textures × 8 swatches = 16 PNGs, generated
once with the luminance-preserving retint this lane already runs on skin
(SkinToneTransform / the 21_tone.py texture-space tone engine). Then ApplyHairColor
swaps a texture instead of multiplying a tint, dark colours land as themselves, and the
/0.55 swatch hack goes away.
Cheap, ours to produce, Ozan's to accept.
6. Eyebrows and eye colour, in brief
Both are downstream of the same root cause — our bodies ship as one mesh, one primitive,
one material (MI_Body_Lena, MI_Body_Mako), so no facial feature is individually
addressable.
- Eyebrows. Lena's ship path deletes painted brows from the albedo and attaches a
generated skinned surface built from a locked UV-space mask
(
tools/generate_lena_brow_surface.py), coloured by the hair swatch. The mask, the browless albedo and the surface JSON are atlas-bound — Lena's v02 re-atlas invalidates all three at once, and they must be regenerated in the same stage that finalizes the atlas. There is also a live-but-superseded mask-tint path (shaders/character_brow.gdshader) that recolors a painted brow in place — 72 % replace, not a multiply. That one generalizes to a scanned face; the cover path does not, because you cannot erase raised scan geometry with a texture. Mako hasAttachFacialFeatures = falseand therefore no brow colour at all. - Eye colour.
ApplyEyeColorByIndexswaps the whole albedo of theOutfitSlot.Eyesmesh between six baked iris PNGs. It needs a separate mesh or primitive — there is no other mechanism. Lena only has one because stock Quaternius eye spheres are laid over her painted face. Mako has none, so the builder's eye swatches are dead for the male default. The pipeline answer is one line of the export stage: emit the iris shells (real geometry on both Mako's scan and Lena's v02 transplanted head) as their own primitive instead of welding them into the body. One extra draw call; retiresquat_eyes.glb.
7. Suggested order
| # | Change | Where | Note |
|---|---|---|---|
| 1 | Land Mako's derived fit (1.363, 1.000, 1.322) / (0, +0.0388, +0.0242) |
game-side C# | His own comment calls the current values "STARTING VALUES for a head-bed pass" — unlocked. He is the shipped male default with hair sunk into his skull. Verify in head_test_bed before baking |
| 2 | Lena's derived fit (1.613, 1.148, 1.311) / (0, −0.0411, −0.0094) |
Ozan's call | Do NOT edit unilaterally: HeadTestBed carries an explicit hair-fit LOCK, a warning label hardcoding 1.545,1.431,1.426, and a documented "1.37 incident". Take the §2b table to him |
| 3 | --fit re-derivation added to the body ship gate |
this repo | Makes staleness structurally impossible; both bodies shipped wrong because nothing re-checked |
| 4 | hair_refit.py + QC gates, baked per body |
this repo | The part a 3-constant fit cannot reach: ear cutouts, the 23 % X/Z anisotropy, forehead line |
| 5 | 16 per-colour hair PNGs | this repo | Kills the /0.55 swatch hack |
| 6 | Iris primitive split at export | this repo | Gives Mako eye colour |
| 7 | Regenerate brow mask/browless/surface at atlas-final | Lena v02 lane | Or v02 ships with brows mismatched to her atlas |
Note the duplicated literal: HeadAccessoryBindMath.ArikiHeadScale carries its own copy of
Lena's numbers marked "keep in sync with CharacterBuildData". Any change to hers must touch
both, and MaleCandidateBed (ArikiHeadScale * 1.08f) and BodyProportionTestBed read it too.