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:
Nicholas Butzke
2026-07-26 13:25:26 -04:00
co-authored by Claude Opus 5
parent 270d5f0973
commit cd0d1b2d99
10 changed files with 1046 additions and 24 deletions
+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):