Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
6 changed files with 661 additions and 0 deletions
Showing only changes of commit daf9627ece - Show all commits
+139
View File
@@ -0,0 +1,139 @@
---
name: character-pipeline
description: Import, rig, stylize and animate an anime-styled character into Papaya-Shooter as a selectable skin — keeping the model's own skeleton, artist weights and separate body/cloth/hair meshes, with cloth and hair driven by the spring solver. Use when adding a new playable character, re-importing an existing one, debugging skinning/cloth/hair/animation problems on a character, or changing the cel-shaded look. Triggers on "add a character", "import a skin", "new playable model", "skirt clipping", "hair flailing", "T-posing", "character looks squashed".
---
# Character pipeline
Turns a source model into a playable, cel-shaded, cloth-simulated character skin.
The whole design follows one principle, which is also what the Hoyoverse-class
anime pipelines (Genshin / Star Rail / Zenless Zone Zero) are built on:
> **The character is not one object. It is a body, a set of garments, and hair —
> authored separately, rigged separately, and moved by different systems.**
> The body is skinned and animated. The garments and hair are bone chains that
> the animation never touches; physics moves them. Keeping those separate is
> what makes the result read as an anime character instead of a mannequin in a
> painted-on costume.
Everything below exists to protect that separation.
## The one rule
**If a model arrives with a skeleton, that skeleton ships.** Its bones, its
artist-painted weights, its per-part meshes and its skirt/hair chains all
survive. Only the ANIMATION is moved onto it.
The old route (`strip_rig.py``autorig.py``merge_animations.py`) solved a
bone-*naming* problem by destroying the asset — 18 meshes became 1, 21 skirt
bones and ~50 hair bones became 0, and 16% of vertices ended up pulled by both
legs. `tools/rig_map.py` solves naming properly now. **Never reach for
`strip_rig.py` or `--rebind`** unless the model genuinely has no skeleton at all.
## Doing it
```bash
# From an already-rigged local model (the normal case)
python tools/pipeline.py --input assets/characters/incoming/<name>.glb --name <name> --rigged
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN)
python tools/pipeline.py --uid <uid> --name <name>
# From an unrigged mesh — auto-rigs, and accepts the quality loss
python tools/pipeline.py --input <mesh.glb> --name <name>
```
Then, once, so Godot sees the new files:
```bash
godot --headless --path . --import
```
The result is `assets/characters/skins/<name>.glb` + `<name>.rig.json`, and a
registry entry in `skins.json` that `SkinManager` picks up with no code change.
Blender is required (`BLENDER_PATH`, or auto-found under
`C:\Program Files\Blender Foundation`). Godot lives at
`C:\Program Files\Godot\Godot_v4.7-stable_win64_console.exe`.
## The stages, and what each one protects
| Stage | Where | Protects |
|---|---|---|
| Fix unlit/emissive materials | `tools/gltf_fix.py` | Textures surviving import at all |
| Resolve bone ROLES, not names | `tools/rig_map.py` | The model's own skeleton |
| Rebuild parenting | `retarget.py::rebuild_hierarchy` | Limbs/cloth following the hips |
| Subdivide cloth panels | `retarget.py::subdivide_cloth_panels` | A skirt being able to bend at all |
| Retarget clips as rest-relative deltas | `retarget.py::retarget_clip` | Limbs not being twisted by foreign bone roll |
| Leave cosmetic bones unkeyed | `export_optimize_animation_keep_anim_armature=False` | Physics owning the cloth |
| Write the rig sidecar | `retarget.py::describe_rig` | The runtime never re-guessing anatomy |
| Cel look | `LevelMaterials.apply_character_look` | The model's own line-work not being re-lit |
| Cloth + hair | `characters/spring_bones.gd` | Clothes reading as clothes |
## Read before you touch anything
Load the reference that matches what you are doing. They are short and each one
is a list of things that cost a debugging cycle to learn.
- **`references/separation.md`** — body vs. garments vs. hair: what must stay
separate, how cloth chains are detected and classed, why cloth is never
skinned to a leg, and the ZZZ-convention mapping.
- **`references/rigging.md`** — role resolution, hierarchy rebuild, cloth panel
subdivision, twist bones, joint helpers, the retarget maths.
- **`references/cloth-and-hair.md`** — the position-based spring solver, its
colliders, per-class tuning, collision hulls, LOD and cost.
- **`references/stylization.md`** — cel shading, outlines, the imported
line-work trap, eyes, materials.
- **`references/verification.md`** — every measuring tool, what each one
actually measures, and the pose-reading trap that invalidated all of them
once. **Read this before trusting any measurement.**
## Non-negotiables
1. **Never join meshes.** Per-part meshes are how body, cloth and hair stay
separable — for materials, for the outline pass, and for the cloth solver's
hull extraction.
2. **Never key cosmetic bones.** If a clip has tracks on skirt/hair bones, the
AnimationPlayer overwrites the solver every frame and the cloth goes rigid.
3. **Never skin cloth to a leg.** 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 thigh — and the leg still overtakes it. There was a
`bind_cloth_to_legs()`; it is deleted, and the note above its grave in
`retarget.py` says why.
4. **Never run `SkinLegRepair` on authored weights.** It snaps weights and
deletes triangles. It exists only to undo auto-rigging. It is gated on
`weights_authored`, which is MEASURED, not assumed.
5. **Measure from inside the modifier pass.** See `references/verification.md`.
## After importing
```bash
godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks
godot --headless --path . -s res://debug/cloth_clip_check.gd # leg-through-cloth
godot --headless --path . -s res://debug/cloth_settle_check.gd # idle stability
godot --headless --path . -s res://debug/cloth_perf_check.gd # ms per character
godot --headless --path . -s res://debug/travel_dir_check.gd # legs face travel
```
What "good" looks like on Taila, for calibration:
| Measure | Good | Bad |
|---|---|---|
| Idle skirt movement | < 0.1 deg/frame | 0.5+, or never decaying |
| Leg inside cloth, idle/walk | < 25 mm | 100 mm |
| Leg inside cloth, run/slide/dash | ~95 mm *(current, unsolved)* | — |
| Solver cost | ~2.6 ms/character | 10 ms |
| Stride vs. travel direction | < 10° (except a capped sidestep) | 90° |
## Known-unsolved
- **Peak cloth clipping** in a run, slide and dash sits at ~95 mm of thigh
inside the skirt. Idle, walk and fall are clean. The solver sees the contact
and pushes on it every iteration; the remaining gap is a standing fight
between the collision and the garment's own shape constraints.
- **No foot IK.** Feet do not plant on ground height, so stairs and uneven
ground read as sliding.
- **No strafe or backpedal clips.** Direction is conveyed by yawing the hips
(`SkinnedPlayerModel._update_travel`), which is capped, so a pure sidestep
still runs its legs ~40° off the direction of travel.
@@ -0,0 +1,146 @@
# Cloth and hair — `characters/spring_bones.gd`
A position-based (PBD) solver over the rig's own cosmetic bones, the same shape
as Magica Cloth 2's BoneCloth, which is the tool the Hoyoverse-style pipelines
are built around.
## Why cloth cannot be solved with weights
Weight a skirt to the thighs → trousers. Weight it to the hips → a rigid bell.
Neither is cloth. A skirt reads as cloth because it **lags** — it keeps going
when the hips stop, swings out through a turn, floats on the way up through a
jump. That is inertia, and inertia has to be integrated, not skinned.
## The solve
Installed as a `SkeletonModifier3D` **after** `ShooterPoseModifier`, so it reacts
to the final body pose (animation + lean/slide/aim layer).
1. **Every JOINT is a particle.** Bone `i` spans particle `i` to `i+1`, so a
bone's HEAD can move. This is the load-bearing choice: a contact with no
rotational leverage — a thigh against the top of a panel — is resolved by the
whole panel moving, which is what a real skirt does.
2. **Predict** with inertia, gravity and wind, in the chain ANCHOR's frame, so
travelling at a steady speed excites nothing.
3. **Relax everything together**, Gauss-Seidel: cross-panel links, then per
chain — bone length, bend limit, backstop, colliders.
4. **Convert to rotations once**, at the end, and feed back the poses the
skeleton ACTUALLY got.
Order matters: **links first, chains second**, so the last thing to touch any
particle is its collision. With the chains first, every iteration ended by
pulling neighbouring panels back toward their rest separation — straight into the
leg just cleared. Measured on a slide: 93 mm in, 95 mm out; with the links off
entirely the same frame solved to 27 mm.
Then a short tail of **collision-and-length-only** passes, because the bend limit
and the backstop are shape constraints and re-imposing them after each collision
makes the two argue rather than converge.
## What the previous version did wrong
One spring per bone plus FOUR repair passes behind it, each writing bone poses the
next read back and partly undid — and the last (an ancestor "lift") wrote poses
never fed back into the spring state, so every frame began pulling against a pose
the springs did not know about. That feedback was the skirt "glitching out".
Three bolt-on stages (a bodily chain push, an ancestor lift, a drape weight) all
existed because a rotation-only solver cannot clear a contact near the head it
rotates about.
## Per-class tuning (`TUNING`)
| | w | zeta | gravity | wind | stray | hinge | bend |
|---|---|---|---|---|---|---|---|
| hair | 15 | 0.45 | 1.6 | 0.30 | 0.45 | 26° | 52° |
| skirt | 12 | 0.48 | 2.0 | 0.18 | 1.25 | 78° | 55° |
| cloth | 13 | 0.55 | 1.9 | 0.18 | 1.10 | 62° | 52° |
- **`hinge` vs `bend`** are the same constraint meaning different things. Between
segments it is BENDING stiffness (how sharply cloth may crease) and belongs
tight. At the root it is a HINGE at the waistband — a knee coming up to hip
height puts a thigh horizontally through where the front panel hangs, and the
panel must ride onto it, which is most of a right angle. Magica's skirt guide
suggests 20° at the root, but that is for a chain whose first bone is a large
share of the skirt; Taila's first segment is 49 mm of a 288 mm panel, so 20°
there moves the panel below it by **17 mm**.
- **`stray`** is Magica's "Max Distance" — how far a particle may end from where
the animation alone would have put it. Tight on hair (it has nothing to get out
of the way of; this is what stops flailing), loose on cloth (a skirt has to lie
along a thigh that has gone horizontal in a slide).
- **`gravity` is small** because the authored rest pose already has the garment
hanging. A constant force offsets the resting particle by `g/w²`, so a large
value pulls the hem below where it was modelled — into the thigh it then has to
be pushed out of.
## Colliders
Five capsules, measured from the mesh by `retarget.py::_leg_colliders`:
- A **waist LID** (`lid: true`) across the pelvis. Magica's skirt guide is blunt
about this: one big sphere at the waist "acts as a lid that prevents particles
in the skirt from slipping into the body". Leg capsules alone only stop cloth
going through a thigh; nothing stops a panel swinging INWARD into the pelvis.
- **Tapered** thigh and shin capsules — separate head and tail radii. A limb is
not a cylinder: Taila's thigh is ~0.11 m at the hip and ~0.06 m above the knee.
Fitted as a least-squares line through ten bands, dropping the contaminated end
bands, with twist children folded in.
- `from: 0.10` — the capsule starts BELOW the hip joint. The top of a thigh is
hip, buried inside the body the skirt hangs from.
**Per-point rest clearance.** Each (bone, collider) point's radius is capped to
just inside where that point rests, so the authored rest pose is a valid state.
Without it, cloth hanging against a thigh is shoved out and pulled back every
frame forever. The cap is PER POINT, not per bone — scaling a whole bone by its
worst point switches collision off for every panel whose top hangs against the
thigh, which is all the ones that matter.
## Collision hulls come from the MESH
`SkinnedPlayerModel._cloth_hulls`, at load time: every vertex a cloth bone
dominates, binned into a ~20 mm grid, outermost cells kept, capped at 14 points.
The sidecar's ten farthest-point samples describe a panel's corners and hem and
leave its MIDDLE unsampled — exactly where a thigh comes through. The solver
reported every contact resolved while 158 vertices sat 95 mm inside a leg.
## There is no drape term
"Cloth takes a share of the leg's motion before the solver runs" is a real
technique (Hoyoverse rigs carry a partial constraint from the leg onto the upper
skirt bones). It was here to move panels the old rotation-only solver could not.
With it against without, over the movement sweep:
```
run 101 -> 92 mm fall 82 -> 49 mm dash 136 -> 95 mm
idle after a dash 103 -> 20 mm
```
Worse in every state but a walk, and 20× worse in stability (0.48 vs 0.05
deg/frame at a dead idle) because its target sat inside the leg the collision was
pushing out of. **If you reintroduce it, the target must be collision-free
first.** A naive "seat the reference on the limb" pass was tried and destabilised
the reference chain, because a parent's seat rotation cascades into every child.
## Cost and LOD
~2.6 ms per character per frame at full quality, three quarters of it collision.
It was 10.9 ms before the inner loop stopped rebuilding every capsule and
reallocating the hull array for every (bone, collider, pass).
`SpringBones.lod` 03 drops passes then collision;
`SkinnedPlayerModel._update_cloth_lod` picks it from camera distance
(6 / 14 / 28 m) four times a second.
If you add cloth bones, re-run `debug/cloth_perf_check.gd`. The cost is the
product of joints × colliders × hull points × passes and all four are easy to
raise by accident.
## Hair specifically
- Hair DOES collide now. It used to be excluded because a collision push happened
after the integrator and so was deaf to spring tuning — long back hair got
shoved out of a thigh and hauled back at stride frequency, which was the blur.
Inside the relaxation there is no such fight.
- Hair chains are NOT linked sideways; linking them stiffens them into rope.
- Hair sits silent at idle (0.005 deg/frame). If it does not, something is
driving its target — that was the drape, and it is the first thing to suspect.
@@ -0,0 +1,102 @@
# Rigging and retargeting
## Roles, not names
`tools/rig_map.py` resolves a skeleton to ROLES — `hips`, `spine[]`, `neck`,
`head`, and `limb[(role, side)]` for `thigh/shin/foot/toe/shoulder/upper_arm/
forearm/hand`. Matching is by whole tokens plus anatomy (chain length, position,
which bone is a child of which), so a Rigify `DEF-thigh.L`, a Mixamo
`mixamorig:LeftUpLeg` and a bespoke `Bip01_L_Thigh` all land on the same role.
This is what removed the need to destroy foreign skeletons. `roles.missing_core()`
is the gate: if the core roles cannot be found the pipeline stops rather than
guessing.
The resolved roles are written to `<model>.rig.json` and read at runtime by
`ShooterPoseModifier._resolve`, which aliases its library-flavoured names
(`DEF-hips`, `DEF-spine.001`…) onto whatever this rig calls them. Taila's hips
are `DEF-spine`, her head is `DEF-spine.006`, and she has **no bone with "neck"
in its name at all** — unresolved, every lean, aim pitch and slide head-lift
silently did nothing.
## Rebuilding the hierarchy
A Rigify DEF-rig exports its chain roots parented straight to the armature root,
because Rigify drives them by constraint rather than by hierarchy. Left that way,
rotating the hips leaves the legs, skirt and hair floating in place.
`rebuild_hierarchy` re-attaches orphans: by anatomy where the role is known, and
by rest geometry (nearest plausible parent) otherwise. **Cloth may only attach to
the trunk.**
## Subdividing cloth panels
`subdivide_cloth_panels(arm, meshes, roles, segments=4)`.
A skirt panel that is a single bone from the waist is a rigid flap: it can only
rotate about its own head, and a contact near that head is unreachable at any
angle. Splitting each panel into a chain is what lets it bend, and it is why the
ZZZ-convention skirt is a grid rather than a fan.
On Taila this turns 21 panel bones into 21 chains of 4. The segment lengths come
out uneven (49/49/49/141 mm) because the last segment runs on to the hem.
Weights are redistributed along the panel as it is split, so the mesh follows the
new chain.
## Twist bones
A forearm or thigh twist bone takes half the roll of its parent so the skin does
not candy-wrap. They are detected (`is_segment_of`) and recorded in the sidecar's
`twist` list. They are also folded into the limb when measuring collider radii:
most of a thigh's surface belongs to `DEF-thigh.L.001`, and what is left
dominated by `DEF-thigh.L` is mostly hip flare, which fitted a 0.154 m radius —
a 30 cm thigh.
## Joint helpers
`SkinJointHelper.install` runs for EVERY model however it was rigged. Linear-blend
skinning collapses any joint by cos(angle/2) no matter how good the weights are;
measured at the knee, 0.77 without helpers against 0.99 with. They are updated
LAST, inside the modification pass, so each helper tracks whatever final rotation
its child bone ended up with.
## The retarget maths
Bake each clip as a **rest-relative delta**:
```
R_world = src_pose_rot * src_rest_rot⁻¹ what the clip does
tgt_rot = R_world * tgt_rest_rot done to THIS rig
```
Copying absolute world orientation instead — which is what a constraint bake does
— forces the library's bone ROLL onto a mesh bound with a different one, and
twists every limb by a constant offset.
Also handled: a facing correction (`facing_correction`) when the library and the
character face different ways, and a hips-height scale so a short character does
not float.
## Export flags that matter
```python
export_bake_animation=False,
export_optimize_animation_keep_anim_armature=False,
```
`keep_anim_armature` forces a track onto every bone whether or not the clip
touches it. Off, the skirt and hair export with **no tracks at all** and belong
entirely to the spring solver. This one flag is the animation/physics split.
## Height normalisation
`flatten_and_scale(arm, meshes, TARGET_HEIGHT)` — default 1.75 m. Applied before
the retarget so the library's stride matches the character's legs.
## When a model has no skeleton
`tools/autorig.py` will fit one, and the pipeline accepts the quality loss:
nearest-bone weights, cross-leg bleed, no cloth chains. `weights_authored` comes
out false, `SkinLegRepair` runs at load to snap the worst of it, and the
character will have no secondary motion. Prefer finding a rigged source.
@@ -0,0 +1,109 @@
# 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 24 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.
## 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.
@@ -0,0 +1,71 @@
# Stylization — the cel-shaded look
Two passes, applied at load in `SkinnedPlayerModel.load_model`:
```gdscript
LevelMaterials.apply_toon_recursive(scene) # world-wide toon shading
LevelMaterials.apply_character_look(scene) # character-only corrections
```
## The trap: imported models bring their own line-work
Anime models exported from MMD/VRoid/Blender toon setups very often ship the
outline **as geometry** — an inverted-hull shell of the mesh with a flat black,
UNTEXTURED material, plus separate flat cards for the eye whites, irises and the
pupil highlight. The mesh you import is not just the character; part of it is
already the drawing.
Toon-lighting that shell is what put a **white rim on every hair strand**. It is
an inverted hull whose normals face away from you; a lighting model that adds a
rim term lights it brightly exactly where it is supposed to read as ink.
`apply_character_look` therefore treats **any untextured surface on a character**
as the model's own line-work and handles it flat and unshaded:
- **Outline hull** → made fully transparent rather than deleted. Deleting a
surface would renumber the rest and break the mesh's own skin bindings. The
game draws its own outline.
- **Eye cards** (`resource_name` starts with `eyes`) → flat ink, except anything
with `HL` in the name, which is the glint in the pupil and really is white.
If a newly imported character comes out with a white halo, or with black eyes
that should have irises, this function and its name-matching are where to look —
the naming conventions vary by source and this is the one place they are read.
## Materials on import: the unlit problem
Anime glTFs are very often exported "unlit": `KHR_materials_unlit`, a **black**
`baseColorFactor`, and the real texture wired to `emissiveTexture`. Renderers
honouring the unlit extension use base colour and ignore emission — so Blender
reads black, never references the images, and imports with `bpy.data.images`
**empty**. The character comes out a silhouette, and there is no node graph left
to patch afterwards.
`tools/gltf_fix.py` rewrites the container **before** import: emissive becomes
base colour, the unlit flag is dropped. It must run first — this is the first
thing `retarget.py::main` does, before `import_any`.
`fix_unlit_materials(meshes)` then repairs anything left inside Blender.
## What the toon pass does
`apply_toon_recursive` gives everything the game's banded ramp. `apply_character_look`
then softens the banding on characters, because re-banding an already-shaded
anime texture reads as gloss — the texture already contains its own shading and
the second pass fights it.
## Convention alignment
The Hoyoverse-class look is, broadly: a ramp texture indexed by NdotL for the
body, a separate ramp and often a dedicated shader for the face, an inverted-hull
outline whose width is vertex-colour-modulated, and specific handling for eyes
and hair highlights. This project does the simplified version — one banded ramp
plus a screen-space-ish ink treatment, and the model's own outline shell hidden
in favour of the game's. The face is NOT specially shaded here; if a character
comes out with harsh shadow shapes across the nose, that is the missing piece.
## Outline thickness
Lives with the toon material in `scenes/maps/level_materials.gd`
(`CHARACTER_INK` and the outline settings). This is the branch it was last
touched on — `feat/outline-thickness-and-tp-weapon-hold`.
@@ -0,0 +1,94 @@
# Verification — and the trap that invalidated all of it
## READ THIS FIRST
**Godot restores every bone's local pose after the `SkeletonModifier3D` pass.**
So calling `force_update_all_bone_transforms()` and reading
`get_bone_global_pose()` from a `SceneTree` script, from `_process`, or anywhere
outside that pass recomputes the globals from the **animation alone**. The
shooter pose layer and the cloth solver are simply not in what you measure.
`debug/cloth_clip_check.gd` did exactly this. It reported the same ~95 mm of
leg-inside-skirt with collision fully enabled **and with the collision call
commented out**. Every number ever taken from that tool before 2026-07-26 is
void, and several rounds of "tuning did nothing" in the history were reading a
pose the solver never touched.
**To measure a pose layer, add your own `SkeletonModifier3D` as a child of the
`Skeleton3D` AFTER the one you care about, and snapshot inside its
`_process_modification()`.** The `PoseProbe` class in `cloth_clip_check.gd` and
`travel_dir_check.gd` is the pattern.
Two related traps:
- **Headless runs uncapped**, so the engine delta is sub-millisecond and anything
integrated barely moves. Set `SpringBones.fixed_delta = 1.0/60.0`.
- **A single frame of a locomotion clip measures the clip.** A run cycle twists
the torso against the hips by tens of degrees twice per stride, swamping
anything a pose layer does. Average over a stride.
## The tools
| Tool | Measures | Good |
|---|---|---|
| `spawn_smoke_test.gd` | spawn, skins, anim tree, camera, state cycling | 29 OK, 0 failures |
| `cloth_clip_check.gd` | leg-inside-cloth per movement state, per vertex | idle < 25 mm |
| `cloth_settle_check.gd` | deg/frame at a dead idle, contacts/frame | skirt < 0.1, hair < 0.01 |
| `cloth_perf_check.gd` | ms per character per frame | ~2.6 ms |
| `cloth_allow_check.gd` | how much of each limb the rest-clearance cap makes the solver blind to | 1735 mm on Taila |
| `cloth_stretch_check.gd` | mesh tearing between panels | no 3× edges |
| `travel_dir_check.gd` | stride direction vs. travel direction | < 10° except a capped sidestep |
| `limb_deform_check.gd` | joint collapse | knee ~0.99 |
| `verify_character.py` | meshes, bones, weights of a SOURCE model | several meshes, cloth bones present |
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
Run them:
```bash
godot --headless --path . -s res://debug/<tool>.gd
godot --headless --path . -s res://debug/<tool>.gd -- res://assets/characters/skins/<name>.glb
```
Scripts run with `-s` MUST extend `SceneTree`. A `Node` script never quits and
hangs forever.
## Measure the right quantity
`cloth_clip_check.gd` used to report "how much CLOSER the leg got than the artist
modelled it". A hem 200 mm clear of a shin legitimately comes 180 mm closer when
the leg kicks out in a slide, and counting that as a failure buried the real
clipping under motion the character is supposed to have. It now reports how far
INSIDE a capsule a cloth vertex is, over and above however far inside it was
modelled — only cloth actually within the capsule can be showing a leg through.
It also applies the collider's `from` offset, so it tests the same band of thigh
the solver is defending. Measuring the full bone tests the hip cap the solver
deliberately excludes and reports it as clipping no tuning can fix.
## Diagnosing "the solver isn't working"
In order:
1. **Is the measurement inside the modifier pass?** (Above. Do this first.)
2. **Does the solver SEE the contact?** `debug_hit_report()` — bone → deepest
overlap it found. If ~0 while the mesh is deep inside a leg, the collision
hull does not cover the geometry that is clipping.
3. **Does it CONVERGE?** `debug_residual_report()` — overlap left after the
relaxation. Seen 93 mm, left 95 mm is a standing fight, not slow convergence;
quadrupling the iterations will buy nothing. Find what is pulling back.
4. **Only then, tune.**
That order was learned the hard way: the drape, the bend limits, the backstop,
the iteration count and the hull sampling were each suspected and tested, and
the answer was in step 1.
## Also run
```bash
godot --headless --path . -s res://movement/tests/run_fsm_tests.gd # 11 tests
godot --headless --path . --check-only --script res://<file>.gd # syntax
```
Autoload identifiers report false "not found" errors under `--check-only`
ignore those.