1634 lines
68 KiB
Python
1634 lines
68 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Put the animation library onto a character WITHOUT touching how it deforms.
|
|
|
|
This replaces the old strip_rig -> autorig -> merge_animations route, which
|
|
solved a naming problem by destroying the asset. That route threw away the
|
|
character's skeleton, joined every mesh into one blob, and rebound the result
|
|
with nearest-four-bones Euclidean weights. Measured on the shipped taila.glb it
|
|
produced 2817 vertices pulled by BOTH legs (worst a dead 50/50 split) and 86%
|
|
of all vertices carrying the full four influences — while the ORIGINAL file it
|
|
was built from had zero cross-leg bleed, one mesh per material, and dedicated
|
|
bone chains for the skirt and the hair. Every runtime "repair" in
|
|
characters/skin_leg_repair.gd exists to undo damage done right here.
|
|
|
|
So: keep the character's own rig, weights, per-part meshes and cloth chains,
|
|
and move the ANIMATION onto it instead.
|
|
|
|
1. Rebuild parenting. A Rigify DEF-rig exports its chain roots parented
|
|
straight to the armature root, because Rigify drives them by constraint
|
|
rather than hierarchy. Left that way, rotating the hips would leave the
|
|
legs, skirt and hair floating in place. Orphans are re-attached by
|
|
anatomy where it is known and by rest geometry otherwise — and cloth may
|
|
only ever attach to the trunk, never to a limb, or a skirt would ride one
|
|
thigh.
|
|
2. Retarget by ROLE, not by name (see tools/rig_map.py).
|
|
3. Bake each clip as a rest-relative delta:
|
|
|
|
R_world = src_pose_rot * src_rest_rot^-1 (what the clip does)
|
|
tgt_rot = R_world * tgt_rest_rot (done to THIS rig)
|
|
|
|
Copying absolute world orientation instead — which is what the old
|
|
constraint bake did — forces the library's bone roll onto a mesh that was
|
|
bound with a different one, and twists every limb by a constant offset.
|
|
4. Drive ONLY the body. Skirt, hair, twist and face bones are left with no
|
|
keys at all, so they rest relative to their parents and are free for the
|
|
spring solver at runtime. That split — clips animate the body, physics
|
|
animates the cloth — is the whole point.
|
|
|
|
Usage:
|
|
blender --background --python tools/retarget.py -- \
|
|
<character.glb> <animations_dir> <output.glb> [--height 1.75] [--keep-root-motion]
|
|
|
|
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
|
|
import tempfile
|
|
from collections import defaultdict
|
|
from mathutils import Matrix, Quaternion, Vector
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import gltf_fix
|
|
import rig_map
|
|
import surface_map
|
|
from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens
|
|
from retarget_pose import (
|
|
authored_world_rotation, build_segment_pairs, heading_inverse
|
|
)
|
|
|
|
argv = sys.argv
|
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
if len(argv) < 3:
|
|
print(__doc__)
|
|
sys.exit(1)
|
|
|
|
CHARACTER, ANIM_DIR, OUTPUT = argv[0], argv[1], argv[2]
|
|
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
|
TARGET_HEIGHT = 1.75
|
|
if "--height" in argv:
|
|
TARGET_HEIGHT = float(argv[argv.index("--height") + 1])
|
|
OVERRIDES = {}
|
|
if "--bone-map" in argv:
|
|
with open(argv[argv.index("--bone-map") + 1], "r", encoding="utf-8") as f:
|
|
OVERRIDES = json.load(f)
|
|
|
|
# Library clip -> the game's canonical clip name. Unmapped clips are skipped so
|
|
# character GLBs stay small.
|
|
LIBRARY_CLIP_MAP = {
|
|
"Idle_Loop": "Idle",
|
|
"Walk_Loop": "Walk",
|
|
"Jog_Fwd_Loop": "Run",
|
|
"Sprint_Loop": "Sprint",
|
|
"Jump_Start": "Jump",
|
|
"Jump_Loop": "Fall",
|
|
"Jump_Land": "Land",
|
|
"Crouch_Idle_Loop": "CrouchIdle",
|
|
"Crouch_Fwd_Loop": "CrouchWalk",
|
|
"Roll": "Roll",
|
|
"Death01": "Death",
|
|
"Hit_Chest": "Hit",
|
|
"Dance_Loop": "Dance",
|
|
"Pistol_Idle_Loop": "PistolIdle",
|
|
"Pistol_Shoot": "PistolShoot",
|
|
"Pistol_Reload": "PistolReload",
|
|
"Sword_Attack": "SwordAttack",
|
|
}
|
|
|
|
LIBRARY_V2_CLIP_MAP = {
|
|
"Slide_Start": "SlideStart",
|
|
"Slide_Loop": "Slide",
|
|
"Slide_Exit": "SlideExit",
|
|
"NinjaJump_Start": "WallRunStart",
|
|
"NinjaJump_Land": "WallRunExit",
|
|
"Idle_Rail_Loop": "WallCling",
|
|
"ClimbUp_1m": "WallClimb",
|
|
"Shield_Dash": "Dash",
|
|
"Sword_Dash": "SwordDash",
|
|
"OverhandThrow": "Throw",
|
|
"Hit_Knockback": "Knockback",
|
|
"Chest_Open": "EmoteStretch",
|
|
"Idle_Rail_Call": "EmoteCall",
|
|
"Yes": "EmoteYes",
|
|
"Idle_No_Loop": "EmoteNo",
|
|
}
|
|
|
|
# The TPS demo set is a combat *walk*, not a run. It used to occupy the
|
|
# high-speed Run* slots, which made the runtime accelerate a roughly 1.8 m/s
|
|
# step cycle to six times normal playback at the game's 11 m/s ground speed.
|
|
# Keep the useful authored front/back steps, but label them honestly.
|
|
DIRECTIONAL_CLIP_MAP = {
|
|
"strafe_front-cycle": "StrafeWalkForward",
|
|
# The same coherent back-step is cadence-scaled for running. This source
|
|
# keeps pelvis and abdomen in one frame; the previous CMU capture folded
|
|
# the torso on the anime rigs.
|
|
"strafe_back-cycle": [
|
|
("StrafeWalkBackward", 0.0),
|
|
("RunBackward", 0.0),
|
|
],
|
|
}
|
|
|
|
# Mesh2Motion's CC0 human add-on library supplies authored forward and lateral
|
|
# actions. Never rotate only the legs to manufacture another direction: the
|
|
# captured CMU clip below owns backward travel as one coherent full-body move.
|
|
MESH2MOTION_CLIP_MAP = {
|
|
"Strafe_left": [("StrafeWalkLeft", 0.0), ("RunLeft", 0.0)],
|
|
"Strafe_right": [("StrafeWalkRight", 0.0), ("RunRight", 0.0)],
|
|
"Run_Anime": [("RunForward", 0.0)],
|
|
# A subtle held superhero-flight motion: the leading hands stay extended
|
|
# while the feet move less than two degrees across the runtime audit.
|
|
"Flying Forward Super": [("Grapple", 0.0)],
|
|
}
|
|
|
|
# Uisco's authored UE4 Mannequin wall-run pair. These are real lateral
|
|
# wall-running performances, not a forward sprint relabelled as traversal.
|
|
WALLRUN_CLIP_MAP = {
|
|
"WallRunLeft": "WallRunLeft",
|
|
"WallRunRight": "WallRunRight",
|
|
}
|
|
|
|
CMU_CLIP_MAP = {}
|
|
|
|
UP = Vector((0.0, 0.0, 1.0))
|
|
|
|
# Which cosmetic chains get secondary motion. A face-shape or eye chain is
|
|
# cosmetic but must never swing, so this is deliberately narrower than
|
|
# rig_map.COSMETIC.
|
|
SPRING_CLASSES = {"hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat",
|
|
"scarf", "sleeve", "breast", "bust", "feather", "strap",
|
|
"antenna", "wing"}
|
|
|
|
|
|
def spring_class(name):
|
|
"""Which secondary-motion class this bone belongs to, or None.
|
|
|
|
Whole token first, then a SHORT positional suffix — the same rule as
|
|
rig_map.is_cosmetic, and it must be the same rule, because a bone that reads
|
|
as cosmetic there and as nothing here is left out of every chain and its
|
|
geometry never moves. DANDADAN's hair is HairFL / HairFR / HairF_Top, which
|
|
tokenise to "hairfl" and matched no class at all: she imported with six
|
|
chains, all of them her bust, and not one strand of hair.
|
|
"""
|
|
for t in tokens(name):
|
|
if t in SPRING_CLASSES:
|
|
return t
|
|
for c in SPRING_CLASSES:
|
|
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
|
|
return c
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------- import
|
|
|
|
|
|
def clear_scene():
|
|
bpy.ops.object.select_all(action="SELECT")
|
|
bpy.ops.object.delete()
|
|
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.actions):
|
|
for b in list(blocks):
|
|
if b.users == 0:
|
|
blocks.remove(b)
|
|
|
|
|
|
def import_any(path):
|
|
ext = os.path.splitext(path)[1].lower()
|
|
if ext in (".glb", ".gltf"):
|
|
bpy.ops.import_scene.gltf(filepath=path)
|
|
elif ext == ".fbx":
|
|
bpy.ops.import_scene.fbx(filepath=path)
|
|
else:
|
|
print(f"ERROR: unsupported character format {ext}")
|
|
sys.exit(1)
|
|
|
|
|
|
def skinned_meshes(arm):
|
|
"""Meshes actually driven by this armature.
|
|
|
|
Anything else in the file is scene dressing — Sketchfab models routinely
|
|
ship a display base or a diorama, and those must not become part of the
|
|
player.
|
|
"""
|
|
out = []
|
|
for o in [o for o in bpy.data.objects if o.type == "MESH"]:
|
|
driven = any(m.type == "ARMATURE" and m.object is arm for m in o.modifiers)
|
|
if driven or (o.parent is arm and o.vertex_groups):
|
|
out.append(o)
|
|
return out
|
|
|
|
|
|
def strip_import_suffixes(arm, meshes):
|
|
"""`DEF-thigh.L_16` -> `DEF-thigh.L`.
|
|
|
|
The glTF importer appends the node index to every bone name. Those names
|
|
ship in the exported GLB and are what the game code matches on, so clean
|
|
them up here rather than teaching every consumer about the suffix.
|
|
"""
|
|
renames = {}
|
|
taken = set(b.name for b in arm.data.bones)
|
|
for bone in arm.data.bones:
|
|
clean = rig_map.strip_gltf_suffix(bone.name)
|
|
if clean != bone.name and clean not in taken:
|
|
renames[bone.name] = clean
|
|
taken.discard(bone.name)
|
|
taken.add(clean)
|
|
for old, new in renames.items():
|
|
arm.data.bones[old].name = new
|
|
# Vertex groups are matched to bones by NAME and are not renamed for us.
|
|
for m in meshes:
|
|
for vg in m.vertex_groups:
|
|
new = renames.get(vg.name)
|
|
if new and new not in m.vertex_groups:
|
|
vg.name = new
|
|
print(f"Cleaned {len(renames)} bone names")
|
|
|
|
|
|
# ----------------------------------------------------------------- hierarchy
|
|
|
|
|
|
def _seg_distance(p, a, b):
|
|
ab = b - a
|
|
d2 = ab.dot(ab)
|
|
t = 0.0 if d2 < 1e-12 else max(0.0, min(1.0, (p - a).dot(ab) / d2))
|
|
return (p - (a + ab * t)).length
|
|
|
|
|
|
def rebuild_hierarchy(arm, roles):
|
|
"""Re-attach chain roots that exported parented to the armature root.
|
|
|
|
Anatomy first (a thigh belongs to the hips, a forearm to the upper arm),
|
|
rest geometry second. Cloth and hair are only ever allowed to attach to the
|
|
TRUNK: pick anchors by raw proximity and a skirt panel hanging beside a leg
|
|
attaches to that thigh and rides it like a trouser leg.
|
|
"""
|
|
trunk = [roles.hips] + list(roles.spine)
|
|
trunk = [n for n in trunk if n]
|
|
limb = roles.limb
|
|
|
|
def anat(role, side):
|
|
return limb.get((role, side))
|
|
|
|
fixed = {}
|
|
for side in ("L", "R"):
|
|
chest = trunk[-3] if len(trunk) >= 3 else (trunk[-1] if trunk else None)
|
|
pairs = [
|
|
(("thigh", side), roles.hips),
|
|
(("shin", side), anat("thigh", side)),
|
|
(("foot", side), anat("shin", side)),
|
|
(("toe", side), anat("foot", side)),
|
|
(("shoulder", side), chest),
|
|
(("upper_arm", side), anat("shoulder", side) or chest),
|
|
(("forearm", side), anat("upper_arm", side)),
|
|
(("hand", side), anat("forearm", side)),
|
|
]
|
|
for key, parent in pairs:
|
|
name = limb.get(key)
|
|
if name and parent:
|
|
fixed[name] = parent
|
|
|
|
bpy.context.view_layer.objects.active = arm
|
|
bpy.ops.object.mode_set(mode="EDIT")
|
|
eb = arm.data.edit_bones
|
|
|
|
# Where each anchor bone actually EXTENDS TO, from authored data only.
|
|
#
|
|
# glTF stores joints as nodes with no tail, so the tails Blender reports are
|
|
# invented — every one of Taila's skirt bones comes back 0.78 m long. Using
|
|
# them, DEF-spine's phantom tail runs straight down through the whole skirt
|
|
# and every panel measures ~0.05 m from "the hips", which beat each panel's
|
|
# real 0.15 m link to its own chain root and flattened all 7 chains.
|
|
# A bone's true extent is the head of the next bone along.
|
|
span = {}
|
|
for i, name in enumerate(trunk):
|
|
nxt = trunk[i + 1] if i + 1 < len(trunk) else None
|
|
span[name] = (eb[name].head,
|
|
eb[nxt].head if nxt and nxt in eb else eb[name].tail)
|
|
for name in limb.values():
|
|
if name not in eb:
|
|
continue
|
|
kids = [c for c in eb[name].children if not is_cosmetic(c.name)]
|
|
span[name] = (eb[name].head, kids[0].head if kids else eb[name].tail)
|
|
|
|
def anchor_distance(point, name):
|
|
a, b = span.get(name, (eb[name].head, eb[name].tail))
|
|
return _seg_distance(point, a, b)
|
|
|
|
def is_root_like(bone):
|
|
return bone is None or any(
|
|
t in ("root", "master", "armature", "scene", "rootjoint")
|
|
for t in tokens(bone.name))
|
|
|
|
def descendants(bone):
|
|
out = {bone.name}
|
|
stack = list(bone.children)
|
|
while stack:
|
|
b = stack.pop()
|
|
out.add(b.name)
|
|
stack.extend(b.children)
|
|
return out
|
|
|
|
trunk_set = set(trunk)
|
|
core = trunk_set | set(limb.values())
|
|
orphans = [b for b in eb
|
|
if b.name != roles.hips and b.name not in trunk_set
|
|
and is_root_like(b.parent)]
|
|
|
|
reparented = 0
|
|
cosmetic_left = []
|
|
for bone in orphans:
|
|
if is_cosmetic(bone.name):
|
|
cosmetic_left.append(bone)
|
|
continue
|
|
target = fixed.get(bone.name)
|
|
if target is None:
|
|
banned = descendants(bone)
|
|
cands = [n for n in core if n in eb and n not in banned]
|
|
if not cands:
|
|
continue
|
|
target = min(cands, key=lambda n: anchor_distance(bone.head, n))
|
|
if target in eb and target != bone.name:
|
|
bone.parent = eb[target]
|
|
bone.use_connect = False
|
|
reparented += 1
|
|
|
|
# Cloth and hair hang from the TRUNK — never from a limb, and never from
|
|
# each other.
|
|
#
|
|
# Attaching cloth to whatever bone is nearest puts 16 of Taila's 21 skirt
|
|
# bones on a thigh, where the panel rides one leg like a trouser leg. The
|
|
# trunk restriction fixes that.
|
|
#
|
|
# Reconstructing multi-bone chains is deliberately NOT attempted. The
|
|
# temptation is obvious — Taila's skirt is really 7 panels of 3 — but the
|
|
# information is not in the file. glTF stores no bone tails, the panel
|
|
# numbering is not sequential (`skirt` -> `skirt.011` -> `skirt.002`), and
|
|
# neighbouring panel roots ring the waist 0.04 m apart, far closer than any
|
|
# of them is to the trunk. Successive attempts at distance, chain-direction
|
|
# and grow-outward rules each produced a topology that was still wrong
|
|
# somewhere — stitching panels together sideways, or hanging hair off an
|
|
# eye bone. A wrong chain is worse than no chain: the solver then swings
|
|
# bones along axes the mesh was never weighted for, and tears it.
|
|
#
|
|
# So each orphan becomes its own pendulum from the body. Chains the ARTIST
|
|
# authored survive untouched, because only chain ROOTS are orphans — which
|
|
# is why Taila's hair keeps its real 4-7 bone strands while her
|
|
# flat-exported skirt becomes per-panel pendulums. Both look like cloth;
|
|
# only the authored one gets true multi-segment drape.
|
|
for bone in cosmetic_left:
|
|
cands = [n for n in trunk_set if n in eb]
|
|
if not cands:
|
|
break
|
|
bone.parent = eb[min(cands, key=lambda n: anchor_distance(bone.head, n))]
|
|
bone.use_connect = False
|
|
reparented += 1
|
|
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
print(f"Re-attached {reparented} orphaned bones")
|
|
|
|
|
|
# ----------------------------------------------------------------- normalize
|
|
|
|
|
|
def flatten_and_scale(arm, meshes, target_height, roles=None):
|
|
"""Bake the import hierarchy away and set the character's real-world size.
|
|
|
|
Sketchfab wraps everything in scaled/rotated empties. Left in place they
|
|
turn up as a scale on the exported Skeleton3D, and every measurement the
|
|
game makes off bone rests reads in the wrong units.
|
|
"""
|
|
if arm.animation_data:
|
|
arm.animation_data_clear()
|
|
for pb in arm.pose.bones:
|
|
pb.matrix_basis = Matrix()
|
|
|
|
for obj in [arm] + meshes:
|
|
world = obj.matrix_world.copy()
|
|
obj.parent = None
|
|
obj.matrix_world = world
|
|
for o in [o for o in bpy.data.objects if o.type == "EMPTY"]:
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
|
|
def apply_all():
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for obj in [arm] + meshes:
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = arm
|
|
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
|
|
|
apply_all()
|
|
|
|
# STAND THE CHARACTER UP before measuring anything.
|
|
#
|
|
# The scale below normalises the bounding box along Z because Z is up in
|
|
# Blender. For a model that arrives lying along another axis that measures
|
|
# the character's THICKNESS — about 0.25 m — so it gets scaled by ~7 and left
|
|
# on its back. One assumption, both symptoms, and invisible afterwards
|
|
# because the exporter maps Blender Z to glTF Y: "is the height 1.75" comes
|
|
# out true on a character who is 7.5 m tall lying down.
|
|
#
|
|
# Which way is up is not a convention to assume, it is a property of the
|
|
# skeleton: the head is above the hips. Snapped to the nearest axis rather
|
|
# than aligned exactly, so a character with a slight lean in their rest pose
|
|
# is stood up, not straightened.
|
|
# Measured from the FEET to the HIPS, not from the hips to the head.
|
|
#
|
|
# The head is not a reliable landmark. The spine walk ends on whatever the
|
|
# last non-cosmetic bone in the chain is, and on a rig with a facial skeleton
|
|
# that can be a bone sitting BELOW the hips — which points this vector
|
|
# downwards and stands the character neatly on her head. Momo did exactly
|
|
# that. Feet cannot be mistaken: they are the bottom of a standing character
|
|
# on every rig, and foot.L/R resolve on every source met so far.
|
|
if roles is not None and roles.hips:
|
|
hips_b = arm.data.bones.get(roles.hips)
|
|
feet = [arm.data.bones.get(roles.limb.get(("foot", s), ""))
|
|
for s in ("L", "R")]
|
|
feet = [f for f in feet if f]
|
|
ref = None
|
|
if feet:
|
|
ref = sum((f.head_local for f in feet), Vector()) / len(feet)
|
|
elif roles.head and arm.data.bones.get(roles.head):
|
|
# No feet — fall back to the head, and accept the risk above.
|
|
ref = hips_b.head_local - (
|
|
arm.data.bones[roles.head].head_local - hips_b.head_local)
|
|
if hips_b and ref is not None:
|
|
d = hips_b.head_local - ref
|
|
k = max(range(3), key=lambda i: abs(d[i]))
|
|
src = Vector((0.0, 0.0, 0.0))
|
|
src[k] = 1.0 if d[k] > 0 else -1.0
|
|
up = Vector((0.0, 0.0, 1.0))
|
|
if src.dot(up) < 0.999:
|
|
axis = src.cross(up)
|
|
if axis.length < 1e-6:
|
|
axis = Vector((1.0, 0.0, 0.0)) # upside down
|
|
R = Matrix.Rotation(src.angle(up), 4, axis.normalized())
|
|
for obj in [arm] + meshes:
|
|
obj.matrix_world = R @ obj.matrix_world
|
|
apply_all()
|
|
print(f"Stood the character up: feet->hips ran along "
|
|
f"{'XYZ'[k]}{'+' if d[k] > 0 else '-'}, rotated to Z+")
|
|
|
|
# TURN THE CHARACTER TO FACE THE SAME WAY AS EVERY OTHER CHARACTER.
|
|
#
|
|
# Which way a model faces is a property of the file, not a constant, and two
|
|
# separate things were guessing at it: facing_correction() aligns the rest
|
|
# pose to the library's, and the runtime then applies a blanket 180 degrees
|
|
# because "glTF forward is +Z". When a source disagrees with either, the two
|
|
# compose into a character who runs backwards, and nothing measured the
|
|
# result. Kiyoko shipped like that.
|
|
#
|
|
# The skeleton knows: toes are in front of ankles. Snap that to Blender -Y,
|
|
# which is the convention the rest of the pipeline and the runtime flip are
|
|
# built around, so every character leaves here pointing the same way whatever
|
|
# the source did.
|
|
if roles is not None:
|
|
fwd = Vector((0.0, 0.0, 0.0))
|
|
n_f = 0
|
|
for side in ("L", "R"):
|
|
a = arm.data.bones.get(roles.limb.get(("foot", side), ""))
|
|
t = arm.data.bones.get(roles.limb.get(("toe", side), ""))
|
|
if a and t:
|
|
fwd += (t.head_local - a.head_local)
|
|
n_f += 1
|
|
if n_f:
|
|
fwd.z = 0.0
|
|
if fwd.length > 1e-4:
|
|
fwd.normalize()
|
|
want = Vector((0.0, -1.0, 0.0))
|
|
ang = math.atan2(fwd.x, -fwd.y) # signed yaw from -Y to fwd
|
|
# Snap to the nearest quarter turn: a rest pose with the feet
|
|
# slightly splayed must not be counted as a turn.
|
|
q = round(ang / (math.pi / 2)) * (math.pi / 2)
|
|
if abs(q) > 1e-6:
|
|
R = Matrix.Rotation(-q, 4, Vector((0.0, 0.0, 1.0)))
|
|
for obj in [arm] + meshes:
|
|
obj.matrix_world = R @ obj.matrix_world
|
|
apply_all()
|
|
print("Turned the character to face -Y: was %.0f degrees off"
|
|
% math.degrees(q))
|
|
|
|
lo = Vector((1e9, 1e9, 1e9))
|
|
hi = -lo.copy()
|
|
for m in meshes:
|
|
for corner in m.bound_box:
|
|
p = m.matrix_world @ Vector(corner)
|
|
lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z)))
|
|
hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z)))
|
|
height = hi.z - lo.z
|
|
if height > 1e-4:
|
|
s = target_height / height
|
|
for obj in [arm] + meshes:
|
|
obj.scale = (s, s, s)
|
|
apply_all()
|
|
lo *= s
|
|
hi *= s
|
|
print(f"Scaled by {s:.4f} to {target_height:.2f} m")
|
|
|
|
offset = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z))
|
|
for obj in [arm] + meshes:
|
|
obj.location = offset
|
|
apply_all()
|
|
|
|
# Re-parent meshes under the armature so the export writes one clean skin.
|
|
for m in meshes:
|
|
m.parent = arm
|
|
m.matrix_parent_inverse = Matrix()
|
|
if not any(mod.type == "ARMATURE" and mod.object is arm for mod in m.modifiers):
|
|
mod = m.modifiers.new("Armature", "ARMATURE")
|
|
mod.object = arm
|
|
|
|
|
|
def fix_unlit_materials(meshes):
|
|
"""Route each material's texture into Base Color.
|
|
|
|
Anime models are commonly authored UNLIT: black base colour with the albedo
|
|
wired to emission. Our toon shader reads ALBEDO, so left alone the
|
|
character renders pitch black.
|
|
"""
|
|
seen = set()
|
|
for m in meshes:
|
|
for mat in m.data.materials:
|
|
if not mat or not mat.use_nodes or mat.name in seen:
|
|
continue
|
|
seen.add(mat.name)
|
|
nt = mat.node_tree
|
|
tex = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image), None)
|
|
bsdf = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None)
|
|
if not bsdf or not tex:
|
|
continue
|
|
base = bsdf.inputs["Base Color"]
|
|
if not base.links:
|
|
nt.links.new(tex.outputs["Color"], base)
|
|
print(f"Material '{mat.name}': routed '{tex.image.name}' to base colour")
|
|
if "Emission Strength" in bsdf.inputs:
|
|
bsdf.inputs["Emission Strength"].default_value = 0.0
|
|
|
|
|
|
# ------------------------------------------------------------------ retarget
|
|
|
|
|
|
def world_rest(arm):
|
|
mw = arm.matrix_world
|
|
return {b.name: mw @ b.matrix_local for b in arm.data.bones}
|
|
|
|
|
|
def rig_forward(arm, roles):
|
|
"""Which way the rest pose faces, on the ground plane, from the feet."""
|
|
mw = arm.matrix_world
|
|
acc = Vector((0.0, 0.0, 0.0))
|
|
for side in ("L", "R"):
|
|
for role in ("toe", "foot"):
|
|
name = roles.limb.get((role, side))
|
|
if not name:
|
|
continue
|
|
b = arm.data.bones[name]
|
|
v = (mw @ b.tail_local) - (mw @ b.matrix_local.translation)
|
|
v.z = 0.0
|
|
if v.length > 1e-5:
|
|
acc += v.normalized()
|
|
break
|
|
return acc.normalized() if acc.length > 1e-5 else None
|
|
|
|
|
|
def facing_correction(src_arm, src_roles, tgt_arm, tgt_roles):
|
|
"""Yaw that carries the source rig's forward onto the target's.
|
|
|
|
Without it a library that rests facing -Y drives a character that rests
|
|
facing +Y and every clip plays backwards.
|
|
"""
|
|
a = rig_forward(src_arm, src_roles)
|
|
b = rig_forward(tgt_arm, tgt_roles)
|
|
if a is None or b is None:
|
|
return Quaternion()
|
|
# angle_signed is 2D-only in mathutils, which is what we want anyway: the
|
|
# correction is a yaw about world up, never a tilt.
|
|
angle = Vector((a.x, a.y)).angle_signed(Vector((b.x, b.y)), 0.0)
|
|
if abs(angle) < 1e-4:
|
|
return Quaternion()
|
|
print(f"Facing correction: {angle * 57.2958:.1f} deg")
|
|
return Quaternion(UP, angle)
|
|
|
|
|
|
def bone_order(arm):
|
|
"""Every bone, parents before children."""
|
|
out = []
|
|
|
|
def walk(b):
|
|
out.append(b.name)
|
|
for c in b.children:
|
|
walk(c)
|
|
|
|
for b in arm.data.bones:
|
|
if b.parent is None:
|
|
walk(b)
|
|
return out
|
|
|
|
|
|
def solve_pose(arm, order, rest_w, desired_rot, hips, hips_head):
|
|
"""Turn desired WORLD orientations into per-bone local basis transforms.
|
|
|
|
Done arithmetically rather than by setting `pose_bone.matrix` and letting
|
|
Blender solve, because that needs a depsgraph update per bone — 150 bones
|
|
across 18 clips is tens of thousands of scene evaluations.
|
|
|
|
Blender relates pose to rest as
|
|
pose = parent_pose * parent_rest^-1 * rest * basis
|
|
so with M standing for everything left of `basis`, a rotation-only basis of
|
|
M.rot^-1 * desired lands the bone on `desired` exactly.
|
|
"""
|
|
pose_w = {}
|
|
basis = {}
|
|
bones = arm.data.bones
|
|
for name in order:
|
|
b = bones[name]
|
|
rest = rest_w[name]
|
|
if b.parent is not None:
|
|
M = pose_w[b.parent.name] @ rest_w[b.parent.name].inverted() @ rest
|
|
else:
|
|
M = rest
|
|
q = Quaternion()
|
|
if name in desired_rot:
|
|
q = M.to_quaternion().inverted() @ desired_rot[name]
|
|
loc = Vector((0.0, 0.0, 0.0))
|
|
if name == hips and hips_head is not None:
|
|
loc = M.inverted() @ hips_head
|
|
basis[name] = (loc, q)
|
|
pose_w[name] = M @ Matrix.Translation(loc) @ q.to_matrix().to_4x4()
|
|
return basis
|
|
|
|
|
|
def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action,
|
|
clip_name, yaw, scale, lower_body_yaw_degrees=0.0):
|
|
src_rest = world_rest(src_arm)
|
|
tgt_rest = world_rest(tgt_arm)
|
|
order = bone_order(tgt_arm)
|
|
src_rest_rot = {n: m.to_quaternion() for n, m in src_rest.items()}
|
|
tgt_rest_rot = {n: m.to_quaternion() for n, m in tgt_rest.items()}
|
|
yaw_inv = yaw.inverted()
|
|
segment_pairs = build_segment_pairs(mapping, src_roles, tgt_roles)
|
|
|
|
src_hips = src_roles.hips
|
|
tgt_hips = tgt_roles.hips
|
|
src_hips_rest = src_rest[src_hips].translation.copy()
|
|
tgt_hips_rest = tgt_rest[tgt_hips].translation.copy()
|
|
|
|
assign_action(src_arm, action)
|
|
f0, f1 = (int(round(v)) for v in action.frame_range)
|
|
source_heading = Quaternion()
|
|
if clip_name == "RunBackward":
|
|
bpy.context.scene.frame_set(f0)
|
|
source_eval = src_arm.evaluated_get(
|
|
bpy.context.evaluated_depsgraph_get()
|
|
)
|
|
source_heading = heading_inverse(
|
|
(
|
|
source_eval.matrix_world
|
|
@ source_eval.pose.bones[src_hips].matrix
|
|
).to_quaternion(),
|
|
src_rest_rot[src_hips],
|
|
UP,
|
|
)
|
|
|
|
baked = bpy.data.actions.new(clip_name)
|
|
assign_action(tgt_arm, baked)
|
|
for pb in tgt_arm.pose.bones:
|
|
pb.rotation_mode = "QUATERNION"
|
|
|
|
scene = bpy.context.scene
|
|
for frame in range(f0, f1 + 1):
|
|
scene.frame_set(frame)
|
|
dg = bpy.context.evaluated_depsgraph_get()
|
|
src_eval = src_arm.evaluated_get(dg)
|
|
smw = src_eval.matrix_world
|
|
|
|
desired = {}
|
|
for tgt_name, src_name in mapping.items():
|
|
if src_name not in src_eval.pose.bones or tgt_name not in tgt_rest_rot:
|
|
continue
|
|
target_rotation = authored_world_rotation(
|
|
tgt_name,
|
|
src_name,
|
|
src_eval,
|
|
smw,
|
|
src_rest_rot,
|
|
tgt_rest,
|
|
tgt_rest_rot,
|
|
yaw,
|
|
yaw_inv,
|
|
segment_pairs,
|
|
source_heading,
|
|
)
|
|
desired[tgt_name] = target_rotation
|
|
|
|
hips_head = None
|
|
if src_hips in src_eval.pose.bones:
|
|
moved = (smw @ src_eval.pose.bones[src_hips].matrix).translation
|
|
d = yaw @ (source_heading @ (
|
|
(moved - src_hips_rest) * scale
|
|
))
|
|
if STRIP_ROOT_MOTION:
|
|
d.x = 0.0
|
|
d.y = 0.0 # gameplay code moves the body; keep the vertical bob
|
|
hips_head = tgt_hips_rest + d
|
|
|
|
basis = solve_pose(tgt_arm, order, tgt_rest, desired, tgt_hips, hips_head)
|
|
for name in mapping:
|
|
if name not in basis:
|
|
continue
|
|
pb = tgt_arm.pose.bones[name]
|
|
pb.rotation_quaternion = basis[name][1]
|
|
pb.keyframe_insert("rotation_quaternion", frame=frame)
|
|
if hips_head is not None:
|
|
pb = tgt_arm.pose.bones[tgt_hips]
|
|
pb.location = basis[tgt_hips][0]
|
|
pb.keyframe_insert("location", frame=frame)
|
|
|
|
assign_action(tgt_arm, None)
|
|
return baked
|
|
|
|
|
|
def assign_action(obj, action):
|
|
if not obj.animation_data:
|
|
obj.animation_data_create()
|
|
obj.animation_data.action = action
|
|
if action is None:
|
|
return
|
|
try: # Blender 4.4+ slotted actions
|
|
if not obj.animation_data.action_slot and len(action.slots):
|
|
obj.animation_data.action_slot = action.slots[0]
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
|
|
|
|
def add_nla_clip(arm, action, name):
|
|
action.name = name
|
|
track = arm.animation_data.nla_tracks.new()
|
|
track.name = name
|
|
strip = track.strips.new(name, 0, action)
|
|
strip.name = name
|
|
track.mute = True
|
|
action.use_fake_user = True
|
|
|
|
|
|
# -------------------------------------------------------------------- sidecar
|
|
|
|
|
|
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:
|
|
gname = {g.index: g.name for g in m.vertex_groups}
|
|
mw = m.matrix_world
|
|
for v in m.data.vertices:
|
|
best = None
|
|
for g in v.groups:
|
|
if best is None or g.weight > best.weight:
|
|
best = g
|
|
if best is not None and best.weight > min_weight:
|
|
out[gname.get(best.group, "")].append(mw @ v.co)
|
|
return out
|
|
|
|
|
|
def _bone_tip(arm, bone, chain, index, owned, fallback):
|
|
"""Where a bone effectively points, in its own rest space.
|
|
|
|
The next bone along when there is one. Otherwise the centroid of the
|
|
geometry this bone actually drives — which is the only real answer for
|
|
Taila's skirt, whose 21 panel bones export with no children and no usable
|
|
tail, so there is nothing in the skeleton to say which way a panel hangs.
|
|
"""
|
|
rest_world = arm.matrix_world @ arm.data.bones[bone].matrix_local
|
|
if index + 1 < len(chain):
|
|
nxt = arm.matrix_world @ arm.data.bones[chain[index + 1]].matrix_local
|
|
return rest_world.inverted() @ nxt.translation
|
|
pts = owned.get(bone, [])
|
|
if pts:
|
|
centroid = sum(pts, Vector((0.0, 0.0, 0.0))) / len(pts)
|
|
local = rest_world.inverted() @ centroid
|
|
# The centroid sits mid-panel, so the far edge is roughly twice out.
|
|
if local.length > 1e-4:
|
|
return local * 2.0
|
|
return fallback
|
|
|
|
|
|
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 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 spring_class(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))
|
|
child = roles.limb.get((child_role, side))
|
|
if not name or not child:
|
|
continue
|
|
a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation
|
|
b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation
|
|
ab = b - a
|
|
d2 = ab.dot(ab)
|
|
if d2 < 1e-9:
|
|
continue
|
|
# 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,
|
|
"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)
|
|
owned = _dominant_vertices(meshes, arm)
|
|
roles_out = {"hips": roles.hips, "head": roles.head, "neck": roles.neck,
|
|
"spine": list(roles.spine)}
|
|
for (role, side), name in roles.limb.items():
|
|
roles_out[f"{role}.{side}"] = name
|
|
|
|
driven = set(mapping)
|
|
chains = []
|
|
springy = {b.name: b for b in arm.data.bones
|
|
if b.name not in driven
|
|
and spring_class(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
|
|
# One chain per leaf path, so each strand solves independently.
|
|
stack = [[name]]
|
|
while stack:
|
|
path = stack.pop()
|
|
kids = [c.name for c in arm.data.bones[path[-1]].children
|
|
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": spring_class(path[0]) or "cloth",
|
|
"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:
|
|
stack.append(path + [k])
|
|
|
|
# Twist bones only — a hair link is also `X.001`, but it is cloth, and
|
|
# listing it here would have the twist distributor and the spring solver
|
|
# both writing the same bone.
|
|
twist = []
|
|
for b in arm.data.bones:
|
|
if b.name in driven or is_cosmetic(b.name) or not is_segment_of(b.name, names):
|
|
continue
|
|
if b.parent is not None:
|
|
twist.append({"bone": b.name, "parent": b.parent.name,
|
|
"child": b.children[0].name if b.children else None})
|
|
# Fingers, so the runtime can close a hand around a grip without knowing
|
|
# how this rig spells "index". Ordered knuckle -> fingertip.
|
|
finger_out = {}
|
|
for (digit, side), bones in roles.fingers.items():
|
|
finger_out["%s.%s" % (digit, side)] = bones
|
|
|
|
# What each surface IS — body, cloth, hair or accessory. Derived from the
|
|
# chains resolved just above, so the surface table and the cloth solver can
|
|
# never disagree about which bones are a skirt. See tools/surface_map.py.
|
|
chain_class = {}
|
|
for chain in chains:
|
|
for bone in chain["bones"]:
|
|
chain_class[bone] = chain["class"]
|
|
role_bone_names = [n for n in roles_out.values() if isinstance(n, str) and n]
|
|
for value in roles_out.values():
|
|
if isinstance(value, list):
|
|
role_bone_names.extend(value)
|
|
|
|
return {"roles": roles_out, "fingers": finger_out,
|
|
"chains": chains, "twist": twist,
|
|
"surfaces": surface_map.describe_surfaces(
|
|
meshes, chain_class, driven, role_bone_names),
|
|
"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)}
|
|
|
|
|
|
def _weights_look_authored(meshes, roles):
|
|
"""Were these weights painted, or solved by a nearest-bone fit?
|
|
|
|
The runtime decides from this whether to run its destructive load-time
|
|
weight repair, so it is MEASURED rather than inferred from which pipeline
|
|
branch ran — a model that arrives unrigged still goes through autorig and
|
|
out through this same tool, and must not be handed a sidecar that says its
|
|
weights are fine when they are not.
|
|
|
|
Two signatures, both taken from the shipped-vs-source comparison that
|
|
started this rework: the nearest-four-bones fit left 16% of vertices pulled
|
|
by BOTH legs and gave 86% of them the full four influences, where the
|
|
artist's own weights had 0.1% and 26%.
|
|
"""
|
|
legs = {}
|
|
for (role, side), name in roles.limb.items():
|
|
if role in ("thigh", "shin", "foot", "toe"):
|
|
legs[name] = -1 if side == "L" else 1
|
|
|
|
def side_of(group_name):
|
|
for name, s in legs.items():
|
|
if group_name == name or group_name.startswith(name + "."):
|
|
return s
|
|
return 0
|
|
|
|
total = 0
|
|
bleeding = 0
|
|
four = 0
|
|
for m in meshes:
|
|
gside = {g.index: side_of(g.name) for g in m.vertex_groups}
|
|
for v in m.data.vertices:
|
|
wl = wr = 0.0
|
|
n = 0
|
|
for g in v.groups:
|
|
if g.weight <= 0.005:
|
|
continue
|
|
n += 1
|
|
s = gside.get(g.group, 0)
|
|
if s == -1:
|
|
wl += g.weight
|
|
elif s == 1:
|
|
wr += g.weight
|
|
total += 1
|
|
if n >= 4:
|
|
four += 1
|
|
if wl > 0.005 and wr > 0.005:
|
|
bleeding += 1
|
|
if total == 0:
|
|
return False
|
|
# Use the same cross-leg ceiling as verify_character. A source can be
|
|
# beautifully hand-painted overall yet still carry enough opposite-leg
|
|
# weight to fold during backward/side steps. Mark that case for the
|
|
# runtime's geometry-aware SkinLegRepair instead of exempting it merely
|
|
# because the rest of the weighting looks authored.
|
|
authored = bleeding / total < 0.005 and four / total < 0.5
|
|
print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), "
|
|
f"{four / total * 100:.0f}% at four influences — "
|
|
f"{'authored' if authored else 'solved, runtime repair stays on'}")
|
|
return authored
|
|
|
|
|
|
# ------------------------------------------------------------------------ main
|
|
|
|
|
|
def main():
|
|
clear_scene()
|
|
print(f"Importing character {CHARACTER}")
|
|
# Normalise emissive-albedo materials FIRST. Blender honours
|
|
# KHR_materials_unlit and reads only base colour, so an unlit model with its
|
|
# texture in the emissive slot imports with no images at all and exports a
|
|
# black silhouette — there is no node graph left to repair afterwards.
|
|
import_any(gltf_fix.prepare(CHARACTER, tempfile.gettempdir()))
|
|
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
|
if not arms:
|
|
print("ERROR: character has no armature. Rig it first (see docs/3D_ASSET_PIPELINE.md).")
|
|
sys.exit(1)
|
|
arm = max(arms, key=lambda a: len(a.data.bones))
|
|
meshes = skinned_meshes(arm)
|
|
if not meshes:
|
|
print("ERROR: no skinned meshes bound to the armature")
|
|
sys.exit(1)
|
|
for o in [o for o in bpy.data.objects if o.type == "MESH" and o not in meshes]:
|
|
print(f"Dropping unskinned prop mesh: {o.name}")
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
print(f"Character: {len(arm.data.bones)} bones, {len(meshes)} meshes "
|
|
f"({sum(len(m.data.vertices) for m in meshes)} verts) — weights kept as authored")
|
|
|
|
strip_import_suffixes(arm, meshes)
|
|
for a in list(bpy.data.actions):
|
|
bpy.data.actions.remove(a) # the character's own clip is not ours
|
|
|
|
roles = RigRoles(arm)
|
|
missing = roles.missing_core()
|
|
if missing:
|
|
print(f"ERROR: could not identify these bones on the character rig: {missing}")
|
|
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, roles)
|
|
fix_unlit_materials(meshes)
|
|
roles = RigRoles(arm) # rest positions moved; re-read
|
|
|
|
print("\nCharacter rig:\n" + roles.describe())
|
|
|
|
if not arm.animation_data:
|
|
arm.animation_data_create()
|
|
for track in list(arm.animation_data.nla_tracks):
|
|
arm.animation_data.nla_tracks.remove(track)
|
|
|
|
merged = 0
|
|
kept_clips = set()
|
|
all_library_objects = []
|
|
primary_mapping = {}
|
|
library_specs = (
|
|
("_library.glb", LIBRARY_CLIP_MAP),
|
|
("_library_v2.glb", LIBRARY_V2_CLIP_MAP),
|
|
("_directional.glb", DIRECTIONAL_CLIP_MAP),
|
|
("_mesh2motion.glb", MESH2MOTION_CLIP_MAP),
|
|
("_cmu_locomotion.glb", CMU_CLIP_MAP),
|
|
("_wallrun.glb", WALLRUN_CLIP_MAP),
|
|
)
|
|
found_library = False
|
|
for filename, clip_map in library_specs:
|
|
library = os.path.join(ANIM_DIR, filename)
|
|
if not os.path.exists(library):
|
|
continue
|
|
found_library = True
|
|
before = set(bpy.data.objects)
|
|
before_actions = set(bpy.data.actions)
|
|
bpy.ops.import_scene.gltf(filepath=library)
|
|
new_objects = [o for o in bpy.data.objects if o not in before]
|
|
all_library_objects.extend(new_objects)
|
|
src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None)
|
|
if not src_arm:
|
|
print(f"ERROR: no armature in animation library {filename}")
|
|
sys.exit(1)
|
|
src_roles = RigRoles(src_arm)
|
|
mapping = build_map(src_roles, roles, OVERRIDES)
|
|
if not primary_mapping:
|
|
primary_mapping = mapping
|
|
print(f"\n{filename} rig:\n" + src_roles.describe())
|
|
print(f"Driving {len(mapping)} of {len(arm.data.bones)} bones; "
|
|
f"{len(arm.data.bones) - len(mapping)} left free.")
|
|
|
|
yaw = facing_correction(src_arm, src_roles, arm, roles)
|
|
src_h = (
|
|
src_arm.matrix_world
|
|
@ src_arm.data.bones[src_roles.hips].matrix_local
|
|
).translation.z
|
|
tgt_h = (
|
|
arm.matrix_world @ arm.data.bones[roles.hips].matrix_local
|
|
).translation.z
|
|
scale = tgt_h / src_h if src_h > 1e-5 else 1.0
|
|
print(
|
|
f"Hips height: library {src_h:.3f} m, "
|
|
f"character {tgt_h:.3f} m (scale {scale:.3f})"
|
|
)
|
|
|
|
for action in [a for a in bpy.data.actions if a not in before_actions]:
|
|
mapped = clip_map.get(action.name.split(".")[0])
|
|
if not mapped:
|
|
continue
|
|
specs = [(mapped, 0.0)] if isinstance(mapped, str) else mapped
|
|
for spec in specs:
|
|
clip, travel_yaw_degrees = spec[:2]
|
|
suffix = (
|
|
f" (lower-body heading {travel_yaw_degrees:+.0f} deg)"
|
|
if abs(travel_yaw_degrees) > 1e-4 else ""
|
|
)
|
|
print(f" {action.name} -> {clip}{suffix}")
|
|
baked = retarget_clip(
|
|
src_arm,
|
|
src_roles,
|
|
arm,
|
|
roles,
|
|
mapping,
|
|
action,
|
|
clip,
|
|
yaw,
|
|
scale,
|
|
travel_yaw_degrees,
|
|
)
|
|
add_nla_clip(arm, baked, clip)
|
|
kept_clips.add(clip)
|
|
merged += 1
|
|
if not found_library:
|
|
print(f"ERROR: no animation libraries found in {ANIM_DIR}")
|
|
sys.exit(1)
|
|
if merged == 0:
|
|
print("ERROR: no clips retargeted")
|
|
sys.exit(1)
|
|
|
|
# Drop anything animated that we did not put here.
|
|
#
|
|
# Clearing bpy.data.actions before the library import is not enough: a
|
|
# character can carry its own clip in an NLA track that survives, and it is
|
|
# then exported alongside ours. `hikari` shipped two — " Girl|Loli Anime
|
|
# GirlAction" — which are keyed for a rig that no longer exists after the
|
|
# retarget, so they export as frozen rest-pose statues and the build fails a
|
|
# check that is right to fail it.
|
|
# Across EVERY object, not just the armature we retargeted onto. The export
|
|
# runs in NLA_TRACKS mode, so anything sitting in an NLA track anywhere in
|
|
# the file ships — including a second armature the model happened to carry,
|
|
# which is where hikari's two were hiding.
|
|
def _strip_foreign(ad):
|
|
if ad is None:
|
|
return
|
|
for track in list(ad.nla_tracks):
|
|
if not any(st.action and st.action.name in kept_clips
|
|
for st in track.strips):
|
|
ad.nla_tracks.remove(track)
|
|
if ad.action is not None and ad.action.name not in kept_clips:
|
|
ad.action = None
|
|
|
|
for obj in list(bpy.data.objects):
|
|
_strip_foreign(obj.animation_data)
|
|
if obj.type == "MESH" and obj.data.shape_keys:
|
|
_strip_foreign(obj.data.shape_keys.animation_data)
|
|
for a in list(bpy.data.actions):
|
|
if a.name not in kept_clips:
|
|
a.use_fake_user = False
|
|
bpy.data.actions.remove(a)
|
|
print(f"Kept {len(kept_clips)} clips; dropped everything else")
|
|
|
|
for o in all_library_objects:
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
|
|
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
|
sidecar = os.path.splitext(OUTPUT)[0] + ".rig.json"
|
|
info = describe_rig(arm, roles, primary_mapping, meshes)
|
|
with open(sidecar, "w", encoding="utf-8") as f:
|
|
json.dump(info, f, indent=2)
|
|
print(f"Wrote {os.path.basename(sidecar)}: {len(info['chains'])} cloth chains, "
|
|
f"{len(info['twist'])} twist bones, {len(info['colliders'])} leg colliders")
|
|
|
|
print(f"Merged {merged} clips. Exporting {OUTPUT}")
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=OUTPUT,
|
|
export_format="GLB",
|
|
export_yup=True,
|
|
export_apply=False,
|
|
export_skins=True,
|
|
export_animations=True,
|
|
export_animation_mode="NLA_TRACKS",
|
|
# Export ONLY the bones the clips actually key. Baking every bone
|
|
# writes rest-pose tracks for the skirt and hair too, which triples the
|
|
# clip data and — worse — has the AnimationPlayer overwrite exactly the
|
|
# bones the spring solver is meant to own.
|
|
export_bake_animation=False,
|
|
export_optimize_animation_size=True,
|
|
# "keep_anim_armature" is what forces a track onto every bone whether
|
|
# or not the clip touches it. Off, so the skirt and hair export with no
|
|
# tracks at all and belong entirely to the spring solver.
|
|
export_optimize_animation_keep_anim_armature=False,
|
|
)
|
|
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
|
|
cls = spring_class(bone.name)
|
|
if cls is None or cls == "hair":
|
|
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()
|