A character has arrived as separate body, garment and hair meshes since the
pipeline stopped joining them — but nothing recorded which was which, so every
system downstream re-guessed from the material. That guess ("untextured and
nearly black means ink") had already rendered the mannequin's flat yellow body
as a black silhouette once.
The question is answerable once, at build time, where the mesh, the weights and
the skeleton are all in hand. tools/surface_map.py answers it three ways, in
order of how much it trusts them: the material name, which on VRoid exports is
formal and on hand-authored models is still explicit; the weights, which are
decisive when the name says nothing — a surface pulled by the skirt chain is a
skirt whatever it is called; and the material flags, which catch the model's own
line-work. The answer goes in the rig sidecar next to the roles and the chains,
and SkinSurfaces reads it.
All eighteen of Taila's surfaces, and every surface of the other five skins,
now resolve from the table with nothing falling through to the heuristic
(debug/surface_class_check.gd). The heuristic stays as the fallback, which is
the one job it was ever right for.
What that buys immediately is per-class art direction, which was impossible
while every surface had to take numbers calibrated on skin. Hair takes a much
thinner line — at the body's 5 mm each strand's hull swallows its neighbour and
the head reads as a solid dark cap. Cloth takes a heavier line and a crisper
terminator, because a garment's silhouette is most of what separates a character
from the background at range. Accessories take the heaviest. `body` is unchanged
on purpose, so the look this was all calibrated against does not move.
That required moving the outline from the instance to the surface: Miku's body,
face and hair are three surfaces of ONE mesh, so an instance-wide overlay could
only ever give all three the same weight.
Two things found on the way, fixed here because they are one line each: the
surface classifier skips meshes with no vertex groups, which drops the stray
42-vertex Icosphere that rides inside every shipped skin — two older tools
already skipped it by spelling its name — and load_model now clears _rig_info,
which a model with no skeleton used to inherit from the last character loaded.
Co-Authored-By: Claude Opus 5 <[email protected]>
1502 lines
63 KiB
Python
1502 lines
63 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
|
|
|
|
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": "Dash",
|
|
"Death01": "Death",
|
|
"Hit_Chest": "Hit",
|
|
"Dance_Loop": "Dance",
|
|
"Swim_Fwd_Loop": "Grapple",
|
|
"Pistol_Idle_Loop": "PistolIdle",
|
|
"Pistol_Shoot": "PistolShoot",
|
|
"Pistol_Reload": "PistolReload",
|
|
"Sword_Attack": "Throw",
|
|
}
|
|
|
|
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):
|
|
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()
|
|
|
|
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)
|
|
|
|
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
|
|
pose_rot = (smw @ src_eval.pose.bones[src_name].matrix).to_quaternion()
|
|
delta = pose_rot @ src_rest_rot[src_name].inverted()
|
|
desired[tgt_name] = (yaw @ delta @ yaw_inv) @ tgt_rest_rot[tgt_name]
|
|
|
|
hips_head = None
|
|
if src_hips in src_eval.pose.bones:
|
|
moved = (smw @ src_eval.pose.bones[src_hips].matrix).translation
|
|
d = yaw @ ((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
|
|
authored = bleeding / total < 0.02 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
|
|
|
|
library = os.path.join(ANIM_DIR, "_library.glb")
|
|
if not os.path.exists(library):
|
|
print(f"ERROR: animation library not found: {library}")
|
|
sys.exit(1)
|
|
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]
|
|
src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None)
|
|
if not src_arm:
|
|
print("ERROR: no armature in the animation library")
|
|
sys.exit(1)
|
|
src_roles = RigRoles(src_arm)
|
|
|
|
mapping = build_map(src_roles, roles, OVERRIDES)
|
|
print("\nLibrary rig:\n" + src_roles.describe())
|
|
print("\nCharacter rig:\n" + roles.describe())
|
|
print(f"\nDriving {len(mapping)} of {len(arm.data.bones)} bones from the library; "
|
|
f"{len(arm.data.bones) - len(mapping)} left free for secondary motion.")
|
|
|
|
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, character {tgt_h:.3f} m (scale {scale:.3f})")
|
|
|
|
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()
|
|
for action in [a for a in bpy.data.actions if a not in before_actions]:
|
|
clip = LIBRARY_CLIP_MAP.get(action.name.split(".")[0])
|
|
if not clip:
|
|
continue
|
|
print(f" {action.name} -> {clip}")
|
|
baked = retarget_clip(src_arm, src_roles, arm, roles, mapping, action,
|
|
clip, yaw, scale)
|
|
add_nla_clip(arm, baked, clip)
|
|
kept_clips.add(clip)
|
|
merged += 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 new_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, 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()
|