diff --git a/.claude/skills/character-pipeline/SKILL.md b/.claude/skills/character-pipeline/SKILL.md new file mode 100644 index 0000000..79e9b2b --- /dev/null +++ b/.claude/skills/character-pipeline/SKILL.md @@ -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/.glb --name --rigged + +# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN) +python tools/pipeline.py --uid --name + +# From an unrigged mesh — auto-rigs, and accepts the quality loss +python tools/pipeline.py --input --name +``` + +Then, once, so Godot sees the new files: + +```bash +godot --headless --path . --import +``` + +The result is `assets/characters/skins/.glb` + `.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. diff --git a/.claude/skills/character-pipeline/references/cloth-and-hair.md b/.claude/skills/character-pipeline/references/cloth-and-hair.md new file mode 100644 index 0000000..db3766e --- /dev/null +++ b/.claude/skills/character-pipeline/references/cloth-and-hair.md @@ -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` 0–3 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. diff --git a/.claude/skills/character-pipeline/references/rigging.md b/.claude/skills/character-pipeline/references/rigging.md new file mode 100644 index 0000000..e038859 --- /dev/null +++ b/.claude/skills/character-pipeline/references/rigging.md @@ -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 `.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. diff --git a/.claude/skills/character-pipeline/references/separation.md b/.claude/skills/character-pipeline/references/separation.md new file mode 100644 index 0000000..46bbddb --- /dev/null +++ b/.claude/skills/character-pipeline/references/separation.md @@ -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 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 `.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 `.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 +``` + +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. diff --git a/.claude/skills/character-pipeline/references/stylization.md b/.claude/skills/character-pipeline/references/stylization.md new file mode 100644 index 0000000..ac56cfd --- /dev/null +++ b/.claude/skills/character-pipeline/references/stylization.md @@ -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`. diff --git a/.claude/skills/character-pipeline/references/verification.md b/.claude/skills/character-pipeline/references/verification.md new file mode 100644 index 0000000..59a8f23 --- /dev/null +++ b/.claude/skills/character-pipeline/references/verification.md @@ -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 | 17–35 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/.gd +godot --headless --path . -s res://debug/.gd -- res://assets/characters/skins/.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://.gd # syntax +``` + +Autoload identifiers report false "not found" errors under `--check-only` — +ignore those.