diff --git a/docs/characters/head-features-contract.md b/docs/characters/head-features-contract.md new file mode 100644 index 0000000..a8907fa --- /dev/null +++ b/docs/characters/head-features-contract.md @@ -0,0 +1,261 @@ +# 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, 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. + +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_