Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
10 changed files with 1046 additions and 24 deletions
Showing only changes of commit cd0d1b2d99 - Show all commits
@@ -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
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."
}
+131
View File
@@ -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"
]
}
+8 -1
View File
@@ -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
}
]
}
+271
View File
@@ -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 5070 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`
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 -- <out_dir> <skin_id>`
— 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
cross-section 0.850.86, everything else 0.891.00, worst stretch 1.16.
@@ -222,6 +487,12 @@ cross-section 0.850.86, everything else 0.891.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.
+28 -2
View File
@@ -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
+102
View File
@@ -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
View File
@@ -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.
"""
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()
+16 -4
View File
@@ -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):