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
+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.
|
||||
"""
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user