Feat/14 movement overhaul #20
Binary file not shown.
@@ -20,7 +20,8 @@ menu dropdown, fully animated in first AND third person, synced in multiplayer.
|
|||||||
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb`
|
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb`
|
||||||
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
|
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
|
||||||
mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to
|
mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to
|
||||||
this library's own skeleton, so clips apply with zero retargeting. To use a
|
this library's skeleton (fitting the arm bones to the model's actual pose),
|
||||||
|
and `merge_animations.py` retargets each clip onto that fitted rest. To use a
|
||||||
different/larger set instead, see "Swapping the animation library" below.
|
different/larger set instead, see "Swapping the animation library" below.
|
||||||
|
|
||||||
## The pipeline, step by step
|
## The pipeline, step by step
|
||||||
@@ -31,8 +32,8 @@ Each step is also runnable on its own:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) |
|
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) |
|
||||||
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license/attribution JSON into `assets/characters/incoming/` |
|
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license/attribution JSON into `assets/characters/incoming/` |
|
||||||
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits a Mixamo-named skeleton, binds automatic weights |
|
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits the skeleton (incl. arm bones) to the mesh, binds smooth nearest-bone weights |
|
||||||
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Merges the shared clip library, strips root motion, canonical names |
|
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Retargets the clip library onto the fitted rest, strips root motion, canonical names |
|
||||||
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` |
|
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` |
|
||||||
|
|
||||||
`tools/pipeline.py` chains all of it. Useful flags:
|
`tools/pipeline.py` chains all of it. Useful flags:
|
||||||
|
|||||||
+93
-13
@@ -127,11 +127,58 @@ def mesh_slice_width(obj, y_frac, height, min_z):
|
|||||||
return max_x
|
return max_x
|
||||||
|
|
||||||
|
|
||||||
|
def fit_arms_to_mesh(arm, mesh):
|
||||||
|
"""Rotate each arm bone chain to lie along the mesh's actual arm direction.
|
||||||
|
|
||||||
|
Sketchfab humanoids are usually modelled in an A-pose (arms angled down),
|
||||||
|
but the library skeleton rests in a T-pose (arms horizontal). If we bind the
|
||||||
|
A-pose mesh to T-pose arm bones, bone-heat weighting fails (bones sit outside
|
||||||
|
the arms) and the animations shove the already-drooped arms across the body.
|
||||||
|
|
||||||
|
Reorienting the arm bones down the real arm axis makes the rest pose fit the
|
||||||
|
mesh, so bone-heat succeeds. The animations are then retargeted from the
|
||||||
|
library's T-pose rest to this fitted rest in merge_animations.py.
|
||||||
|
"""
|
||||||
|
mw = mesh.matrix_world
|
||||||
|
verts = [mw @ v.co for v in mesh.data.vertices]
|
||||||
|
bpy.context.view_layer.objects.active = arm
|
||||||
|
bpy.ops.object.mode_set(mode="EDIT")
|
||||||
|
eb = arm.data.edit_bones
|
||||||
|
for side, sx in (("L", 1.0), ("R", -1.0)):
|
||||||
|
chain = [eb.get(f"DEF-upper_arm.{side}"), eb.get(f"DEF-forearm.{side}"),
|
||||||
|
eb.get(f"DEF-hand.{side}")]
|
||||||
|
chain = [b for b in chain if b]
|
||||||
|
if not chain:
|
||||||
|
continue
|
||||||
|
shoulder = chain[0].head.copy()
|
||||||
|
# Hand tip = farthest mesh vertex to this side, near arm height.
|
||||||
|
arm_z = shoulder.z
|
||||||
|
cand = [v for v in verts if (v.x * sx) > abs(shoulder.x) * 0.8
|
||||||
|
and abs(v.z - arm_z) < 0.35]
|
||||||
|
if not cand:
|
||||||
|
continue
|
||||||
|
hand_tip = max(cand, key=lambda v: v.x * sx)
|
||||||
|
cur = chain[-1].tail - shoulder
|
||||||
|
tgt = hand_tip - shoulder
|
||||||
|
if cur.length < 1e-4 or tgt.length < 1e-4:
|
||||||
|
continue
|
||||||
|
rot = cur.normalized().rotation_difference(tgt.normalized())
|
||||||
|
# Rotate the whole chain about the shoulder joint.
|
||||||
|
for b in chain:
|
||||||
|
b.head = shoulder + rot @ (b.head - shoulder)
|
||||||
|
b.tail = shoulder + rot @ (b.tail - shoulder)
|
||||||
|
sh = eb.get(f"DEF-shoulder.{side}")
|
||||||
|
if sh:
|
||||||
|
sh.tail = chain[0].head
|
||||||
|
bpy.ops.object.mode_set(mode="OBJECT")
|
||||||
|
print("Fitted arm bones to mesh A-pose")
|
||||||
|
|
||||||
|
|
||||||
def import_library_armature(height):
|
def import_library_armature(height):
|
||||||
"""Import the animation library's armature as the rig template.
|
"""Import the animation library's armature as the rig template.
|
||||||
|
|
||||||
Rigging to the exact skeleton the clips were authored on means zero
|
Rigging to the exact skeleton the clips were authored on means clips need
|
||||||
retargeting: merge_animations.py can apply every clip verbatim.
|
only a rest-pose retarget (handled in merge_animations.py).
|
||||||
"""
|
"""
|
||||||
before = set(bpy.data.objects)
|
before = set(bpy.data.objects)
|
||||||
before_actions = set(bpy.data.actions)
|
before_actions = set(bpy.data.actions)
|
||||||
@@ -230,26 +277,42 @@ def _count_weighted_verts(mesh):
|
|||||||
return sum(1 for v in mesh.data.vertices if len(v.groups) > 0)
|
return sum(1 for v in mesh.data.vertices if len(v.groups) > 0)
|
||||||
|
|
||||||
|
|
||||||
def rigid_nearest_bone_weights(mesh, arm):
|
# Bones that must never deform the mesh: the root/master and any rig control
|
||||||
"""Assign every vertex fully to its nearest deform bone.
|
# or mechanism bones. A deform-flagged root runs up the body centre-line, so
|
||||||
|
# without this it captures inner-leg/heel verts and stretches them to centre.
|
||||||
|
_NON_DEFORM_HINTS = ("root", "master", "mch-", "mch_", "ctrl", "org-", "-ik",
|
||||||
|
"_ik", "pole", "target", "properties")
|
||||||
|
|
||||||
A guaranteed-to-work fallback when bone-heat weighting fails (common on
|
|
||||||
imported meshes with layered hair/clothing). Produces rigid skinning — no
|
def _deform_segments(arm):
|
||||||
smooth falloff at joints — but the character follows the skeleton and every
|
|
||||||
animation plays. Good enough for a fast-paced shooter at gameplay distance.
|
|
||||||
"""
|
|
||||||
# Build (name, head, tail) for deform bones in world space.
|
|
||||||
segs = []
|
segs = []
|
||||||
for b in arm.data.bones:
|
for b in arm.data.bones:
|
||||||
if not b.use_deform:
|
if not b.use_deform:
|
||||||
continue
|
continue
|
||||||
|
if any(h in b.name.lower() for h in _NON_DEFORM_HINTS):
|
||||||
|
continue
|
||||||
head = arm.matrix_world @ b.head_local
|
head = arm.matrix_world @ b.head_local
|
||||||
tail = arm.matrix_world @ b.tail_local
|
tail = arm.matrix_world @ b.tail_local
|
||||||
segs.append((b.name, head, tail))
|
segs.append((b.name, head, tail))
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def rigid_nearest_bone_weights(mesh, arm):
|
||||||
|
"""Weight each vertex to its nearest deform bones (distance-falloff blend).
|
||||||
|
|
||||||
|
A guaranteed-to-work substitute when bone-heat weighting fails — which it
|
||||||
|
does on most imported characters, whose joined hair/clothing/body meshes
|
||||||
|
have the interior and overlapping geometry the heat solver chokes on.
|
||||||
|
|
||||||
|
Each vertex is blended across its nearest few bone segments with an
|
||||||
|
inverse-distance falloff, so joints deform smoothly instead of tearing.
|
||||||
|
Control/root bones are excluded (see _NON_DEFORM_HINTS) so the mesh follows
|
||||||
|
real limbs, not the centre-line master bone.
|
||||||
|
"""
|
||||||
|
segs = _deform_segments(arm)
|
||||||
if not segs:
|
if not segs:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Fresh vertex groups.
|
|
||||||
for vg in list(mesh.vertex_groups):
|
for vg in list(mesh.vertex_groups):
|
||||||
mesh.vertex_groups.remove(vg)
|
mesh.vertex_groups.remove(vg)
|
||||||
groups = {name: mesh.vertex_groups.new(name=name) for name, _, _ in segs}
|
groups = {name: mesh.vertex_groups.new(name=name) for name, _, _ in segs}
|
||||||
@@ -260,11 +323,19 @@ def rigid_nearest_bone_weights(mesh, arm):
|
|||||||
t = 0.0 if denom == 0 else max(0.0, min(1.0, (p - a).dot(ab) / denom))
|
t = 0.0 if denom == 0 else max(0.0, min(1.0, (p - a).dot(ab) / denom))
|
||||||
return (p - (a + ab * t)).length
|
return (p - (a + ab * t)).length
|
||||||
|
|
||||||
|
K = 4 # blend across up to this many nearest bones
|
||||||
|
FALLOFF = 3.0 # higher = tighter to the single nearest bone
|
||||||
mw = mesh.matrix_world
|
mw = mesh.matrix_world
|
||||||
for v in mesh.data.vertices:
|
for v in mesh.data.vertices:
|
||||||
p = mw @ v.co
|
p = mw @ v.co
|
||||||
best_name = min(segs, key=lambda s: dist_to_seg(p, s[1], s[2]))[0]
|
dists = sorted(((dist_to_seg(p, s[1], s[2]), s[0]) for s in segs),
|
||||||
groups[best_name].add([v.index], 1.0, "REPLACE")
|
key=lambda d: d[0])[:K]
|
||||||
|
nearest = max(dists[0][0], 1e-5)
|
||||||
|
# Inverse-distance weights, relative to the nearest bone.
|
||||||
|
raw = [((nearest / max(d, 1e-5)) ** FALLOFF, name) for d, name in dists]
|
||||||
|
total = sum(w for w, _ in raw)
|
||||||
|
for w, name in raw:
|
||||||
|
groups[name].add([v.index], w / total, "REPLACE")
|
||||||
return _count_weighted_verts(mesh)
|
return _count_weighted_verts(mesh)
|
||||||
|
|
||||||
|
|
||||||
@@ -357,15 +428,24 @@ def main():
|
|||||||
height = TARGET_HEIGHT
|
height = TARGET_HEIGHT
|
||||||
|
|
||||||
arm = None
|
arm = None
|
||||||
|
from_library = False
|
||||||
if LIBRARY:
|
if LIBRARY:
|
||||||
print(f"Using animation library skeleton: {LIBRARY}")
|
print(f"Using animation library skeleton: {LIBRARY}")
|
||||||
arm = import_library_armature(height)
|
arm = import_library_armature(height)
|
||||||
|
from_library = arm is not None
|
||||||
if not arm:
|
if not arm:
|
||||||
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
|
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
|
||||||
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
|
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
|
||||||
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
|
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
|
||||||
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
|
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
|
||||||
|
|
||||||
|
# Fit the arm bones to the mesh's real (usually A-pose) arm direction so
|
||||||
|
# bone-heat weighting fits and the rest pose matches the geometry. The
|
||||||
|
# library-clip retarget in merge_animations.py compensates for the
|
||||||
|
# rest-pose change.
|
||||||
|
if from_library:
|
||||||
|
fit_arms_to_mesh(arm, mesh)
|
||||||
|
|
||||||
bind_mesh_to_armature(mesh, arm)
|
bind_mesh_to_armature(mesh, arm)
|
||||||
|
|
||||||
print(f"Exporting {OUTPUT}...")
|
print(f"Exporting {OUTPUT}...")
|
||||||
|
|||||||
+82
-14
@@ -230,8 +230,68 @@ def _add_clip_track(target_arm, action, clip_name):
|
|||||||
action.use_fake_user = True
|
action.use_fake_user = True
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_action(obj, action):
|
||||||
|
"""Assign an action to an object across Blender versions (slotted actions)."""
|
||||||
|
if not obj.animation_data:
|
||||||
|
obj.animation_data_create()
|
||||||
|
obj.animation_data.action = action
|
||||||
|
try:
|
||||||
|
slots = action.slots
|
||||||
|
if len(slots):
|
||||||
|
obj.animation_data.action_slot = slots[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def retarget_action(target_arm, src_arm, src_action, clip_name):
|
||||||
|
"""Retarget one source clip onto the character rig via world-space
|
||||||
|
constraint baking.
|
||||||
|
|
||||||
|
The character rig's arms were fitted to the mesh's A-pose (autorig.py), so
|
||||||
|
its rest differs from the library's T-pose. Copying each bone's WORLD
|
||||||
|
orientation (not its local channel) reproduces the library's actual motion
|
||||||
|
regardless of that rest difference, then baking captures it as a clean
|
||||||
|
action on the character rig.
|
||||||
|
"""
|
||||||
|
_assign_action(src_arm, src_action)
|
||||||
|
fr0 = int(src_action.frame_range[0])
|
||||||
|
fr1 = int(src_action.frame_range[1])
|
||||||
|
|
||||||
|
for pb in target_arm.pose.bones:
|
||||||
|
if pb.name not in src_arm.pose.bones:
|
||||||
|
continue
|
||||||
|
cr = pb.constraints.new("COPY_ROTATION")
|
||||||
|
cr.target = src_arm
|
||||||
|
cr.subtarget = pb.name
|
||||||
|
cr.target_space = "WORLD"
|
||||||
|
cr.owner_space = "WORLD"
|
||||||
|
if pb.name.lower().endswith("hips"):
|
||||||
|
cl = pb.constraints.new("COPY_LOCATION")
|
||||||
|
cl.target = src_arm
|
||||||
|
cl.subtarget = pb.name
|
||||||
|
cl.target_space = "WORLD"
|
||||||
|
cl.owner_space = "WORLD"
|
||||||
|
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
target_arm.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = target_arm
|
||||||
|
if target_arm.animation_data:
|
||||||
|
target_arm.animation_data.action = None
|
||||||
|
|
||||||
|
bpy.ops.nla.bake(frame_start=fr0, frame_end=fr1, step=1,
|
||||||
|
only_selected=False, visual_keying=True,
|
||||||
|
clear_constraints=True, clear_parents=False,
|
||||||
|
use_current_action=True, bake_types={"POSE"})
|
||||||
|
|
||||||
|
baked = target_arm.animation_data.action
|
||||||
|
if STRIP_ROOT_MOTION:
|
||||||
|
strip_hips_horizontal(baked)
|
||||||
|
return baked
|
||||||
|
|
||||||
|
|
||||||
def merge_from_library(target_arm, target_hips, library_path):
|
def merge_from_library(target_arm, target_hips, library_path):
|
||||||
"""Merge clips from a single multi-clip library GLB (same skeleton)."""
|
"""Retarget every mapped clip from a single multi-clip library GLB onto the
|
||||||
|
character rig (whose arm rest was fitted to the mesh)."""
|
||||||
print(f"Using multi-clip library: {library_path}")
|
print(f"Using multi-clip library: {library_path}")
|
||||||
before = set(bpy.data.objects)
|
before = set(bpy.data.objects)
|
||||||
before_actions = set(bpy.data.actions)
|
before_actions = set(bpy.data.actions)
|
||||||
@@ -239,31 +299,39 @@ def merge_from_library(target_arm, target_hips, library_path):
|
|||||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||||
new_actions = [a for a in bpy.data.actions if a not in before_actions]
|
new_actions = [a for a in bpy.data.actions if a not in before_actions]
|
||||||
src_arm = find_armature(new_objects)
|
src_arm = find_armature(new_objects)
|
||||||
|
if not src_arm:
|
||||||
|
print("ERROR: no armature in animation library")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
ratio = 1.0
|
# Match the library rig's height to the character so hips bob / foot planting
|
||||||
if src_arm:
|
# translate correctly under the world-space location copy.
|
||||||
ratio = target_hips / max(hips_height(src_arm), 1e-6)
|
src_hips = hips_height(src_arm)
|
||||||
print(f"Location scale ratio: {ratio:.3f}")
|
if src_hips > 1e-4:
|
||||||
|
s = target_hips / src_hips
|
||||||
|
src_arm.scale = (s, s, s)
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
src_arm.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = src_arm
|
||||||
|
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||||
|
|
||||||
merged = 0
|
merged = 0
|
||||||
keep: list = []
|
keep = []
|
||||||
for action in new_actions:
|
for action in new_actions:
|
||||||
base = action.name.split(".")[0] # tolerate .001 suffixes
|
base = action.name.split(".")[0] # tolerate .001 suffixes
|
||||||
clip_name = LIBRARY_CLIP_MAP.get(base)
|
clip_name = LIBRARY_CLIP_MAP.get(base)
|
||||||
if not clip_name:
|
if not clip_name:
|
||||||
continue
|
continue
|
||||||
print(f"--- {base} -> '{clip_name}'")
|
print(f"--- {base} -> '{clip_name}' (retarget bake)")
|
||||||
scale_location_curves(action, ratio)
|
baked = retarget_action(target_arm, src_arm, action, clip_name)
|
||||||
if STRIP_ROOT_MOTION:
|
target_arm.animation_data.action = None
|
||||||
strip_hips_horizontal(action)
|
_add_clip_track(target_arm, baked, clip_name)
|
||||||
_add_clip_track(target_arm, action, clip_name)
|
keep.append(baked)
|
||||||
keep.append(action)
|
|
||||||
merged += 1
|
merged += 1
|
||||||
|
|
||||||
for o in new_objects:
|
for o in new_objects:
|
||||||
bpy.data.objects.remove(o, do_unlink=True)
|
bpy.data.objects.remove(o, do_unlink=True)
|
||||||
for a in new_actions:
|
for a in list(bpy.data.actions):
|
||||||
if a not in keep:
|
if a not in keep and a in new_actions:
|
||||||
bpy.data.actions.remove(a)
|
bpy.data.actions.remove(a)
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user