diff --git a/.claude/skills/character-pipeline/references/stylization.md b/.claude/skills/character-pipeline/references/stylization.md index ac56cfd..178a216 100644 --- a/.claude/skills/character-pipeline/references/stylization.md +++ b/.claude/skills/character-pipeline/references/stylization.md @@ -19,8 +19,16 @@ 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: +`apply_character_look` therefore looks for the model's own line-work and handles +it flat and unshaded. "Untextured" alone is NOT the test — that made every +flat-coloured model render as a black silhouette, because Quaternius' mannequin +has two untextured materials (a yellow body, lilac joints) and both were hidden +as though they were an outline shell. `_is_line_work()` asks three things +instead: is it named `eyes*`, is it drawn front-face-culled (the classic +inverted-hull setup), or is its albedo near-black. An ink shell is black; a +flat-coloured character is any colour at all. + +What it then does: - **Outline hull** → made fully transparent rather than deleted. Deleting a surface would renumber the rest and break the mesh's own skin bindings. The @@ -28,9 +36,15 @@ as the model's own line-work and handles it flat and unshaded: - **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. +If a newly imported character comes out with a white halo, a black silhouette, +or black eyes that should have irises, `_is_line_work()` and the name-matching +below it are where to look. The conventions vary by source and this is the one +place they are read. + +Taila's eyes still render as black cards rather than amber irises. Her eye +surfaces are untextured, and the glTF import hands every untextured surface a +default near-white albedo, so colour cannot tell an iris card from a lash card +on her — the name is all there is, and `eyes*` currently means "ink". Unfixed. ## Materials on import: the unlit problem diff --git a/assets/characters/skins/mannequin.glb b/assets/characters/skins/mannequin.glb new file mode 100644 index 0000000..c6e997a Binary files /dev/null and b/assets/characters/skins/mannequin.glb differ diff --git a/assets/characters/skins/mannequin.license.json b/assets/characters/skins/mannequin.license.json new file mode 100644 index 0000000..4ac3c59 --- /dev/null +++ b/assets/characters/skins/mannequin.license.json @@ -0,0 +1,8 @@ +{ + "name": "Universal Animation Library \u2014 Mannequin", + "author": "Quaternius", + "license": "CC0 1.0 Universal (Public Domain Dedication)", + "url": "https://quaternius.com/", + "source": "assets/characters/animations/_library.glb", + "note": "The reference mannequin shipped inside the animation library this project already uses. No attribution required under CC0; recorded anyway, and because the library's own LICENSE asks that Quaternius be credited." +} \ No newline at end of file diff --git a/assets/characters/skins/mannequin.rig.json b/assets/characters/skins/mannequin.rig.json new file mode 100644 index 0000000..6aa3a53 --- /dev/null +++ b/assets/characters/skins/mannequin.rig.json @@ -0,0 +1,131 @@ +{ + "roles": { + "hips": "DEF-hips", + "head": "DEF-head", + "neck": "DEF-neck", + "spine": [ + "DEF-spine.001", + "DEF-spine.002", + "DEF-spine.003", + "DEF-neck", + "DEF-head" + ], + "upper_arm.R": "DEF-upper_arm.R", + "upper_arm.L": "DEF-upper_arm.L", + "forearm.R": "DEF-forearm.R", + "toe.R": "DEF-toe.R", + "hand.L": "DEF-hand.L", + "thigh.R": "DEF-thigh.R", + "foot.R": "DEF-foot.R", + "shin.L": "DEF-shin.L", + "shin.R": "DEF-shin.R", + "thigh.L": "DEF-thigh.L", + "toe.L": "DEF-toe.L", + "shoulder.L": "DEF-shoulder.L", + "shoulder.R": "DEF-shoulder.R", + "hand.R": "DEF-hand.R", + "foot.L": "DEF-foot.L", + "forearm.L": "DEF-forearm.L" + }, + "chains": [], + "twist": [], + "colliders": [ + { + "bone": "DEF-hips", + "child": "DEF-spine.001", + "from": 0.0, + "radius_head": 0.1381, + "radius_tail": 0.1381, + "radius": 0.1381, + "lid": true + }, + { + "bone": "DEF-thigh.L", + "child": "DEF-shin.L", + "from": 0.1, + "radius_head": 0.1037, + "radius_tail": 0.0733, + "radius": 0.0733 + }, + { + "bone": "DEF-thigh.R", + "child": "DEF-shin.R", + "from": 0.1, + "radius_head": 0.1037, + "radius_tail": 0.0733, + "radius": 0.0733 + }, + { + "bone": "DEF-shin.L", + "child": "DEF-foot.L", + "from": 0.1, + "radius_head": 0.0927, + "radius_tail": 0.0516, + "radius": 0.0516 + }, + { + "bone": "DEF-shin.R", + "child": "DEF-foot.R", + "from": 0.1, + "radius_head": 0.0927, + "radius_tail": 0.0516, + "radius": 0.0516 + } + ], + "weights_authored": true, + "driven_bones": [ + "DEF-f_index.01.L", + "DEF-f_index.01.R", + "DEF-f_index.02.L", + "DEF-f_index.02.R", + "DEF-f_index.03.L", + "DEF-f_index.03.R", + "DEF-f_middle.01.L", + "DEF-f_middle.01.R", + "DEF-f_middle.02.L", + "DEF-f_middle.02.R", + "DEF-f_middle.03.L", + "DEF-f_middle.03.R", + "DEF-f_pinky.01.L", + "DEF-f_pinky.01.R", + "DEF-f_pinky.02.L", + "DEF-f_pinky.02.R", + "DEF-f_pinky.03.L", + "DEF-f_pinky.03.R", + "DEF-f_ring.01.L", + "DEF-f_ring.01.R", + "DEF-f_ring.02.L", + "DEF-f_ring.02.R", + "DEF-f_ring.03.L", + "DEF-f_ring.03.R", + "DEF-foot.L", + "DEF-foot.R", + "DEF-forearm.L", + "DEF-forearm.R", + "DEF-hand.L", + "DEF-hand.R", + "DEF-head", + "DEF-hips", + "DEF-neck", + "DEF-shin.L", + "DEF-shin.R", + "DEF-shoulder.L", + "DEF-shoulder.R", + "DEF-spine.001", + "DEF-spine.002", + "DEF-spine.003", + "DEF-thigh.L", + "DEF-thigh.R", + "DEF-thumb.01.L", + "DEF-thumb.01.R", + "DEF-thumb.02.L", + "DEF-thumb.02.R", + "DEF-thumb.03.L", + "DEF-thumb.03.R", + "DEF-toe.L", + "DEF-toe.R", + "DEF-upper_arm.L", + "DEF-upper_arm.R", + "root" + ] +} \ No newline at end of file diff --git a/assets/characters/skins/skins.json b/assets/characters/skins/skins.json index 77624be..0d98e6d 100644 --- a/assets/characters/skins/skins.json +++ b/assets/characters/skins/skins.json @@ -3,7 +3,7 @@ { "id": "miku", "name": "Miku", - "description": "", + "description": "Hatsune Miku \u2014 Virtual Idol", "model": "res://assets/characters/skins/miku.glb", "unlocked": true }, @@ -13,6 +13,13 @@ "description": "", "model": "res://assets/characters/skins/taila.glb", "unlocked": true + }, + { + "id": "mannequin", + "name": "Mannequin", + "description": "Quaternius reference mannequin (CC0)", + "model": "res://assets/characters/skins/mannequin.glb", + "unlocked": true } ] } \ No newline at end of file diff --git a/docs/3D_ASSET_PIPELINE.md b/docs/3D_ASSET_PIPELINE.md index 05b76bc..2381ff4 100644 --- a/docs/3D_ASSET_PIPELINE.md +++ b/docs/3D_ASSET_PIPELINE.md @@ -114,6 +114,236 @@ nothing in the skeleton says which way a panel hangs or how thick a thigh is. Tuning per class (hair stiffer and lighter, skirt slacker and heavier) lives in `SpringBones.TUNING`. +### Collision, and why cloth "never settles" + +Getting the capsules wrong does not look like a collision bug — it looks like +cloth that jitters forever. Four things had to be right: + +1. **A limb tapers.** Taila's thigh is ~0.10 m across at the hip and ~0.055 m + above the knee. Head and tail radii are stored separately and interpolated. +2. **A leg's own vertices are not the leg.** Most of the thigh is weighted to + the TWIST bone; what is left dominated by `DEF-thigh.L` is the hip flare, + which measured a 0.154 m radius — a 30 cm thigh. Twist children are folded + in, and only vertices that clearly belong to the limb (dominant weight > 0.6) + are counted, or a hip/thigh/glute blend puts the flare straight back. +2b. **Measure the taper, do not pick a percentile of a pooled bucket.** Every + single-number answer is wrong at one end: the median leaves half the limb's + surface OUTSIDE its own collider, so cloth pushed out to it is clear of the + capsule while the thigh is visibly through it; a high percentile over- + measures the shaft by 30% because the top bucket is still the hip. Take the + 90th percentile in each of ten bands along the bone, drop the two contaminated + end bands, and fit a line. Taila's thigh: 0.116 m at the hip to 0.063 m above + the knee, against a real surface of 0.106 → 0.058 plus cloth thickness. +3. **The rest pose must be a valid state.** The artist modelled the skirt over + these legs, so a capsule is capped per bone to just inside its own rest + clearance. Without that, bones resting against the thigh were shoved out and + pulled straight back in every frame — measured 13 of 70 cloth bones in + permanent contact in a dead-still idle, a limit cycle that never decayed. + That was the "hair and skirt never settle", and it also left the collider + saturated and useless against real clipping. +4. **Resolve as a rotation, sampled along the bone.** A skirt panel is a sheet + and its bone tip is at the far edge; pushing only the tip out leaves the + middle of the panel inside the thigh — the thigh visibly clipping through the + front of the skirt at a stride. +5. **Carry the whole rotation back, not a tip.** Most of what lifts a wide sheet + off a thigh is rotation about the bone's OWN axis, and a twist moves the tip + not at all. Reading the correction off the corrected tip therefore threw away + the part that mattered: the solver measured 50–70 mm of thigh inside a panel, + corrected it every frame, and the render never changed. +6. **Apply the drape to the bone, not to the spring's target.** The final + rotation is measured FROM the drape target and applied TO the undraped basis, + so rotating only the target cancelled out exactly at equilibrium and the + drape was a silent no-op. This is why raising the drape weight — even to 0.99 + — never moved a panel off a thigh, and it sent three rounds hunting the + collision solver for a fault that was never there. In a chain, apply each + link's share as the DIFFERENCE from what its parent already carries, or three + segments at 0.45 compound to 1.35 of the thigh's swing at the hem. +7. **Fix the deepest contact, not the one wanting the largest angle.** The angle + is depth ÷ leverage, so a graze on a hull point sitting almost on the bone's + head outbids a 60 mm impalement further down and spends the pass rotating + about a point that barely moves. Cap the turn so it never throws the rest of + the panel further than the overlap being fixed, or a 5 mm contact swings a + panel 34°. +8. **Take the leg out of the REST TARGET too.** While the spring's target sits + inside a thigh, the collision pushes out and the spring hauls straight back + forever. Resolving the target first makes resting on a leg an equilibrium. + +### The drape has to RIDE the limb, not copy its angle + +This was the one that mattered, and it hid behind every other theory for a long +time. The drape used to take the thigh's ROTATION and apply it to the panel's +basis — which turns the panel about ITS OWN head, up at the waist. The thigh +turns about the HIP, some 20 cm lower. The two arcs are nothing alike, so the +panel never tracked the leg however high the drape weight went, and the thigh +walked straight out through the front of the skirt. + +Carry the bone's rest tip through the limb's FULL transform instead — pivot +included — and aim the bone at the result: + +``` +carried = L_now * L_rest⁻¹ * tip_rest # where the limb would take it +aim = slerp(current_dir, carried_dir, w) # w = how much of it rests there +``` + +That is what "the cloth rides the leg" actually means, and it is the difference +between a panel that gets overtaken and one that lifts over the thigh. + +Two supporting pieces, both needed: + +- **Which limb, decided per frame.** A static both-thigh split by rest distance + is right for a panel hanging between the legs and wrong the moment they + scissor — the halves cancel, the panel does not move, and the advancing thigh + walks into it. `DRAPE_BITE` keeps a limb's share climbing once its gap goes + negative, so the leg actually inside the cloth wins outright. +- **The correction walks UP the chain** (`_lift_chains`). A bone rotates about + its own head, so it can move a point by at most twice that point's distance + from the head — and the front panels' contacts sit 10-48 mm from their pivot + against a thigh ~100 mm inside them. Their ancestors have 5-15x the lever, so + a few degrees up the chain does what no local rotation could. Keep the per-bone + cap small (4°): at 9° over 3 passes the whole panel hiked up. + +Tuning that matters: `DRAPE_MAX` 0.55. At 0.9 the panel over-swings and hikes; +at 0.0 the thigh passes straight through. Panels subdivide into 4 segments — 3 +leaves the pivots too far from the contacts, 6 lets the chain curl up. + +### What a production cloth setup actually does + +Everything above was arrived at by measurement, and it converged on a solver that +was still missing the single most important piece. From Magica Cloth 2's BoneCloth +skirt guide (the Unity asset most anime-style games use for exactly this garment): + +> Normally, the skirt bones are linked vertically due to the parent-child +> structure, **but not horizontally** … If horizontal bones are not connected, the +> accuracy of collision detection will be significantly reduced. **This is the +> most important work when expressing a skirt with BoneCloth.** + +That was the gap. Every chain here solved alone, so each panel individually +satisfied its constraints while the garment came apart. Four things were taken +from that guide and its penetration page: + +1. **Horizontal links between chains** (`LINK_PASSES`, `_build_links`). Distance + constraints between the TIPS of bones that share mesh vertices, relaxed + Gauss-Seidel after the springs and collision — Magica's "Near Point" + connection. Adjacency comes from the sidecar's `neighbours`, which is shared + vertex weight: the artist's answer to what is sewn to what, not a guess from + names or rest distance. 215 links on Taila. +2. **A lid across the waist.** "Put one big sphere collider on your waist… it acts + as a lid that prevents particles in the skirt from slipping into the body." + There was no torso collider at all — leg capsules stop cloth going through a + thigh, but nothing stopped a panel swinging INWARD into the pelvis, which is + where several of the worst contacts sat. Marked `lid` in the sidecar so the + drape does not treat it as a limb to be carried by, and sized from non-cloth + geometry (including the garment measured the skirt itself, 0.24 m). +3. **A graded bend clamp.** "The start point can be bent up to 20 degrees and the + tip up to 50." A flat limit either lets the waist collapse or stops the hem + moving; `MAX_SWING_ROOT`/`MAX_SWING_TIP` interpolate along each chain. + Collision still overrides it, which is Magica's precedence too. +4. **Collision is not enough on its own** — "if you make a vigorous movement, you + will inevitably penetrate" — which is why the drape and the bodily shift exist + alongside it rather than instead of it. Magica's two extra modes are Surface + Penetration (needs the cloth weighted to the main bones) and Collider + Penetration (for cloth that does not follow the leg animation). This skirt is + deliberately the second kind, since any leg weighting on it tears. + +### The skirt "breaking" instead of stretching + +A separate failure from clipping, and it looks like clipping: the front of the +skirt is pushed aside and then splits, and the thigh shows through the split. +Measure it with `debug/cloth_stretch_check.gd`, which skins every cloth triangle +and compares each edge against its own rest length — a bone-level or capsule- +level number cannot see it, because every individual bone is behaving. + +It was the WEIGHTS. A band of skirt vertices carried `DEF-thigh.L` at 0.24 while +the vertices next to them carried none, so when the thigh swung one followed it +and its neighbour did not. Measured during a slide: 80 mm apart, 3.3x rest +length. 0.24 of the thigh's ~0.35 m of travel is 84 mm, so that discontinuity +was the whole of it. + +**Cloth is not skinned to the legs at all** — `tools/retarget.py:: +unbind_cloth_from_legs`. Smoothing the field instead of deleting it does not +work, and the arithmetic says why: differential motion is the weight GRADIENT +times how far the limb travels, so holding an edge under 10 mm against a thigh +that moves 350 mm needs under 0.03 of weight difference across it — and this +skirt's edges are ~48 mm long, so the falloff would have to span most of the +character. Tried: a 1.7-radius falloff put 0.24 on one vertex and 0.00 on its +neighbour and tore by 95 mm. After stripping, the worst cloth edge in the whole +sweep grows **0.0 mm**. + +There was also a `bind_cloth_to_legs()` that did this deliberately, on the +(wrong) conclusion that clipping was a weights problem. It is gone for the same +reason, plus two of its own: 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; and it +poisoned the collider measurement, fitting a 0.28 m thigh out of 2258 skirt +vertices. + +Keeping the leg out of the weights costs the one thing the weights were doing, +so the solver has to make it up: + +- **The capsule starts 30% down the thigh.** The top of a thigh is hip, not limb. + Cloth points there sit 20-30 mm from their own bone's head, and a rotation + moves a point near its pivot by at most twice the lever — 48 mm against 86 mm + of overlap. The solver spent all six passes saturated at its cap and still left + 60-90 mm. +- **The drape picks its limb per frame, by current proximity.** Splitting a panel + between both thighs by REST distance is right for a panel hanging between them + and wrong the moment they scissor: the two opposite swings cancel, the panel + does not move, and the advancing thigh walks into it. +- **Chains are shifted bodily as well as turned** (`PUSH_MAX`). Translation is + the degree of freedom rotation lacks, and it is safe here precisely because the + whole chain moves as one piece — unlike a weight gradient it cannot stretch the + mesh, and neighbouring panels see the same contact and shift the same way, so + the seams between them stay shut. +- **Neighbouring cloth bones are known to the runtime.** `neighbours` in the + sidecar means SHARED VERTICES — the artist's own answer to which pieces are + sewn together — and the drape is relaxed across them so sewn panels cannot be + handed wildly different shares. + +On the shipped materials the skirt now reads as one continuous sheet with an +unbroken hem through the whole sweep, and the thighs emerge from below it. + +Two measurement traps found while confirming that, both of which produced +"remaining clipping" that was not there: + +- **A silhouette test cannot see clipping.** When the legs are apart you see the + FAR side of the skirt through the gap between them, and the thighs are + legitimately in front of that. `tools/measure_clipview.py` counts those as + hits. It is a good continuity check — a tear is a hole in the cloth mask — and + a bad clipping check. Judge clipping from `debug/skirt_probe.gd` and the plain + render. +- **The torso is in front of the waistband, and that is correct.** Counting all + body pixels reported 5% of the cloth covered on a pose that is clean, so the + measurement is cut at the hip joint. Colouring the legs separately does not + work either: the bare thigh is part of the body mesh, and a per-surface colour + caught only the boots. + +Together those took idle penetration from 65 mm to ~1 mm and contacts from ~10 +per frame to ~2. Damping then matters: 0.3 rang for about three visible +oscillations after every step, so it sits near 0.6. The lag that makes a skirt +read as cloth comes from inertia, not from low damping, so this costs nothing in +the run cycle. Gravity is small for the same reason — a constant force offsets +the resting tip by `g/w²`, and the 5.0 first used pulled the hem 41 mm below +where it was modelled, into the thigh it then had to be pushed out of. + +## Locomotion transitions + +Two separate things change when a character starts running, and they have to +arrive together: + +- **The clip**, crossfaded by `AnimationNodeTransition` over `BLEND_TIMES` + (~0.4 s for locomotion). Tier selection has hysteresis and a minimum dwell: + without them, hard acceleration crossed Idle → Walk → Run in under a second + and each crossfade cut off the one before it, giving Walk 0.19 s of a 0.40 s + blend. With a dwell, a hard start now goes Idle → Run in one step. +- **The procedural lean** (`ShooterPoseModifier`), which is NOT part of the + blend graph. The controller passes a normalised input direction, so it stepped + 0 → 1 the instant a key went down and planted a full run posture in about a + tenth of a second — the body snapping forward ahead of the run cycle. It is + now scaled by actual speed and smoothed on its own slower rate + (`LEAN_SMOOTH`), so it grows as the character accelerates. + +Measure with `debug/transition_check.gd`: it reports clip changes, when the lean +reaches 10% and 90%, and the worst single-frame change. + ### `.rig.json` Written next to every built GLB, so the runtime never re-guesses anatomy: @@ -188,6 +418,41 @@ Missing clips are fine: the game falls back along sensible chains nearly caused bad "fixes". - `godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- ` — renders every movement state front and side. +- `godot --headless --path . -s res://debug/cloth_settle_check.gd -- ` — does + the cloth actually come to rest? Reports deviation, per-frame motion and, most + usefully, which bones are penetrating a leg capsule and by how much. A steady + non-zero contact count is a limit cycle that no amount of damping will fix. +- `godot --headless --path . -s res://debug/transition_check.gd -- ` — clip + changes and lean ramp when accelerating from a standstill. +- `godot --path . --windowed --resolution 900x900 -s res://debug/skirt_clip_view.gd -- ` + — **the tool that settles "is the leg through the skirt?"**. Paints every cloth + surface flat magenta and the body flat grey across a movement sweep, so grey + inside the magenta is the leg in front of the cloth and grey outside it is just + the leg past the hem. Those two look identical on the shipped materials and + were guessed at, in both directions, for several rounds. It also saves a + cloth-only frame, which separates clipping from a gap opening between panels. +- `godot --headless --path . -s res://debug/skirt_probe.gd` — per cloth bone, how + deep the leg is inside it and whether the solver can SEE that depth (the + rest-clearance allowance can hide it). Also reports the lever the solver has on + the point, and its height above the hip joint — anything positive is inside the + fictional sphere the capsule puts at the top of the thigh, not inside the leg. +- `godot --headless --path . -s res://debug/cloth_stretch_check.gd` — **the tool + for "the skirt breaks instead of stretching"**. Skins every cloth triangle over + a movement sweep and compares each edge against its own rest length, reporting + seams between panels separately from edges inside one. Nothing at bone or + capsule level can see a tear, because each bone individually is fine. Restrict + it to genuinely cloth-owned vertices — body surfaces carry stray cloth + influence (one arm vertex measured 0.54 forearm, 0.35 skirt) and counting those + made the skirt look like it was tearing by half a metre when the arm moved. +- `godot --headless --path . -s res://debug/leg_radius_check.gd` — the real + per-band radius of each limb next to the capsule actually shipped. +- `godot --path . --windowed --resolution 900x900 -s res://debug/idle_jitter_check.gd -- ` + — consecutive frames of a still idle. Counting changed PIXELS between them is + the only trustworthy settling measure: `cloth_settle_check` reports LOCAL bone + rotation, and a parent's correction shows up as an equal and opposite delta on + each of its segments, so a hem that has not moved on screen can read 18 + deg/frame. Measured here: 24866 changed px/frame with collision on against + 38594 with it off — the collision was damping the idle, not driving it. Current Taila, worst over a run/walk/jump/fall/slide/dash sweep: knee cross-section 0.85–0.86, everything else 0.89–1.00, worst stretch 1.16. @@ -222,6 +487,12 @@ cross-section 0.85–0.86, everything else 0.89–1.00, worst stretch 1.16. clips are keying cloth bones (`verify_character.py` checks this). - *Cloth flies off the model* — a spring instability. `SpringBones` substeps and clamps for exactly this; do not remove those guards. +- *Cloth jitters and never settles* — almost certainly a capsule the rest pose is + already inside, not the damping. Run `cloth_settle_check.gd` and look at the + contact count before touching `TUNING`. +- *A limb clips through cloth* — check the measured capsule in the sidecar is a + believable size for that limb, and remember the collider only knows about the + legs. - *Limbs squash at a stride* — measure with `limb_deform_check.gd` before changing anything. Renders are repeatedly misleading; a slim anime leg at full stride genuinely looks stretched. diff --git a/scenes/maps/level_materials.gd b/scenes/maps/level_materials.gd index 0651a23..1b45256 100644 --- a/scenes/maps/level_materials.gd +++ b/scenes/maps/level_materials.gd @@ -126,6 +126,32 @@ const CHARACTER_INK := Color(0.07, 0.06, 0.09) ## almost-invisible second step so the painted shading carries the form. ## ## Props and level geometry keep the crisp banding they were calibrated with. +## Is this untextured surface part of the model's own DRAWING, or is it just an +## untextured surface? +## +## "No albedo texture" alone is not the question, and answering it that way made +## every flat-coloured model render as a black silhouette — Quaternius' mannequin +## has two untextured materials, a yellow body and lilac joints, and both were +## being hidden as though they were an outline shell. +## +## What actually distinguishes line-work: +## +## DARK an ink shell or a lash card is black or nearly so. A flat-coloured +## character is any colour at all. This is the discriminator that +## does the work. +## INVERTED the classic inverted-hull outline is drawn front-face-culled so +## only its backfaces show. Nothing else on a character is. +## NAMED eye cards say so — they are kept, not hidden, and need to reach +## the branch below whatever colour they are. +static func _is_line_work(src: BaseMaterial3D) -> bool: + if src.resource_name.to_lower().begins_with("eyes"): + return true + if src.cull_mode == BaseMaterial3D.CULL_FRONT: + return true + var c: Color = src.albedo_color + return maxf(maxf(c.r, c.g), c.b) < 0.18 + + static func apply_character_look(root: Node) -> void: for mi in root.find_children("*", "MeshInstance3D", true, false): if not mi.mesh: @@ -134,8 +160,8 @@ static func apply_character_look(root: Node) -> void: var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D if src == null: continue - if src.albedo_texture == null: - # Untextured surface on a character = the model's own line-work. + if src.albedo_texture == null and _is_line_work(src): + # Untextured AND dark or inside-out — the model's own line-work. var name := src.resource_name.to_lower() var flat := StandardMaterial3D.new() flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED diff --git a/tools/measure_clipview.py b/tools/measure_clipview.py new file mode 100644 index 0000000..3421f0d --- /dev/null +++ b/tools/measure_clipview.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""How much leg is rendering IN FRONT OF the skirt, in pixels. + + python tools/measure_clipview.py + +That tool saves each pose twice, both drawing DISTANCE FROM THE CAMERA into the +colour channel: `cloth_N.png` with only the cloth drawn and `clipview_N.png` with +only the body. A pixel counts when the body is nearer than the nearest cloth +there — which is exactly what "the thigh is showing through the skirt" means. + +Depth, not silhouettes. With the legs apart you see the FAR side of the skirt +through the gap between them and the thigh is correctly in front of that; a mask +test counts all of it, reported 25% of the cloth covered on poses that are fine, +and sent two rounds of tuning after a defect that was not there. + +`hip_N.txt` gives the screen row of the hip joint; only rows below it count, +because above it the torso is inside the skirt and in front of its waistband. Magenta is cloth, grey is body. So the cloth +silhouette comes from the second image, and any grey inside it in the first is +the leg in front of the skirt — the defect — while grey outside it is just the +leg past the hem, which is correct. + +Reading that off the two pictures by eye is unreliable: the two failures look +alike, the silhouettes interleave, and it was misjudged in both directions more +than once. This counts it. + +WHAT THIS IS AND IS NOT FOR. It measures the garment's CONTINUITY well — a torn +panel shows up as a hole in the cloth mask. It is NOT a clipping test. When the +legs are apart you see the far side of the skirt through the gap between them, +and the thighs are legitimately in front of that, so those pixels count as hits +while being correct rendering. Every large reading here traced back to that. +Judge clipping from debug/skirt_probe.gd and the plain textured render. + +It is also not repeatable enough to A/B tuning changes. The solver can be given a +fixed timestep, but the AnimationTree still advances on the real frame delta, so +the pose at a given frame drifts between runs: the same build measured 93k and +76k. Differences under about 20% here mean nothing. + +The cloth mask is eroded a little first. The two frames are one apart (the tool +swaps materials between them), so the character has moved a fraction of a +millimetre and the silhouette edges do not line up exactly; without the erosion +every outline pixel reads as a hit. +""" +import os +import sys + +from PIL import Image, ImageChops, ImageFilter + +## Depth difference, in 8-bit steps, that counts as the body being in front. +## One step is about 6 mm over the 1.6 m the shader encodes; three keeps +## coincident surfaces and the one-frame offset between the two shots quiet. +NEAR_EPS = 3 +MIN_BLOB = 40 # ignore specks — anti-aliasing along an edge, not a defect + + +def depth(path): + """Per-pixel camera distance as an 8-bit band; 0 means no geometry.""" + return Image.open(path).convert("RGB").split()[0] + + +def main(): + root = sys.argv[1] if len(sys.argv) > 1 else "." + total = 0 + shots = 0 + for n in range(256): + full = os.path.join(root, "clipview_%d.png" % n) + only = os.path.join(root, "cloth_%d.png" % n) + if not (os.path.exists(full) and os.path.exists(only)): + continue + # `clipview` draws EVERYTHING, so it holds the nearest of cloth-or-body; + # `cloth` draws only the garment. Where the combined pass is nearer than + # the cloth pass, something that is not cloth is in front of it. + both_pass = depth(full) + cloth = depth(only) + w, h = cloth.size + top = 0 + hipf = os.path.join(root, "hip_%d.txt" % n) + if os.path.exists(hipf): + with open(hipf) as fh: + top = max(0, int(fh.read().strip())) + box = (0, top, w, h) + b = both_pass.crop(box) + c = cloth.crop(box) + has_c = c.point(lambda p: 255 if p > 0 else 0) + # Nearer than the nearest cloth, by more than a little depth noise. + nearer = ImageChops.subtract(c, b).point(lambda p: 255 if p > NEAR_EPS else 0) + hit_mask = ImageChops.multiply(has_c, nearer) + hit = sum(hit_mask.point(lambda p: 1 if p else 0).get_flattened_data()) + area = sum(has_c.point(lambda p: 1 if p else 0).get_flattened_data()) + if hit >= MIN_BLOB: + over = Image.merge("RGB", (c, c, c)).convert("RGB") + over.paste(Image.new("RGB", over.size, (255, 32, 32)), (0, top), hit_mask) + over.save(os.path.join(root, "over_%d.png" % n)) + total += hit + shots += 1 + flag = "" if hit < MIN_BLOB else " <-- leg in front of cloth" + print(" shot %d: %6d px of leg over %7d px of cloth (%.2f%%)%s" + % (n, hit, area, 100.0 * hit / max(area, 1), flag)) + if shots: + print(" TOTAL %d px over %d shots" % (total, shots)) + + +main() diff --git a/tools/retarget.py b/tools/retarget.py index e1f2ef6..b5795c9 100644 --- a/tools/retarget.py +++ b/tools/retarget.py @@ -44,6 +44,7 @@ Writes .rig.json beside the GLB: resolved bone roles, cloth chains and twist pairs, so the runtime never has to re-guess the skeleton's anatomy. """ import bpy +import math import json import os import sys @@ -581,12 +582,17 @@ def add_nla_clip(arm, action, name): # -------------------------------------------------------------------- sidecar -def _dominant_vertices(meshes, arm): +def _dominant_vertices(meshes, arm, min_weight=0.25): """bone name -> world positions of the vertices it mostly owns. "Mostly" as in holds the largest share — a vertex belongs to one bone for the purpose of measuring what that bone covers, even though it is skinned to several. + + `min_weight` is how strongly a vertex must belong to its bone to count. + The collider pass raises it: a vertex split 0.3/0.3/0.4 across hip, thigh + and glute is a BLEND, and letting those in put the hip flare back into the + thigh's band samples — the fitted capsule came out 0.18 m at the head. """ out = defaultdict(list) for m in meshes: @@ -597,7 +603,7 @@ def _dominant_vertices(meshes, arm): for g in v.groups: if best is None or g.weight > best.weight: best = g - if best is not None and best.weight > 0.25: + if best is not None and best.weight > min_weight: out[gname.get(best.group, "")].append(mw @ v.co) return out @@ -624,14 +630,131 @@ def _bone_tip(arm, bone, chain, index, owned, fallback): return fallback -def _leg_colliders(arm, roles, owned): - """Capsules for the legs, sized from the body geometry itself. +def _hull_samples(arm, bone, owned, limit=10): + """A few points spread across the geometry a cloth bone actually drives, + in that bone's own rest space. - The skirt has to be kept off the thighs, and a guessed radius either lets - it clip through or holds it out in a bell. The 70th percentile of how far a - leg bone's own vertices sit from its axis measures the actual limb. + The runtime collides THESE, not points along the bone. A skirt panel is a + wide sheet hanging off a single stick from the waist, so keeping the stick + out of the thigh is nearly meaningless: measured over a movement sweep, the + bones were clear by ~1 mm while the leg was 85 mm inside the skirt MESH with + 190 vertices swallowed. + + Chosen by farthest-point sampling so the handful of points spans the panel + (edges, hem, middle) instead of clustering wherever the mesh is dense. + """ + pts = owned.get(bone, []) + if len(pts) < 4: + return [] + inv = (arm.matrix_world @ arm.data.bones[bone].matrix_local).inverted() + local = [inv @ p for p in pts] + picked = [max(local, key=lambda v: v.length)] + while len(picked) < min(limit, len(local)): + far = max(local, key=lambda v: min((v - q).length for q in picked)) + if min((far - q).length for q in picked) < 1e-4: + break + picked.append(far) + return [[round(v.x, 5), round(v.y, 5), round(v.z, 5)] for v in picked] + + +# How far off the skin a garment sits — its own thickness, plus the fact that +# cloth drapes over a limb rather than being painted onto it. +CLOTH_CLEARANCE = 0.008 + + +def _pct(sorted_values, p): + """Value at percentile `p` of an already-sorted list.""" + i = int(round(p * (len(sorted_values) - 1))) + return sorted_values[max(0, min(len(sorted_values) - 1, i))] + + +def _body_points(meshes): + """Every skinned vertex that is NOT cloth, in world space. + + The waist lid is sized from these. Including the garment measured the skirt + itself — a 0.24 m radius that would have held it out in a bell. """ out = [] + for m in meshes: + gname = {g.index: g.name for g in m.vertex_groups} + mw = m.matrix_world + for v in m.data.vertices: + cloth_w = sum(g.weight for g in v.groups + if any(t in SPRING_CLASSES + for t in tokens(gname.get(g.group, "")))) + if cloth_w < 0.35: + out.append(mw @ v.co) + return out + + +def _leg_colliders(arm, roles, owned, body_pts=None): + """TAPERED capsules for the legs, sized from the body geometry itself. + + The skirt has to be kept off the thighs, and a guessed radius either lets it + clip through or holds it out in a bell. Three things the obvious version got + wrong, all measured on Taila: + + * A limb is not a cylinder. Her thigh is ~0.10 m across at the hip and + ~0.055 m just above the knee, so one radius is either too fat at the knee + or too thin at the hip. Head and tail radii are stored separately and + interpolated at runtime. + * A leg's own vertices are not the leg. Most of the thigh belongs to the + TWIST bone (`DEF-thigh.L.001`); what is left dominated by `DEF-thigh.L` + is mostly the hip flare, which measured a 0.154 m radius — a 30 cm thigh. + Twist children are folded in. + * Neither a low percentile nor a high one works on a POOLED bucket. The + 70th tracked that flare; the median then left half the limb's surface + outside its own collider, so cloth pushed out to it was clear of the + capsule while the thigh was visibly through it in the render; and the + 88th over-measured the shaft by 30% because the top bucket still holds + the hip. Measured per band along the bone instead and fitted as the line + it actually is, dropping the contaminated end bands. + * Garments have thickness and hang OFF a leg rather than painted onto it, + so a small clearance is added on top. Without it the cloth's rest state + is exactly tangent to the limb and every frame is a contact. + """ + names = set(b.name for b in arm.data.bones) + out = [] + # A LID across the waist first. + # + # Magica Cloth 2's skirt guide is blunt about this: put "one big sphere + # collider on your waist", because it "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 being swung INWARD + # between the legs and ending up inside the pelvis, which is where several + # of the worst contacts here were sitting. + trunk = [roles.hips] + [n for n in roles.spine if n != roles.hips] + if len(trunk) >= 2 and trunk[0] in names and trunk[1] in names: + a = (arm.matrix_world @ arm.data.bones[trunk[0]].matrix_local).translation + b = (arm.matrix_world @ arm.data.bones[trunk[1]].matrix_local).translation + # Sized from the geometry that actually surrounds the pelvis, not from + # the hip bone's own vertices: on a Rigify rig the hips own almost + # nothing (2 vertices here, and spine.001 none) because the torso + # belongs to spine.002, so there is nothing there to measure. + ab = b - a + d2 = ab.dot(ab) + pts = [] + if d2 > 1e-9: + for p in (body_pts or []): + t = (p - a).dot(ab) / d2 + if 0.0 <= t <= 1.0: + pts.append(p) + if len(pts) >= 12: + rr = sorted(_seg_distance(p, a, b) for p in pts) + # 60th percentile, not the 90th used for limbs: the lid only has to + # stop cloth being swung INTO the body. Sized to the widest thing + # near the hips it would hold the whole skirt out in a bell. + r = _pct(rr, 0.60) + CLOTH_CLEARANCE + out.append({ + "bone": trunk[0], "child": trunk[1], + "from": 0.0, + "radius_head": round(r, 4), + "radius_tail": round(r, 4), + "radius": round(r, 4), + # A lid is something to stay OUT of, not a limb to be carried by. + "lid": True, + }) + for role, child_role in (("thigh", "shin"), ("shin", "foot")): for side in ("L", "R"): name = roles.limb.get((role, side)) @@ -640,17 +763,110 @@ def _leg_colliders(arm, roles, owned): continue a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation - pts = owned.get(name, []) - if len(pts) < 8: + ab = b - a + d2 = ab.dot(ab) + if d2 < 1e-9: continue - radii = sorted(_seg_distance(p, a, b) for p in pts) + # This bone plus any twist segment hanging off it — together they + # are the limb. + pts = list(owned.get(name, [])) + for other in names: + if other != name and other.startswith(name + ".") \ + and is_segment_of(other, names): + pts.extend(owned.get(other, [])) + if len(pts) < 12: + continue + + # A limb is a TAPER, so measure it as one. Ten bands along the + # bone, the 90th percentile radius in each, and a least-squares + # line through them. Two pooled buckets could not do this: the top + # bucket is contaminated by the hip flare and the bottom one by the + # knee and boot, so whatever percentile was chosen came out wrong + # at one end or the other — a median under-measured the limb by + # half its surface, and a high percentile over-measured it by 30% + # along the whole shaft. The two end bands are dropped for exactly + # that reason; the fit extrapolates back through them. + bands = [[] for _ in range(10)] + for p in pts: + t = max(0.0, min(1.0, (p - a).dot(ab) / d2)) + bands[min(int(t * 10.0), 9)].append(_seg_distance(p, a, b)) + samples = [] + for k in range(1, 9): + if len(bands[k]) < 3: + continue + bands[k].sort() + samples.append(((k + 0.5) / 10.0, _pct(bands[k], 0.90))) + if len(samples) < 3: + continue + n = len(samples) + mt = sum(t for t, _ in samples) / n + mr = sum(r for _, r in samples) / n + den = sum((t - mt) ** 2 for t, _ in samples) + slope = sum((t - mt) * (r - mr) for t, r in samples) / den if den > 1e-9 else 0.0 + head = mr + slope * (0.0 - mt) + CLOTH_CLEARANCE + tail = mr + slope * (1.0 - mt) + CLOTH_CLEARANCE + # A limb never widens toward the joint below it, and a fit through + # noisy bands occasionally says otherwise. + tail = max(0.01, min(tail, head)) + head = max(head, tail) + # The capsule starts BELOW the hip joint. + # + # The top of a thigh is not a free limb, it is the hip, and it is + # buried inside the body the skirt hangs from. Colliding against it + # asks the solver for something it cannot do: those cloth points sit + # 20-30 mm from their own bone's head, and rotating a bone moves a + # point near its pivot by almost nothing — measured, 24 mm of lever + # against 86 mm of overlap, where the most any rotation can achieve + # is twice the lever. The solver spent all six passes saturated at + # its per-pass cap and still left 60-90 mm. + # + # Only the sphere cap buried in the pelvis is cut. The upper thigh + # itself stays covered, because the runtime can also SHIFT a chain + # bodily (SpringBones.PUSH_MAX) and a shift does not care how much + # lever the bone has: rotation handles the contacts with leverage, + # translation handles the ones without. Trimming 30% instead of 10% + # stopped the solver even trying across the top of the thigh, and + # that band is exactly what then showed through the skirt. + head_t = 0.10 out.append({ "bone": name, "child": child, - "radius": round(radii[int(len(radii) * 0.7)], 4), + "from": head_t, + "radius_head": round(head + (tail - head) * head_t, 4), + "radius_tail": round(tail, 4), + # Kept so an older runtime still gets a usable single radius. + "radius": round(tail, 4), }) return out +def _cloth_neighbours(meshes, cloth_names): + """bone -> {neighbouring bone: how strongly they share the same mesh}. + + Two cloth bones are neighbours when the SAME VERTICES are weighted to both. + That is the only definition that matters here: a vertex driven half by one + skirt panel and half by the next is the piece of mesh that has to absorb any + difference between them, and linear-blend skinning absorbs it by pulling + itself apart. Measured with debug/cloth_stretch_check.gd, adjacent panels + taking drape shares of 0.85 and 0.48 stretched the edge between them to 3.3x + its rest length — an 80 mm hole in the front of the skirt, which is the + skirt "breaking" around the thigh rather than deforming over it. + + Adjacency by NAME or by rest distance would both be guesses; the artist + already answered the question in the weights. + """ + shared = defaultdict(lambda: defaultdict(float)) + for m in meshes: + gname = {g.index: g.name for g in m.vertex_groups} + for v in m.data.vertices: + here = [(gname.get(g.group, ""), g.weight) for g in v.groups + if gname.get(g.group, "") in cloth_names and g.weight > 0.05] + for a_name, aw in here: + for b_name, bw in here: + if a_name != b_name: + shared[a_name][b_name] += aw * bw + return {a: dict(d) for a, d in shared.items()} + + def describe_rig(arm, roles, mapping, meshes): """Record what we worked out, so the runtime never re-guesses anatomy.""" names = set(b.name for b in arm.data.bones) @@ -665,6 +881,8 @@ def describe_rig(arm, roles, mapping, meshes): springy = {b.name: b for b in arm.data.bones if b.name not in driven and any(t in SPRING_CLASSES for t in tokens(b.name))} + neighbours = _cloth_neighbours(meshes, set(springy)) + for name, bone in springy.items(): if bone.parent is not None and bone.parent.name in springy: continue # not the root of a chain @@ -676,10 +894,12 @@ def describe_rig(arm, roles, mapping, meshes): if c.name in springy] if not kids: tips = [] + hulls = [] fallback = Vector((0.0, 0.0, -0.06)) for i in range(len(path)): t = _bone_tip(arm, path[i], path, i, owned, fallback) tips.append([round(t.x, 5), round(t.y, 5), round(t.z, 5)]) + hulls.append(_hull_samples(arm, path[i], owned)) fallback = t chains.append({ "class": next((t for t in tokens(path[0]) @@ -687,6 +907,11 @@ def describe_rig(arm, roles, mapping, meshes): "root_parent": bone.parent.name if bone.parent else None, "bones": path, "tips": tips, + "hulls": hulls, + # Which other cloth bones share mesh with each of these, + # so the runtime can stop neighbours drifting apart. See + # _cloth_neighbours. + "neighbours": [neighbours.get(n, {}) for n in path], }) continue for k in kids: @@ -703,7 +928,9 @@ def describe_rig(arm, roles, mapping, meshes): twist.append({"bone": b.name, "parent": b.parent.name, "child": b.children[0].name if b.children else None}) return {"roles": roles_out, "chains": chains, "twist": twist, - "colliders": _leg_colliders(arm, roles, owned), + "colliders": _leg_colliders( + arm, roles, _dominant_vertices(meshes, arm, min_weight=0.6), + _body_points(meshes)), "weights_authored": _weights_look_authored(meshes, roles), "driven_bones": sorted(driven)} @@ -801,6 +1028,8 @@ def main(): print("Resolved so far:\n" + roles.describe()) sys.exit(1) rebuild_hierarchy(arm, roles) + subdivide_cloth_panels(arm, meshes, roles) + unbind_cloth_from_legs(arm, meshes, roles) flatten_and_scale(arm, meshes, TARGET_HEIGHT) fix_unlit_materials(meshes) roles = RigRoles(arm) # rest positions moved; re-read @@ -884,4 +1113,226 @@ def main(): print("Done.") + + +def subdivide_cloth_panels(arm, meshes, roles, segments=4): + """Split single-bone cloth panels into a CHAIN so they can bend. + + A skirt panel modelled as one rigid bone off the waist cannot ride up over a + thigh, because riding up is a FOLD: the top of the panel stays at the waist + while the lower part lifts. One bone can only rotate the whole sheet about + the waist, so the hem swings forward while the middle stays put and the + thigh comes straight through it. No amount of collision or drape strength + fixes that — the degree of freedom does not exist. + + Taila's skirt is 21 such bones (see rebuild_hierarchy: the panels export + flat, and their real 3-segment chains are not recoverable from the file). + So the segments are BUILT here: each panel bone gains `segments - 1` + children strung along the direction it actually covers, and the vertices it + drives are handed to whichever segment spans them. The runtime spring + solver already treats chains properly, so the lower segment can lift over + the leg while the top stays anchored. + + SIX segments, not three. Magica Cloth 2's skirt guide: "if the bone position + deviates from the shape of the skirt, or if the bones are spaced too far + apart, the accuracy of collision detection will be significantly reduced." + That is measurable here — a collision is resolved by ROTATING a bone, which + moves a point at most twice its distance from that bone's head, and with + three segments the front panels had contacts sitting 10-48 mm from their own + pivot against a thigh 100 mm inside them. No amount of tuning can clear that; + the pivot has to be closer to the contact. + + Runs before flatten_and_scale, in the source's own units. + """ + owned = _dominant_vertices(meshes, arm) + panels = [] + for bone in arm.data.bones: + if not is_cosmetic(bone.name): + continue + if not any(t in SPRING_CLASSES and t != "hair" for t in tokens(bone.name)): + continue + if bone.children: + continue # already a chain — the artist's own topology wins + pts = owned.get(bone.name, []) + if len(pts) < 12: + continue + rest = arm.matrix_world @ bone.matrix_local + tip = _bone_tip(arm, bone.name, [bone.name], 0, owned, + Vector((0.0, 0.0, -0.06))) + if tip.length < 1e-4: + continue + panels.append((bone.name, rest, tip)) + if not panels: + return 0 + + bpy.context.view_layer.objects.active = arm + bpy.ops.object.mode_set(mode="EDIT") + eb = arm.data.edit_bones + made = {} + for name, rest, tip_local in panels: + parent = eb[name] + head = parent.head.copy() + step = (rest.to_3x3() @ tip_local) / segments + chain = [name] + prev = parent + for s in range(1, segments): + seg = eb.new("%s.seg%d" % (name, s)) + seg.head = head + step * s + seg.tail = head + step * (s + 1) + seg.parent = prev + seg.use_connect = False + prev = seg + chain.append(seg.name) + parent.tail = head + step + made[name] = chain + bpy.ops.object.mode_set(mode="OBJECT") + + # Spread each vertex across the TWO nearest segments, so the panel bends + # where the geometry is and the mesh stays continuous while it does. + # + # Handing every vertex to exactly one segment puts a hard cut through the + # sheet: the vertices either side of a segment boundary are then driven + # 100% by different bones, and the moment those bones rotate apart the mesh + # opens along that line. Measured with debug/cloth_stretch_check.gd over a + # movement sweep, an edge inside DEF-skirt.L.003 grew 270 mm — 11.7x its own + # rest length. That is the skirt visibly BREAKING around a thigh instead of + # stretching over it, and it looks nothing like a collision bug. + # + # Weighting to the two nearest segment CENTRES instead makes the transition + # linear, which is the same trick that stops a limb tearing at an elbow. + for m in meshes: + for name, chain in made.items(): + vg = m.vertex_groups.get(name) + if vg is None: + continue + groups = [vg] + [m.vertex_groups.new(name=n) for n in chain[1:]] + rest = arm.matrix_world @ arm.data.bones[name].matrix_local + origin = rest.translation + axis = (rest.to_3x3() @ _bone_tip( + arm, name, [name], 0, owned, Vector((0.0, 0.0, -0.06)))) + span = axis.length + if span < 1e-5: + continue + axis = axis / span + mw = m.matrix_world + for v in m.data.vertices: + w = 0.0 + for g in v.groups: + if g.group == vg.index: + w = g.weight + if w <= 0.0: + continue + t = max(0.0, min(1.0, (mw @ v.co - origin).dot(axis) / span)) + # Position measured in segment-CENTRE space: the ends of the + # panel sit on one segment outright, everything between is a + # blend of the two it lies between. + u = t * segments - 0.5 + lo = int(math.floor(u)) + f = u - lo + if lo < 0: + lo, f = 0, 0.0 + elif lo >= segments - 1: + lo, f = segments - 1, 0.0 + groups[0].remove([v.index]) + if w * (1.0 - f) > 1e-5: + groups[lo].add([v.index], w * (1.0 - f), "REPLACE") + if f > 1e-5: + groups[lo + 1].add([v.index], w * f, "REPLACE") + print(f"Subdivided {len(made)} cloth panels into {segments} segments each") + return len(made) + + +def unbind_cloth_from_legs(arm, meshes, roles): + """Take the LIMBS out of any vertex the cloth chains own. + + A source model auto-weighted in Rigify leaves a band of skirt vertices + partly weighted to the thigh, and the vertices immediately next to them not + at all. Nothing about that is smooth: when the thigh swings, one vertex + follows it and its neighbour does not, and the mesh splits along the join. + Measured with debug/cloth_stretch_check.gd during a slide, two adjacent + front-panel vertices — identical weights except that one carried + DEF-thigh.L at 0.24 — pulled 80 mm apart, 3.3x their rest length. 0.24 of + the thigh's ~0.35 m of travel is 84 mm, so that is the whole of it. On + screen it is the skirt "breaking" around the thigh instead of stretching + over it. + + Smoothing the field instead of deleting it does NOT work, and the arithmetic + says why: differential motion is the weight GRADIENT times how far the limb + travels. Holding an edge to under 10 mm against a thigh that moves 350 mm + needs less than 0.03 of weight difference across it, and this skirt's edges + are ~48 mm long, so the falloff would have to span most of the character. + Tried it — a 1.7-radius falloff put 0.24 on one vertex and 0.00 on its + neighbour and tore by 95 mm. Any leg weighting on this cloth tears. + + Which is the project's own rule anyway: cloth moves by spring bones, never + by weights. Keeping the leg out of the weights is also what leaves the + spring solver able to move these vertices at all — see the note below on + bind_cloth_to_legs, which was this same mistake made deliberately. + """ + limbs = set() + for (role, side), name in roles.limb.items(): + if role not in ("thigh", "shin", "foot"): + continue + limbs.add(name) + # ...and the twist segments hanging off them, which are the same limb. + for b in arm.data.bones: + if b.name != name and b.name.startswith(name + "."): + limbs.add(b.name) + cloth = set(b.name for b in arm.data.bones + if any(t in SPRING_CLASSES for t in tokens(b.name))) + if not limbs or not cloth: + return 0 + + freed = 0 + for m in meshes: + gname = {g.index: g.name for g in m.vertex_groups} + for v in m.data.vertices: + cloth_w = 0.0 + limb_w = 0.0 + for g in v.groups: + n = gname.get(g.group, "") + if n in cloth: + cloth_w += g.weight + elif n in limbs: + limb_w += g.weight + # The cloth has to actually own the vertex. A thigh vertex with a + # little skirt on it is a LEG and must keep following the leg. + if limb_w <= 0.0 or cloth_w < 0.5 or cloth_w <= limb_w: + continue + scale = (cloth_w + limb_w) / cloth_w + for g in list(v.groups): + n = gname.get(g.group, "") + if n in limbs: + m.vertex_groups[g.group].remove([v.index]) + elif n in cloth: + m.vertex_groups[g.group].add( + [v.index], min(1.0, g.weight * scale), "REPLACE") + freed += 1 + print(f"Freed {freed} cloth vertices from the legs they were skinned to") + return freed + + +# Cloth is NOT skinned to the legs. +# +# There was a bind_cloth_to_legs() here that gave every cloth vertex near a +# thigh a share of that thigh, so the skirt would ride the leg the way a real +# one does. It was written because the drape and collision in +# characters/spring_bones.gd appeared to do nothing however hard they were +# driven — and they did nothing because of two bugs since fixed there (the +# drape rotated the spring's target but not the bone, so it cancelled out at +# equilibrium; and the collision's answer was read back off the bone tip, which +# discards the twist that is most of what lifts a sheet off a leg). +# +# With those fixed the binding is actively harmful. 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 the leg — and the leg still overtakes it, because 0.9 of a +# rotation always lags the surface doing 1.0 of it. It also poisoned the +# collider measurement below: 2258 skirt vertices counted as thigh geometry and +# fitted a 0.28 m thigh. +# +# Measured on the run cycle with debug/skirt_clip_view.gd, which paints cloth +# magenta so leg-in-front-of-cloth is unambiguous: removing it made the frames +# it was supposed to fix cleaner, not worse. + + main() diff --git a/tools/verify_character.py b/tools/verify_character.py index 0bb70ce..87ef9e0 100644 --- a/tools/verify_character.py +++ b/tools/verify_character.py @@ -58,8 +58,8 @@ warnings = [] weights_authored = True -def check(ok, label, detail="", needs_authored_weights=False): - soft = needs_authored_weights and not weights_authored +def check(ok, label, detail="", needs_authored_weights=False, advisory=False): + soft = advisory or (needs_authored_weights and not weights_authored) tag = "PASS" if ok else ("WARN" if soft else "FAIL") print(f" [{tag}] {label}" + (f" — {detail}" if detail else "")) if ok: @@ -134,8 +134,20 @@ check(bleed_frac < 0.005, "cross-leg blending is limited to draping cloth", check(four < 0.5, "influences look authored, not solved", f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}", needs_authored_weights=True) -check(len(meshes) > 1, "model keeps its per-part meshes", f"{len(meshes)} meshes", - needs_authored_weights=True) +# ADVISORY, not a gate. Several meshes is what we want — it is how body, cloth +# and hair stay separable for materials, for the outline pass and for the cloth +# solver's hull extraction — but the OUTPUT cannot tell "the pipeline joined +# them" from "the artist authored one mesh". Quaternius' mannequin is a single +# mesh on purpose and was failing a check about damage that had not happened. +# +# The join path leaves two signatures that ARE unambiguous, and both are hard +# checks above: cross-leg weight bleed, and the 4-influences-everywhere spread +# of a nearest-bone rebind. Those catch what this was standing in for. +check(len(meshes) > 1, "model keeps its per-part meshes", + f"{len(meshes)} mesh{'es' if len(meshes) != 1 else ''}" + + (" — fine for a single-piece model; a costume should be several" + if len(meshes) == 1 else ""), + advisory=True) # ------------------------------------------------------------------- skeleton def is_rootish(b):