Feat/14 movement overhaul #20

Merged
Dotts merged 86 commits from feat/14-movement-overhaul into main 2026-07-17 10:44:23 -07:00
11 changed files with 385 additions and 90 deletions
Showing only changes of commit 64bbbf93c6 - Show all commits
Binary file not shown.
@@ -0,0 +1,15 @@
-------------------------------------------------------
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
------------------------------------------------------
Models by @Quaternius
Consider supporting me on Patreon!
https://www.patreon.com/quaternius
-------------------------------------------------------
Join the Discord Server:
https://discord.gg/vJqnRUYRfT
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Hatsune Miku",
"uid": "34f3e7daa4c64c8a8000ae7f90b01ceb",
"author": "Tigerar1",
"author_url": "https://sketchfab.com/allanromanreyes",
"license": "Free Standard",
"license_slug": "free-st",
"source_url": "https://sketchfab.com/3d-models/hatsune-miku-34f3e7daa4c64c8a8000ae7f90b01ceb"
}
+8 -1
View File
@@ -3,9 +3,16 @@
{ {
"id": "miku", "id": "miku",
"name": "Miku", "name": "Miku",
"description": "Hatsune Miku Virtual Idol", "description": "Hatsune Miku \u2014 Virtual Idol",
"model": "res://assets/characters/skins/miku_proper_anim.glb", "model": "res://assets/characters/skins/miku_proper_anim.glb",
"unlocked": true "unlocked": true
},
{
"id": "miku_test",
"name": "Miku Test",
"description": "",
"model": "res://assets/characters/skins/miku_test.glb",
"unlocked": true
} }
] ]
} }
+27 -31
View File
@@ -17,9 +17,11 @@ menu dropdown, fully animated in first AND third person, synced in multiplayer.
Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named
`.sketchfab_token` in the project root (gitignored). `.sketchfab_token` in the project root (gitignored).
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`. 2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
3. **Animation library**fill `assets/characters/animations/` with one clip 3. **Animation library**already bundled. `assets/characters/animations/_library.glb`
per file on the Mixamo skeleton. This is done ONCE and reused for every is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
character forever. See "Building the animation library" below. 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
different/larger set instead, see "Swapping the animation library" below.
## The pipeline, step by step ## The pipeline, step by step
@@ -63,38 +65,32 @@ automation needs an alternative. In order of preference:
All roads lead to the same place: a GLB with Mixamo bone names, which is what All roads lead to the same place: a GLB with Mixamo bone names, which is what
`merge_animations.py` and the game expect. `merge_animations.py` and the game expect.
## Building the animation library (once) ## Swapping the animation library
The library lives in `assets/characters/animations/` — one clip per file, `merge_animations.py` supports two library layouts in
filename = clip name (`idle.fbx``Idle`, `crouch_walk.fbx``CrouchWalk`). `assets/characters/animations/`:
**Recommended: Mixamo clips.** Go to mixamo.com, pick the X Bot character, **A. Multi-clip library GLB (the bundled default).** If `_library.glb` exists,
download each clip as *FBX Binary, Without Skin, 30 fps* (use "In Place" its clips are merged and mapped through `LIBRARY_CLIP_MAP` in
variants where offered): `tools/merge_animations.py`. The bundled file is the CC0 Quaternius Universal
Animation Library (13 clips: Idle, Walk, Run, Sprint, Jump, Fall, Land,
CrouchIdle, CrouchWalk, Dash, Death, Hit, Dance). **Important:** `autorig.py`
rigs characters to *this GLB's own skeleton*, so if you replace `_library.glb`
you must re-rig characters against the new one (the pipeline does this
automatically). To pull in more of Quaternius's 120-clip set, drop in the Pro
`_library.glb` and add rows to `LIBRARY_CLIP_MAP`.
| File | Mixamo search | **B. Loose one-clip-per-file (Mixamo).** Delete `_library.glb` and instead add
|---|---| FBX/GLB files named by clip (`idle.fbx``Idle`, `crouch_walk.fbx`
| `idle.fbx` | Rifle Idle (or Idle) | `CrouchWalk`). Download from mixamo.com as *FBX Binary, Without Skin, 30 fps*
| `walk.fbx` | Walking (In Place) | ("In Place" variants). In this mode characters must be on the Mixamo skeleton
| `run.fbx` | Rifle Run / Fast Run (In Place) | (rig via Mixamo web / AccuRig and pass `--rigged`, or let the heuristic
| `sprint.fbx` | Sprint (In Place) | `build_mixamo_armature` fit one).
| `jump.fbx` | Jump |
| `fall.fbx` | Falling Idle |
| `crouch_idle.fbx` | Crouch Idle |
| `crouch_walk.fbx` | Crouch Walk (In Place) |
| `slide.fbx` | Running Slide |
| `wall_run.fbx` | (see note) Run variant works fine |
| `dash.fbx` | Sprint or Dive |
| `death.fbx` | Dying |
CC0 alternative: **Quaternius Universal Animation Library** (quaternius.com, Missing clips are fine either way: the game falls back along sensible chains
500+ clips, Mixamo-compatible skeleton) or **Kenney character assets** (`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see `CLIP_FALLBACKS` in
both free for commercial use, no attribution required. `characters/skinned_player_model.gd`). A model with just Idle/Walk/Run still
animates in every movement state.
Missing clips are fine: the game falls back along sensible chains
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see
`CLIP_FALLBACKS` in `characters/skinned_player_model.gd`). A model with just
Idle/Walk/Run still animates in every movement state.
## How it works in-game ## How it works in-game
+11
View File
@@ -0,0 +1,11 @@
# Python dependencies for tools/ (sketchfab_import.py, pipeline.py).
#
# Currently empty: these scripts only use the standard library
# (urllib, json, zipfile, argparse, subprocess). No third-party
# packages to install — this file exists so `pip install -r
# requirements.txt` works out of the box in a fresh venv.
#
# NOT covered here: tools/autorig.py and tools/merge_animations.py run
# inside Blender's own bundled Python via `blender --background
# --python ...`, not this venv. Their `bpy` / `mathutils` imports come
# from Blender itself and cannot be pip-installed.
+161 -14
View File
@@ -10,7 +10,15 @@ animation (or CC0 pack retargeted to Mixamo names) can be merged onto the
result with tools/merge_animations.py — rig once, reuse every animation. result with tools/merge_animations.py — rig once, reuse every animation.
Usage: Usage:
blender --background --python tools/autorig.py -- <input> <output.glb> [target_height] blender --background --python tools/autorig.py -- <input> <output.glb> [target_height] [library.glb]
Skeleton source, in order of preference:
1. If a 4th arg (or assets/characters/animations/_library.glb) exists, the
armature from that animation-library GLB is used as the template. This
guarantees every library clip fits the character perfectly — same rig,
no retargeting.
2. Otherwise a Mixamo-named skeleton is built from heuristics (works with
Mixamo-skeleton clip files instead).
If the input already contains an armature, it is kept as-is (bones are only If the input already contains an armature, it is kept as-is (bones are only
renamed to Mixamo convention when obvious matches exist) and the mesh is renamed to Mixamo convention when obvious matches exist) and the mesh is
@@ -35,6 +43,10 @@ if len(argv) < 2:
INPUT = argv[0] INPUT = argv[0]
OUTPUT = argv[1] OUTPUT = argv[1]
TARGET_HEIGHT = float(argv[2]) if len(argv) > 2 else 1.75 TARGET_HEIGHT = float(argv[2]) if len(argv) > 2 else 1.75
_default_library = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"assets", "characters", "animations", "_library.glb")
LIBRARY = argv[3] if len(argv) > 3 else (_default_library if os.path.exists(_default_library) else "")
# Mixamo bone names Godot/our animation library expects. # Mixamo bone names Godot/our animation library expects.
MIX = "mixamorig:" MIX = "mixamorig:"
@@ -115,6 +127,53 @@ def mesh_slice_width(obj, y_frac, height, min_z):
return max_x return max_x
def import_library_armature(height):
"""Import the animation library's armature as the rig template.
Rigging to the exact skeleton the clips were authored on means zero
retargeting: merge_animations.py can apply every clip verbatim.
"""
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]
arm = None
for o in new_objects:
if o.type == "ARMATURE":
arm = o
if not arm:
print("WARNING: no armature in library, falling back to heuristic skeleton")
for o in new_objects:
bpy.data.objects.remove(o, do_unlink=True)
return None
# Drop the library's mannequin mesh and all bundled actions — we only
# want the bare skeleton here; clips get merged in the next stage.
for o in new_objects:
if o is not arm:
bpy.data.objects.remove(o, do_unlink=True)
if arm.animation_data:
arm.animation_data_clear()
for a in [a for a in bpy.data.actions if a not in before_actions]:
bpy.data.actions.remove(a)
# Scale the rig uniformly so its height matches the character's.
bpy.context.view_layer.objects.active = arm
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
arm.location = (0, 0, 0)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
rig_top = max((arm.matrix_world @ b.head_local).z for b in arm.data.bones)
rig_top = max(rig_top, max((arm.matrix_world @ b.tail_local).z for b in arm.data.bones))
if rig_top > 0.01:
s = height / rig_top
arm.scale = (s, s, s)
bpy.ops.object.transform_apply(scale=True)
print(f"Library rig scaled by {s:.3f} to height {height:.2f}")
return arm
def build_mixamo_armature(height, min_z, shoulder_w, hip_w): def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
"""Create a Mixamo-named humanoid armature fitted to the mesh bounds. """Create a Mixamo-named humanoid armature fitted to the mesh bounds.
@@ -167,6 +226,80 @@ def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
return arm return arm
def _count_weighted_verts(mesh):
return sum(1 for v in mesh.data.vertices if len(v.groups) > 0)
def rigid_nearest_bone_weights(mesh, arm):
"""Assign every vertex fully to its nearest deform bone.
A guaranteed-to-work fallback when bone-heat weighting fails (common on
imported meshes with layered hair/clothing). Produces rigid skinning — no
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 = []
for b in arm.data.bones:
if not b.use_deform:
continue
head = arm.matrix_world @ b.head_local
tail = arm.matrix_world @ b.tail_local
segs.append((b.name, head, tail))
if not segs:
return 0
# Fresh vertex groups.
for vg in list(mesh.vertex_groups):
mesh.vertex_groups.remove(vg)
groups = {name: mesh.vertex_groups.new(name=name) for name, _, _ in segs}
def dist_to_seg(p, a, b):
ab = b - a
denom = ab.dot(ab)
t = 0.0 if denom == 0 else max(0.0, min(1.0, (p - a).dot(ab) / denom))
return (p - (a + ab * t)).length
mw = mesh.matrix_world
for v in mesh.data.vertices:
p = mw @ v.co
best_name = min(segs, key=lambda s: dist_to_seg(p, s[1], s[2]))[0]
groups[best_name].add([v.index], 1.0, "REPLACE")
return _count_weighted_verts(mesh)
def bind_mesh_to_armature(mesh, arm):
"""Bind mesh to armature robustly and in a form the glTF exporter skins.
1. Try Blender automatic (bone-heat) weights for smooth deformation.
2. If that assigns (almost) nothing, fall back to rigid nearest-bone.
3. Present the result as a parent_type='ARMATURE' relationship with NO
Armature modifier — the Blender 5.x glTF exporter only emits a skin
for that exact configuration (a lingering modifier yields skins:0).
"""
bpy.ops.object.select_all(action="DESELECT")
mesh.select_set(True)
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
weighted = _count_weighted_verts(mesh)
total = len(mesh.data.vertices)
if weighted < total * 0.5:
print(f"Automatic weights covered {weighted}/{total} verts — "
"falling back to rigid nearest-bone weights")
weighted = rigid_nearest_bone_weights(mesh, arm)
print(f"Bound mesh: {weighted}/{total} verts weighted")
# Normalize to the exporter-friendly form: parent_type=ARMATURE, no modifier.
for m in list(mesh.modifiers):
if m.type == "ARMATURE":
mesh.modifiers.remove(m)
if mesh.parent != arm:
mesh.parent = arm
mesh.parent_type = "ARMATURE"
def main(): def main():
clear_scene() clear_scene()
print(f"Importing {INPUT}...") print(f"Importing {INPUT}...")
@@ -186,7 +319,19 @@ def main():
if len(meshes) > 1: if len(meshes) > 1:
bpy.ops.object.join() bpy.ops.object.join()
mesh = bpy.context.active_object mesh = bpy.context.active_object
# Detach from the importer's hierarchy (Sketchfab wraps meshes in scaled/
# rotated empties) so the mesh sits in clean world space, then bake its
# own transform. Otherwise bind + normalization fight the parent transform.
bpy.ops.object.select_all(action="DESELECT")
mesh.select_set(True)
bpy.context.view_layer.objects.active = mesh
if mesh.parent:
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
for o in list(bpy.data.objects):
if o.type == "EMPTY":
bpy.data.objects.remove(o, do_unlink=True)
if armatures: if armatures:
print("Input already rigged — keeping existing skeleton, renaming bones") print("Input already rigged — keeping existing skeleton, renaming bones")
@@ -214,19 +359,17 @@ def main():
min_z = 0.0 min_z = 0.0
height = TARGET_HEIGHT height = TARGET_HEIGHT
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z) arm = None
hip_w = mesh_slice_width(mesh, 0.53, height, min_z) if LIBRARY:
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}") print(f"Using animation library skeleton: {LIBRARY}")
arm = import_library_armature(height)
if not arm:
shoulder_w = mesh_slice_width(mesh, 0.80, 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}")
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w) bind_mesh_to_armature(mesh, arm)
# Bind with automatic weights.
bpy.ops.object.select_all(action="DESELECT")
mesh.select_set(True)
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
print("Bound mesh with automatic weights")
print(f"Exporting {OUTPUT}...") print(f"Exporting {OUTPUT}...")
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True) os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
@@ -234,7 +377,11 @@ def main():
filepath=OUTPUT, filepath=OUTPUT,
export_format="GLB", export_format="GLB",
export_yup=True, export_yup=True,
export_apply=True, # NOTE: export_apply must stay False — applying modifiers on a skinned
# mesh bakes away the Armature modifier and drops the skin binding
# (glTF then exports bones as plain nodes with skins:0). Transforms are
# already applied in code before binding, so nothing is lost here.
export_apply=False,
export_animations=True, export_animations=True,
export_skins=True, export_skins=True,
) )
+145 -41
View File
@@ -6,12 +6,19 @@ Merges every animation clip from the shared animation library onto a rigged
character (Mixamo-compatible bone names) and exports one game-ready GLB whose character (Mixamo-compatible bone names) and exports one game-ready GLB whose
animations use the game's canonical clip names (Idle, Walk, Run, Jump, ...). animations use the game's canonical clip names (Idle, Walk, Run, Jump, ...).
Animation library: a directory of FBX/GLB files, ONE clip per file, all using Animation library — two supported layouts in <animations_dir>:
the Mixamo skeleton. Download once from Mixamo ("without skin") or use a CC0
pack — see docs/ASSET_SOURCES.md. Filenames map to canonical clip names: A. `_library.glb` — a single multi-clip library (e.g. Quaternius Universal
idle.fbx -> Idle Animation Library, CC0, committed at assets/characters/animations/).
run.fbx -> Run Clip names map through LIBRARY_CLIP_MAP below. The character must be
crouch_walk.fbx -> CrouchWalk rigged on this library's own skeleton (tools/autorig.py does this
automatically when _library.glb is present).
B. Loose FBX/GLB files, ONE clip per file, all on the Mixamo skeleton
(manual Mixamo downloads). Filenames map to canonical clip names:
idle.fbx -> Idle
run.fbx -> Run
crouch_walk.fbx -> CrouchWalk
Usage: Usage:
blender --background --python tools/merge_animations.py -- \ blender --background --python tools/merge_animations.py -- \
@@ -38,12 +45,55 @@ STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
MIX = "mixamorig:" MIX = "mixamorig:"
# Quaternius Universal Animation Library clip -> game canonical clip name.
# Unmapped clips are skipped to keep character GLBs 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",
}
def clear_scene(): def clear_scene():
bpy.ops.object.select_all(action="SELECT") bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete() bpy.ops.object.delete()
def action_fcurves(action):
"""Return an action's F-curves across Blender versions.
Blender 4.4+/5.x replaced `Action.fcurves` with the slotted-action system
(layers -> strips -> channelbags -> fcurves). This yields the curves either
way so the merge logic doesn't care which Blender it runs under.
"""
legacy = getattr(action, "fcurves", None)
if legacy is not None:
return list(legacy)
out = []
for layer in getattr(action, "layers", []):
for strip in layer.strips:
cbags = getattr(strip, "channelbags", None)
if cbags is not None:
for cbag in cbags:
out.extend(cbag.fcurves)
else:
for slot in getattr(action, "slots", []):
cbag = strip.channelbag(slot)
if cbag:
out.extend(cbag.fcurves)
return out
def to_pascal(stem): def to_pascal(stem):
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_")) return "".join(part.capitalize() for part in stem.replace("-", "_").split("_"))
@@ -76,7 +126,7 @@ CORE_BONES = {
def normalize_action_paths(action): def normalize_action_paths(action):
for fc in action.fcurves: for fc in action_fcurves(action):
if 'pose.bones["' in fc.data_path: if 'pose.bones["' in fc.data_path:
start = fc.data_path.index('"') + 1 start = fc.data_path.index('"') + 1
end = fc.data_path.index('"', start) end = fc.data_path.index('"', start)
@@ -88,7 +138,7 @@ def normalize_action_paths(action):
def hips_height(arm): def hips_height(arm):
for b in arm.data.bones: for b in arm.data.bones:
if b.name.endswith("Hips"): if b.name.lower().endswith("hips"):
return (arm.matrix_world @ b.head_local).z return (arm.matrix_world @ b.head_local).z
return 1.0 return 1.0
@@ -96,7 +146,7 @@ def hips_height(arm):
def scale_location_curves(action, ratio): def scale_location_curves(action, ratio):
if abs(ratio - 1.0) < 0.01: if abs(ratio - 1.0) < 0.01:
return return
for fc in action.fcurves: for fc in action_fcurves(action):
if fc.data_path.endswith(".location"): if fc.data_path.endswith(".location"):
for kp in fc.keyframe_points: for kp in fc.keyframe_points:
kp.co.y *= ratio kp.co.y *= ratio
@@ -105,13 +155,15 @@ def scale_location_curves(action, ratio):
def strip_hips_horizontal(action): def strip_hips_horizontal(action):
"""Zero hips X (side) and Y (forward) location so clips play in place. """Zero horizontal root motion so clips play in place (gameplay code
moves the body). Applies to hips AND any dedicated root bone; keeps the
Blender bone-local axes for a Y-up-exported Mixamo hips: X = side, vertical channel so bob/land weight survives.
Y = up (bone axis), Z = forward. We keep Y (vertical bob) and zero X/Z.
""" """
for fc in action.fcurves: for fc in action_fcurves(action):
if fc.data_path.endswith('.location') and "Hips" in fc.data_path and fc.array_index in (0, 2): if not fc.data_path.endswith(".location"):
continue
path_lower = fc.data_path.lower()
if ("hips" in path_lower or '"root"' in path_lower) and fc.array_index in (0, 2):
for kp in fc.keyframe_points: for kp in fc.keyframe_points:
kp.co.y = 0.0 kp.co.y = 0.0
kp.handle_left.y = 0.0 kp.handle_left.y = 0.0
@@ -144,9 +196,83 @@ def main():
target_arm.animation_data.nla_tracks.remove(track) target_arm.animation_data.nla_tracks.remove(track)
target_arm.animation_data.action = None target_arm.animation_data.action = None
library_path = os.path.join(ANIM_DIR, "_library.glb")
if os.path.exists(library_path):
merged = merge_from_library(target_arm, target_hips, library_path)
else:
merged = merge_from_files(target_arm, target_hips)
if merged == 0:
print("ERROR: no animations merged")
sys.exit(1)
print(f"Merged {merged} clips. Exporting {OUTPUT}...")
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_skins=True,
export_bake_animation=True,
)
print("Done.")
def _add_clip_track(target_arm, action, clip_name):
action.name = clip_name
track = target_arm.animation_data.nla_tracks.new()
track.name = clip_name
strip = track.strips.new(clip_name, 0, action)
strip.name = clip_name
track.mute = True
action.use_fake_user = True
def merge_from_library(target_arm, target_hips, library_path):
"""Merge clips from a single multi-clip library GLB (same skeleton)."""
print(f"Using multi-clip library: {library_path}")
before = set(bpy.data.objects)
before_actions = set(bpy.data.actions)
bpy.ops.import_scene.gltf(filepath=library_path)
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]
src_arm = find_armature(new_objects)
ratio = 1.0
if src_arm:
ratio = target_hips / max(hips_height(src_arm), 1e-6)
print(f"Location scale ratio: {ratio:.3f}")
merged = 0
keep: list = []
for action in new_actions:
base = action.name.split(".")[0] # tolerate .001 suffixes
clip_name = LIBRARY_CLIP_MAP.get(base)
if not clip_name:
continue
print(f"--- {base} -> '{clip_name}'")
scale_location_curves(action, ratio)
if STRIP_ROOT_MOTION:
strip_hips_horizontal(action)
_add_clip_track(target_arm, action, clip_name)
keep.append(action)
merged += 1
for o in new_objects:
bpy.data.objects.remove(o, do_unlink=True)
for a in new_actions:
if a not in keep:
bpy.data.actions.remove(a)
return merged
def merge_from_files(target_arm, target_hips):
"""Merge clips from loose one-clip-per-file FBX/GLB files."""
anim_files = sorted( anim_files = sorted(
f for f in os.listdir(ANIM_DIR) f for f in os.listdir(ANIM_DIR)
if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith(".") if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith((".", "_"))
) )
if not anim_files: if not anim_files:
print(f"ERROR: no animation files in {ANIM_DIR}") print(f"ERROR: no animation files in {ANIM_DIR}")
@@ -176,21 +302,14 @@ def main():
if not new_actions: if not new_actions:
print(" SKIP: no action found in file") print(" SKIP: no action found in file")
else: else:
action = max(new_actions, key=lambda a: len(a.fcurves)) action = max(new_actions, key=lambda a: len(action_fcurves(a)))
action.name = clip_name
normalize_action_paths(action) normalize_action_paths(action)
if src_arm: if src_arm:
ratio = target_hips / max(hips_height(src_arm), 1e-6) ratio = target_hips / max(hips_height(src_arm), 1e-6)
scale_location_curves(action, ratio) scale_location_curves(action, ratio)
if STRIP_ROOT_MOTION: if STRIP_ROOT_MOTION:
strip_hips_horizontal(action) strip_hips_horizontal(action)
_add_clip_track(target_arm, action, clip_name)
track = target_arm.animation_data.nla_tracks.new()
track.name = clip_name
strip = track.strips.new(clip_name, 0, action)
strip.name = clip_name
track.mute = True
action.use_fake_user = True
merged += 1 merged += 1
# Remove the imported helper objects (keep the action). # Remove the imported helper objects (keep the action).
@@ -200,22 +319,7 @@ def main():
if a.name != clip_name: if a.name != clip_name:
bpy.data.actions.remove(a) bpy.data.actions.remove(a)
if merged == 0: return merged
print("ERROR: no animations merged")
sys.exit(1)
print(f"Merged {merged} clips. Exporting {OUTPUT}...")
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_skins=True,
export_bake_animation=True,
)
print("Done.")
main() main()
+8 -2
View File
@@ -43,8 +43,14 @@ def get_token(cli_token: str | None) -> str:
return tok return tok
token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token") token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token")
if os.path.exists(token_file): if os.path.exists(token_file):
with open(token_file, "r", encoding="utf-8") as f: with open(token_file, "rb") as f:
tok = f.read().strip() raw = f.read()
# PowerShell's `echo`/`>` write UTF-16 with a BOM by default on
# Windows; tolerate that as well as plain UTF-8.
if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
tok = raw.decode("utf-16").strip()
else:
tok = raw.decode("utf-8-sig").strip()
if tok: if tok:
return tok return tok
print("ERROR: no Sketchfab API token found.") print("ERROR: no Sketchfab API token found.")