Feat/outline thickness and tp weapon hold #22
@@ -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` 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.
|
||||
@@ -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 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.
|
||||
|
||||
## 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 | 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/<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.
|
||||
Reference in New Issue
Block a user