Files
animation/docs/characters/head-features-contract.md
T

262 lines
14 KiB
Markdown
Raw Normal View 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`](../../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, 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.** 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 has `AttachFacialFeatures = false` and therefore no brow colour 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 | 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.