Merging origin/main in ariki-game surfaced a product decision newer than this doc: Ozan removed the mesh brows, lash cards and browless-face swap from the product path. Brows are painted on the body texture again and keep their baked colour, so "brows follow the hair swatch" and the generated-surface lane are both parked. The tooling and the brow wiki page still describe the 3D lane, so the note now says to read the code before the docs there. Two consequences outlive the revert and are kept: painted brows are still atlas-bound, so the v02 re-atlas has to carry the pigment (an albedo-fidelity job now, not three artifacts to regenerate); and character_brow.gdshader is still the only mechanism that could recolour a brow on a scanned face, since raised scan geometry cannot be erased with a texture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 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 — the whole 3D lane was reverted on 2026-08-12. Ozan removed the mesh brows,
the lash cards and the browless-face swap from the product path: brows are painted on the
body texture again and keep their baked colour. That retires, for now, the generated
surface (
tools/generate_lena_brow_surface.py), the locked BLUE mask, the browless albedo, and "brows follow the hair swatch". The tooling and the wiki page remain, so read the code before the docs on this one. Two consequences survive the revert. First, painted brows are atlas-bound, so Lena's v02 re-atlas still has to carry the brow pigment correctly — the difference is that it now rides in the albedo rather than in three separate artifacts. Second, the mask-tint path (shaders/character_brow.gdshader, 72 % replace, not a multiply) is still live code and is still the only mechanism that could recolour a brow on a scanned face — you cannot erase raised scan geometry with a texture. Mako hasAttachFacialFeatures = false, so his brows are his scan's and are not editable 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 | Carry the painted brow pigment through the v02 re-atlas | Lena v02 lane | Brows went back to texture-only on 2026-08-12, so this is an albedo-fidelity job now, not three artifacts to regenerate |
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.