760cf12788
Ozan's hair system rebinds Quaternius styles (100% rigid to one Head bone) onto
whichever body loads, corrected by three hand-tuned constants in
CharacterBuildData.HeadFit. Those constants are only valid against the skull they
were tuned on, and nothing re-checks them when a body is rebaked. Both ship bodies
are currently wrong, in opposite directions.
tools/measure_head_shell.py:
--fit solve HeadScale/HairOffset from two cranium landmarks. A scale-about-
pivot plus a translate has exactly two degrees of freedom per axis, so
the crown + widest-cranium slice determine both outright -- no bed pass.
--sweep score every style against the clearance it was AUTHORED with on its own
stock rig (a mohawk stands proud, a beard hangs at the chin -- absolute
crown height is not the test).
default full Head-weighted shell AND the cranium band. The full shell is what
MakoHeadFit quotes; it is inflated several cm by jaw/ear/neck weight
bleed and is not the surface a cap sits on.
Measured against the shipped GLBs:
Lena cranium needs Y 0.960, carries 1.431 (+49%) -- every style floats 4.4-7.3cm
Mako cranium needs Y 1.186, carries 1.346 (+13%) -- every style sinks, buzz cut
2.2cm into his skull, moustache 8.0cm low
Solved constants take Mako to 0.00cm worst error over 10 styles, Lena to 0.29cm mean.
docs/characters/head-features-contract.md records the system, the measurements, and
what a 3-constant fit still cannot reach: Lena needs 1.613 in X but 1.311 in Z, a
23% anisotropy that stretches an authored cap sideways and drags ear cutouts along a
diagonal. That is Ozan's logged residual and wants a per-body geometry bake. Mako's
anisotropy is only 3%, so constants alone should suffice for him.
Also noted: the whole hair set shares just two base textures, so "authored per-colour
hair textures" -- the fix the wiki writes off in favour of the /0.55 swatch hack --
is 16 PNGs.
Crown placement is rest-pose arithmetic; X/Z and the in-bed look are unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
14 KiB
Markdown
262 lines
14 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.** 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.
|