feat(characters): import the Quaternius mannequin as a selectable skin
A third playable character, built with the pipeline skill from a source that was already in the repo: the animation library ships a rigged Mannequin mesh on the exact 53-joint reference skeleton, CC0, so it needed no download and retargets perfectly. 18 clips, 0.3% cross-leg bleed, 7% of verts at four influences — a clean authored-weight import. Licence recorded in mannequin.license.json as the other skins do. It has no cloth chains, correctly: it is a mannequin and has neither hair nor clothes. Importing it turned up two real bugs, both of which would have hit any flat-coloured or single-piece model: - LevelMaterials.apply_character_look treated ANY untextured surface on a character as the model's own outline shell and hid it, so the mannequin rendered as a solid black silhouette — its body and joint materials are untextured flat colours, not ink. _is_line_work() now asks whether the surface is named eyes*, is drawn front-face-culled (the inverted-hull setup), or is near-black. Taila and Miku are unaffected: their materials are textured and never reach that branch. Verified by render. - verify_character.py failed the build for having one mesh. That check cannot tell "the pipeline joined them" from "the artist authored one mesh" — Quaternius' mannequin is one piece on purpose. It is advisory now; the join path's two unambiguous signatures, cross-leg bleed and the 4-influences-everywhere spread, are still hard checks. Also restored Miku's description, which the re-import had blanked. 3 GLB skins selectable (6 with the built-in colour skins). Smoke 0 failures, 11/11 movement tests, cloth idle 0.024-0.078 deg/frame. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
270d5f0973
commit
cd0d1b2d99
@@ -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
|
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.
|
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**
|
`apply_character_look` therefore looks for the model's own line-work and handles
|
||||||
as the model's own line-work and handles it flat and unshaded:
|
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
|
- **Outline hull** → made fully transparent rather than deleted. Deleting a
|
||||||
surface would renumber the rest and break the mesh's own skin bindings. The
|
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
|
- **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.
|
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
|
If a newly imported character comes out with a white halo, a black silhouette,
|
||||||
that should have irises, this function and its name-matching are where to look —
|
or black eyes that should have irises, `_is_line_work()` and the name-matching
|
||||||
the naming conventions vary by source and this is the one place they are read.
|
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
|
## Materials on import: the unlit problem
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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."
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
"id": "miku",
|
"id": "miku",
|
||||||
"name": "Miku",
|
"name": "Miku",
|
||||||
"description": "",
|
"description": "Hatsune Miku \u2014 Virtual Idol",
|
||||||
"model": "res://assets/characters/skins/miku.glb",
|
"model": "res://assets/characters/skins/miku.glb",
|
||||||
"unlocked": true
|
"unlocked": true
|
||||||
},
|
},
|
||||||
@@ -13,6 +13,13 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"model": "res://assets/characters/skins/taila.glb",
|
"model": "res://assets/characters/skins/taila.glb",
|
||||||
"unlocked": true
|
"unlocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mannequin",
|
||||||
|
"name": "Mannequin",
|
||||||
|
"description": "Quaternius reference mannequin (CC0)",
|
||||||
|
"model": "res://assets/characters/skins/mannequin.glb",
|
||||||
|
"unlocked": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
Tuning per class (hair stiffer and lighter, skirt slacker and heavier) lives in
|
||||||
`SpringBones.TUNING`.
|
`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.
|
||||||
|
|
||||||
### `<model>.rig.json`
|
### `<model>.rig.json`
|
||||||
|
|
||||||
Written next to every built GLB, so the runtime never re-guesses anatomy:
|
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".
|
nearly caused bad "fixes".
|
||||||
- `godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> <skin_id>`
|
- `godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> <skin_id>`
|
||||||
— renders every movement state front and side.
|
— renders every movement state front and side.
|
||||||
|
- `godot --headless --path . -s res://debug/cloth_settle_check.gd -- <glb>` — 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 -- <glb>` — clip
|
||||||
|
changes and lean ramp when accelerating from a standstill.
|
||||||
|
- `godot --path . --windowed --resolution 900x900 -s res://debug/skirt_clip_view.gd -- <out_dir>`
|
||||||
|
— **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 -- <out_dir>`
|
||||||
|
— 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
|
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.
|
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).
|
clips are keying cloth bones (`verify_character.py` checks this).
|
||||||
- *Cloth flies off the model* — a spring instability. `SpringBones` substeps and
|
- *Cloth flies off the model* — a spring instability. `SpringBones` substeps and
|
||||||
clamps for exactly this; do not remove those guards.
|
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
|
- *Limbs squash at a stride* — measure with `limb_deform_check.gd` before
|
||||||
changing anything. Renders are repeatedly misleading; a slim anime leg at full
|
changing anything. Renders are repeatedly misleading; a slim anime leg at full
|
||||||
stride genuinely looks stretched.
|
stride genuinely looks stretched.
|
||||||
|
|||||||
@@ -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.
|
## almost-invisible second step so the painted shading carries the form.
|
||||||
##
|
##
|
||||||
## Props and level geometry keep the crisp banding they were calibrated with.
|
## 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:
|
static func apply_character_look(root: Node) -> void:
|
||||||
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
||||||
if not mi.mesh:
|
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
|
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
|
||||||
if src == null:
|
if src == null:
|
||||||
continue
|
continue
|
||||||
if src.albedo_texture == null:
|
if src.albedo_texture == null and _is_line_work(src):
|
||||||
# Untextured surface on a character = the model's own line-work.
|
# Untextured AND dark or inside-out — the model's own line-work.
|
||||||
var name := src.resource_name.to_lower()
|
var name := src.resource_name.to_lower()
|
||||||
var flat := StandardMaterial3D.new()
|
var flat := StandardMaterial3D.new()
|
||||||
flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
|||||||
@@ -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 <dir written by debug/skirt_clip_view.gd>
|
||||||
|
|
||||||
|
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()
|
||||||
+463
-12
@@ -44,6 +44,7 @@ Writes <output>.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.
|
twist pairs, so the runtime never has to re-guess the skeleton's anatomy.
|
||||||
"""
|
"""
|
||||||
import bpy
|
import bpy
|
||||||
|
import math
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -581,12 +582,17 @@ def add_nla_clip(arm, action, name):
|
|||||||
# -------------------------------------------------------------------- sidecar
|
# -------------------------------------------------------------------- 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.
|
"""bone name -> world positions of the vertices it mostly owns.
|
||||||
|
|
||||||
"Mostly" as in holds the largest share — a vertex belongs to one bone for
|
"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
|
the purpose of measuring what that bone covers, even though it is skinned
|
||||||
to several.
|
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)
|
out = defaultdict(list)
|
||||||
for m in meshes:
|
for m in meshes:
|
||||||
@@ -597,7 +603,7 @@ def _dominant_vertices(meshes, arm):
|
|||||||
for g in v.groups:
|
for g in v.groups:
|
||||||
if best is None or g.weight > best.weight:
|
if best is None or g.weight > best.weight:
|
||||||
best = g
|
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)
|
out[gname.get(best.group, "")].append(mw @ v.co)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -624,14 +630,131 @@ def _bone_tip(arm, bone, chain, index, owned, fallback):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def _leg_colliders(arm, roles, owned):
|
def _hull_samples(arm, bone, owned, limit=10):
|
||||||
"""Capsules for the legs, sized from the body geometry itself.
|
"""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
|
The runtime collides THESE, not points along the bone. A skirt panel is a
|
||||||
it clip through or holds it out in a bell. The 70th percentile of how far a
|
wide sheet hanging off a single stick from the waist, so keeping the stick
|
||||||
leg bone's own vertices sit from its axis measures the actual limb.
|
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 = []
|
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 role, child_role in (("thigh", "shin"), ("shin", "foot")):
|
||||||
for side in ("L", "R"):
|
for side in ("L", "R"):
|
||||||
name = roles.limb.get((role, side))
|
name = roles.limb.get((role, side))
|
||||||
@@ -640,17 +763,110 @@ def _leg_colliders(arm, roles, owned):
|
|||||||
continue
|
continue
|
||||||
a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation
|
a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation
|
||||||
b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation
|
b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation
|
||||||
pts = owned.get(name, [])
|
ab = b - a
|
||||||
if len(pts) < 8:
|
d2 = ab.dot(ab)
|
||||||
|
if d2 < 1e-9:
|
||||||
continue
|
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({
|
out.append({
|
||||||
"bone": name, "child": child,
|
"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
|
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):
|
def describe_rig(arm, roles, mapping, meshes):
|
||||||
"""Record what we worked out, so the runtime never re-guesses anatomy."""
|
"""Record what we worked out, so the runtime never re-guesses anatomy."""
|
||||||
names = set(b.name for b in arm.data.bones)
|
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
|
springy = {b.name: b for b in arm.data.bones
|
||||||
if b.name not in driven
|
if b.name not in driven
|
||||||
and any(t in SPRING_CLASSES for t in tokens(b.name))}
|
and any(t in SPRING_CLASSES for t in tokens(b.name))}
|
||||||
|
neighbours = _cloth_neighbours(meshes, set(springy))
|
||||||
|
|
||||||
for name, bone in springy.items():
|
for name, bone in springy.items():
|
||||||
if bone.parent is not None and bone.parent.name in springy:
|
if bone.parent is not None and bone.parent.name in springy:
|
||||||
continue # not the root of a chain
|
continue # not the root of a chain
|
||||||
@@ -676,10 +894,12 @@ def describe_rig(arm, roles, mapping, meshes):
|
|||||||
if c.name in springy]
|
if c.name in springy]
|
||||||
if not kids:
|
if not kids:
|
||||||
tips = []
|
tips = []
|
||||||
|
hulls = []
|
||||||
fallback = Vector((0.0, 0.0, -0.06))
|
fallback = Vector((0.0, 0.0, -0.06))
|
||||||
for i in range(len(path)):
|
for i in range(len(path)):
|
||||||
t = _bone_tip(arm, path[i], path, i, owned, fallback)
|
t = _bone_tip(arm, path[i], path, i, owned, fallback)
|
||||||
tips.append([round(t.x, 5), round(t.y, 5), round(t.z, 5)])
|
tips.append([round(t.x, 5), round(t.y, 5), round(t.z, 5)])
|
||||||
|
hulls.append(_hull_samples(arm, path[i], owned))
|
||||||
fallback = t
|
fallback = t
|
||||||
chains.append({
|
chains.append({
|
||||||
"class": next((t for t in tokens(path[0])
|
"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,
|
"root_parent": bone.parent.name if bone.parent else None,
|
||||||
"bones": path,
|
"bones": path,
|
||||||
"tips": tips,
|
"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
|
continue
|
||||||
for k in kids:
|
for k in kids:
|
||||||
@@ -703,7 +928,9 @@ def describe_rig(arm, roles, mapping, meshes):
|
|||||||
twist.append({"bone": b.name, "parent": b.parent.name,
|
twist.append({"bone": b.name, "parent": b.parent.name,
|
||||||
"child": b.children[0].name if b.children else None})
|
"child": b.children[0].name if b.children else None})
|
||||||
return {"roles": roles_out, "chains": chains, "twist": twist,
|
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),
|
"weights_authored": _weights_look_authored(meshes, roles),
|
||||||
"driven_bones": sorted(driven)}
|
"driven_bones": sorted(driven)}
|
||||||
|
|
||||||
@@ -801,6 +1028,8 @@ def main():
|
|||||||
print("Resolved so far:\n" + roles.describe())
|
print("Resolved so far:\n" + roles.describe())
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
rebuild_hierarchy(arm, roles)
|
rebuild_hierarchy(arm, roles)
|
||||||
|
subdivide_cloth_panels(arm, meshes, roles)
|
||||||
|
unbind_cloth_from_legs(arm, meshes, roles)
|
||||||
flatten_and_scale(arm, meshes, TARGET_HEIGHT)
|
flatten_and_scale(arm, meshes, TARGET_HEIGHT)
|
||||||
fix_unlit_materials(meshes)
|
fix_unlit_materials(meshes)
|
||||||
roles = RigRoles(arm) # rest positions moved; re-read
|
roles = RigRoles(arm) # rest positions moved; re-read
|
||||||
@@ -884,4 +1113,226 @@ def main():
|
|||||||
print("Done.")
|
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()
|
main()
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ warnings = []
|
|||||||
weights_authored = True
|
weights_authored = True
|
||||||
|
|
||||||
|
|
||||||
def check(ok, label, detail="", needs_authored_weights=False):
|
def check(ok, label, detail="", needs_authored_weights=False, advisory=False):
|
||||||
soft = needs_authored_weights and not weights_authored
|
soft = advisory or (needs_authored_weights and not weights_authored)
|
||||||
tag = "PASS" if ok else ("WARN" if soft else "FAIL")
|
tag = "PASS" if ok else ("WARN" if soft else "FAIL")
|
||||||
print(f" [{tag}] {label}" + (f" — {detail}" if detail else ""))
|
print(f" [{tag}] {label}" + (f" — {detail}" if detail else ""))
|
||||||
if ok:
|
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",
|
check(four < 0.5, "influences look authored, not solved",
|
||||||
f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}",
|
f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}",
|
||||||
needs_authored_weights=True)
|
needs_authored_weights=True)
|
||||||
check(len(meshes) > 1, "model keeps its per-part meshes", f"{len(meshes)} meshes",
|
# ADVISORY, not a gate. Several meshes is what we want — it is how body, cloth
|
||||||
needs_authored_weights=True)
|
# 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
|
# ------------------------------------------------------------------- skeleton
|
||||||
def is_rootish(b):
|
def is_rootish(b):
|
||||||
|
|||||||
Reference in New Issue
Block a user