feat: implement automated 3D character pipeline with retargeting and rig management tools
This commit is contained in:
@@ -0,0 +1,887 @@
|
||||
#!/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 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
|
||||
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"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 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):
|
||||
"""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()
|
||||
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
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 > 0.25:
|
||||
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 _leg_colliders(arm, roles, owned):
|
||||
"""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. The 70th percentile of how far a
|
||||
leg bone's own vertices sit from its axis measures the actual limb.
|
||||
"""
|
||||
out = []
|
||||
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
|
||||
pts = owned.get(name, [])
|
||||
if len(pts) < 8:
|
||||
continue
|
||||
radii = sorted(_seg_distance(p, a, b) for p in pts)
|
||||
out.append({
|
||||
"bone": name, "child": child,
|
||||
"radius": round(radii[int(len(radii) * 0.7)], 4),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
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 any(t in SPRING_CLASSES for t in tokens(b.name))}
|
||||
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 = []
|
||||
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)])
|
||||
fallback = t
|
||||
chains.append({
|
||||
"class": next((t for t in tokens(path[0])
|
||||
if t in SPRING_CLASSES), "cloth"),
|
||||
"root_parent": bone.parent.name if bone.parent else None,
|
||||
"bones": path,
|
||||
"tips": tips,
|
||||
})
|
||||
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})
|
||||
return {"roles": roles_out, "chains": chains, "twist": twist,
|
||||
"colliders": _leg_colliders(arm, roles, owned),
|
||||
"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)
|
||||
flatten_and_scale(arm, meshes, TARGET_HEIGHT)
|
||||
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
|
||||
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)
|
||||
merged += 1
|
||||
if merged == 0:
|
||||
print("ERROR: no clips retargeted")
|
||||
sys.exit(1)
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user