The two shipped characters are a controlled comparison: Taila's source arrived rigged (35 cloth chains, 8 twist bones, 18 meshes, artist weights), Miku's did not (5 meshes, 0 joints), so Miku was auto-rigged into one mesh with nearest-bone weights and no cloth chains at all. Her twin tails and skirt are dead geometry and the destructive load-time weight repair runs on her every spawn. None of that is recoverable downstream, which makes picking a source that already has skirt and hair bones the highest-leverage decision in the pipeline. Added the no-Blender check for vetting a candidate. Co-Authored-By: Claude Opus 5 <[email protected]>
146 lines
6.6 KiB
Markdown
146 lines
6.6 KiB
Markdown
# Body, garments, hair — what must stay separate
|
||
|
||
The single structural idea behind an anime-styled character rig, and the thing
|
||
every failure in this project traced back to.
|
||
|
||
## The convention this pipeline follows
|
||
|
||
Hoyoverse-class character rigs (Genshin, Star Rail, Zenless Zone Zero) are built
|
||
the same way, and the parts that matter are visible in any of their exported
|
||
assets and in the toolchains built around them (Magica Cloth 2, UnityChan
|
||
SpringBone, VRM's spring-bone spec — all of which exist because this shape is
|
||
the convention):
|
||
|
||
| Convention | What this repo does |
|
||
|---|---|
|
||
| Body, face, hair and each garment are SEPARATE meshes with separate materials | Never join meshes; 18 meshes on Taila are all kept |
|
||
| Skirts get a radial grid of bone chains — many panels, several segments each | 21 panels × 4 segments, subdivided at build time |
|
||
| Hair is chains of 2–4 bones from the scalp | Detected from the source rig; 14 chains on Taila |
|
||
| Cloth/hair bones carry NO animation keys; physics owns them | `export_optimize_animation_keep_anim_armature=False` |
|
||
| Physics colliders are a small set of capsules: thighs, shins, and a big one at the waist acting as a lid | 5 capsules, measured from the mesh (`_leg_colliders`) |
|
||
| Neighbouring skirt panels are linked sideways | 278 cross-panel distance links from shared vertices |
|
||
| Cel shading with a ramp, plus a separate outline pass | `LevelMaterials.apply_toon_recursive` + `apply_character_look` |
|
||
|
||
Where we differ: their collider capsules and cloth parameters are hand-authored
|
||
per character by a technical artist. We MEASURE them from the model's own
|
||
geometry at build time, because there is no artist in this loop. That is the
|
||
whole reason `<model>.rig.json` exists.
|
||
|
||
## Why the separation is load-bearing
|
||
|
||
**Materials.** The body wants skin shading, hair wants an anisotropic-ish ramp
|
||
and its own outline weight, cloth wants flat banding. One merged mesh gets one
|
||
treatment and everything reads as plastic.
|
||
|
||
**The cloth solver.** `SkinnedPlayerModel._cloth_hulls` extracts, per cloth bone,
|
||
the vertices that bone dominates — that is only meaningful while the garment is
|
||
its own mesh with its own weights. Merge the meshes and the solver has no way to
|
||
know which vertices are skirt.
|
||
|
||
**Weights.** A joined mesh rebound by nearest-bone weighting produced 2817
|
||
vertices pulled by BOTH legs on Taila (16% of the model, worst a dead 50/50).
|
||
Such a vertex sits between the legs and stays there while they separate,
|
||
stretching every triangle around it. That is the "squashing on jump" and the
|
||
"elongated boot".
|
||
|
||
## How cloth is detected and classed
|
||
|
||
`tools/rig_map.py::is_cosmetic` matches WHOLE TOKENS in a bone name against:
|
||
|
||
```
|
||
hair skirt cloth ribbon tail cape coat scarf sleeve breast bust
|
||
feather strap antenna wing (+ face/eye classes that must never swing)
|
||
```
|
||
|
||
Whole-token only — `shoulder` must not match `should`, and a bone called
|
||
`hair_root` is hair while `chairbone` is not.
|
||
|
||
`retarget.py::SPRING_CLASSES` is a NARROWER set: the classes that actually get
|
||
secondary motion. A face-shape or eye chain is cosmetic but must never swing.
|
||
|
||
Each chain lands in `<model>.rig.json` as:
|
||
|
||
```json
|
||
{ "class": "skirt",
|
||
"root_parent": "DEF-spine.001",
|
||
"bones": ["DEF-skirt", "DEF-skirt.seg1", "DEF-skirt.seg2", "DEF-skirt.seg3"],
|
||
"tips": [[x,y,z], ...], // where each bone points, in its own space
|
||
"hulls": [[[x,y,z], ...], ...], // sample of the geometry it drives
|
||
"neighbours": [{"DEF-skirt.L": 10.7, ...}] // shared-vertex weight
|
||
}
|
||
```
|
||
|
||
`tips` exists because **a glTF skeleton carries no bone tails at all**, and
|
||
Taila's skirt panel bones have no children either, so nothing in the skeleton
|
||
says which way a panel hangs. It is measured from the geometry the bone drives.
|
||
|
||
`neighbours` means SHARED VERTICES — the artist's own answer to which pieces of
|
||
cloth are sewn together. Adjacency by name or by rest distance would both be
|
||
guesses.
|
||
|
||
## The three rules that keep it intact
|
||
|
||
1. **Cloth may only ever parent to the trunk, never to a limb.**
|
||
`rebuild_hierarchy` enforces this. A skirt parented to a thigh becomes
|
||
trousers.
|
||
|
||
2. **Cloth is never SKINNED to a leg.** There was a `bind_cloth_to_legs()` that
|
||
gave cloth vertices near a thigh a share of that thigh, so the skirt would
|
||
ride the leg the way a real one does. It is deleted. A vertex weighted 0.9 to
|
||
a thigh cannot be moved by its own cloth bone, so the solver loses the
|
||
authority to push it out of that leg — and 0.9 of a rotation always lags the
|
||
surface doing 1.0 of it, so the leg overtakes it anyway. It also poisoned the
|
||
collider measurement: 2258 skirt vertices counted as thigh geometry and fitted
|
||
a 0.28 m thigh.
|
||
|
||
3. **Cloth bones carry no animation tracks.** If the exporter bakes rest-pose
|
||
tracks onto them (`keep_anim_armature`), the AnimationPlayer overwrites the
|
||
spring solver every frame.
|
||
|
||
## Worked example: why the two shipped characters differ so much
|
||
|
||
Both are in `assets/characters/skins/`. Compare their sidecars:
|
||
|
||
| | Taila | Miku |
|
||
|---|---|---|
|
||
| source had a skeleton | yes | **no — 5 meshes, 0 joints** |
|
||
| `weights_authored` | true | **false** |
|
||
| cloth chains | 35 (127 bones) | **0** |
|
||
| twist bones | 8 | **0** |
|
||
| meshes shipped | 18 | **1** |
|
||
|
||
Miku's source (`assets/characters/incoming/miku_test.glb`) is an unrigged mesh,
|
||
so she went through `autorig.py`: joined to one mesh, rebound by nearest-bone
|
||
weighting, no cloth chains. Her twin tails and skirt are dead geometry that
|
||
cannot move, and `SkinLegRepair` runs destructively on her every spawn.
|
||
|
||
Nothing downstream can recover this. **The single highest-leverage decision in
|
||
this whole pipeline is choosing a source model that already has a skeleton with
|
||
skirt and hair bones.** Everything else is recoverable; this is not.
|
||
|
||
A quick check on any candidate, without Blender:
|
||
|
||
```python
|
||
import json, struct
|
||
with open(path,'rb') as f:
|
||
f.read(12); clen,_=struct.unpack('<II',f.read(8))
|
||
j=json.loads(f.read(clen))
|
||
nodes=[n.get('name','') for n in j['nodes']]
|
||
joints=[nodes[i] for s in j.get('skins',[]) for i in s['joints']]
|
||
print(len(j['meshes']), 'meshes', len(joints), 'joints')
|
||
print([n for n in joints if any(t in n.lower() for t in ('hair','skirt','tail','ribbon'))])
|
||
```
|
||
|
||
Several meshes, 50+ joints, and a non-empty cosmetic list means a good source.
|
||
|
||
## Checking a source model before importing
|
||
|
||
```bash
|
||
python tools/verify_character.py <model.glb>
|
||
```
|
||
|
||
What you want to see: several meshes, bone names containing `skirt`/`hair`,
|
||
twist bones (`thigh.L.001`), and weights that are NOT all at 4 influences.
|
||
`weights_authored` in the sidecar is measured from exactly this and decides
|
||
whether the destructive load-time repair runs.
|