23fc4b378e
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>
266 lines
15 KiB
Markdown
266 lines
15 KiB
Markdown
# 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_<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 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 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.
|