Files
animation/docs/characters/head-features-contract.md
T
jeremy 23fc4b378e docs(characters): brows went back to painted on 2026-08-12
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>
2026-08-12 17:47:00 -07:00

15 KiB
Raw Blame History

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 ApplyHairColorStandardMaterial3D.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, 12 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.

  1. Extract the scalp surface of both heads (Head-weighted, above the brow line).
  2. 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.)
  3. 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.
  4. Recompute normals. Topology, UVs, materials and the single Head weight are untouched — that is what makes this safe to ship.
  5. Export Hair_<style>.<bodyid>.glb as 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 18 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 4663,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 has AttachFacialFeatures = false, so his brows are his scan's and are not editable at all.
  • Eye colour. ApplyEyeColorByIndex swaps the whole albedo of the OutfitSlot.Eyes mesh 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; retires quat_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.