Feat/14 movement overhaul #20
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"
|
||||
}
|
||||
@@ -3,9 +3,16 @@
|
||||
{
|
||||
"id": "miku",
|
||||
"name": "Miku",
|
||||
"description": "Hatsune Miku — Virtual Idol",
|
||||
"description": "Hatsune Miku \u2014 Virtual Idol",
|
||||
"model": "res://assets/characters/skins/miku_proper_anim.glb",
|
||||
"unlocked": true
|
||||
},
|
||||
{
|
||||
"id": "miku_test",
|
||||
"name": "Miku Test",
|
||||
"description": "",
|
||||
"model": "res://assets/characters/skins/miku_test.glb",
|
||||
"unlocked": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+27
-31
@@ -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
|
||||
`.sketchfab_token` in the project root (gitignored).
|
||||
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
|
||||
3. **Animation library** — fill `assets/characters/animations/` with one clip
|
||||
per file on the Mixamo skeleton. This is done ONCE and reused for every
|
||||
character forever. See "Building the animation library" below.
|
||||
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb`
|
||||
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`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,
|
||||
filename = clip name (`idle.fbx` → `Idle`, `crouch_walk.fbx` → `CrouchWalk`).
|
||||
`merge_animations.py` supports two library layouts in
|
||||
`assets/characters/animations/`:
|
||||
|
||||
**Recommended: Mixamo clips.** Go to mixamo.com, pick the X Bot character,
|
||||
download each clip as *FBX Binary, Without Skin, 30 fps* (use "In Place"
|
||||
variants where offered):
|
||||
**A. Multi-clip library GLB (the bundled default).** If `_library.glb` exists,
|
||||
its clips are merged and mapped through `LIBRARY_CLIP_MAP` in
|
||||
`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 |
|
||||
|---|---|
|
||||
| `idle.fbx` | Rifle Idle (or Idle) |
|
||||
| `walk.fbx` | Walking (In Place) |
|
||||
| `run.fbx` | Rifle Run / Fast Run (In Place) |
|
||||
| `sprint.fbx` | Sprint (In Place) |
|
||||
| `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 |
|
||||
**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` →
|
||||
`CrouchWalk`). Download from mixamo.com as *FBX Binary, Without Skin, 30 fps*
|
||||
("In Place" variants). In this mode characters must be on the Mixamo skeleton
|
||||
(rig via Mixamo web / AccuRig and pass `--rigged`, or let the heuristic
|
||||
`build_mixamo_armature` fit one).
|
||||
|
||||
CC0 alternative: **Quaternius Universal Animation Library** (quaternius.com,
|
||||
500+ clips, Mixamo-compatible skeleton) or **Kenney character assets** —
|
||||
both free for commercial use, no attribution required.
|
||||
|
||||
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.
|
||||
Missing clips are fine either way: 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+157
-10
@@ -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.
|
||||
|
||||
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
|
||||
renamed to Mixamo convention when obvious matches exist) and the mesh is
|
||||
@@ -35,6 +43,10 @@ if len(argv) < 2:
|
||||
INPUT = argv[0]
|
||||
OUTPUT = argv[1]
|
||||
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.
|
||||
MIX = "mixamorig:"
|
||||
@@ -115,6 +127,53 @@ def mesh_slice_width(obj, y_frac, height, min_z):
|
||||
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):
|
||||
"""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
|
||||
|
||||
|
||||
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():
|
||||
clear_scene()
|
||||
print(f"Importing {INPUT}...")
|
||||
@@ -186,7 +319,19 @@ def main():
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
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)
|
||||
for o in list(bpy.data.objects):
|
||||
if o.type == "EMPTY":
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
if armatures:
|
||||
print("Input already rigged — keeping existing skeleton, renaming bones")
|
||||
@@ -214,19 +359,17 @@ def main():
|
||||
min_z = 0.0
|
||||
height = TARGET_HEIGHT
|
||||
|
||||
arm = None
|
||||
if LIBRARY:
|
||||
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)
|
||||
|
||||
# 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")
|
||||
bind_mesh_to_armature(mesh, arm)
|
||||
|
||||
print(f"Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
@@ -234,7 +377,11 @@ def main():
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
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_skins=True,
|
||||
)
|
||||
|
||||
+142
-38
@@ -6,9 +6,16 @@ Merges every animation clip from the shared animation library onto a rigged
|
||||
character (Mixamo-compatible bone names) and exports one game-ready GLB whose
|
||||
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
|
||||
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:
|
||||
Animation library — two supported layouts in <animations_dir>:
|
||||
|
||||
A. `_library.glb` — a single multi-clip library (e.g. Quaternius Universal
|
||||
Animation Library, CC0, committed at assets/characters/animations/).
|
||||
Clip names map through LIBRARY_CLIP_MAP below. The character must be
|
||||
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
|
||||
@@ -38,12 +45,55 @@ STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
|
||||
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():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
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):
|
||||
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_"))
|
||||
|
||||
@@ -76,7 +126,7 @@ CORE_BONES = {
|
||||
|
||||
|
||||
def normalize_action_paths(action):
|
||||
for fc in action.fcurves:
|
||||
for fc in action_fcurves(action):
|
||||
if 'pose.bones["' in fc.data_path:
|
||||
start = fc.data_path.index('"') + 1
|
||||
end = fc.data_path.index('"', start)
|
||||
@@ -88,7 +138,7 @@ def normalize_action_paths(action):
|
||||
|
||||
def hips_height(arm):
|
||||
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 1.0
|
||||
|
||||
@@ -96,7 +146,7 @@ def hips_height(arm):
|
||||
def scale_location_curves(action, ratio):
|
||||
if abs(ratio - 1.0) < 0.01:
|
||||
return
|
||||
for fc in action.fcurves:
|
||||
for fc in action_fcurves(action):
|
||||
if fc.data_path.endswith(".location"):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y *= ratio
|
||||
@@ -105,13 +155,15 @@ def scale_location_curves(action, ratio):
|
||||
|
||||
|
||||
def strip_hips_horizontal(action):
|
||||
"""Zero hips X (side) and Y (forward) location so clips play in place.
|
||||
|
||||
Blender bone-local axes for a Y-up-exported Mixamo hips: X = side,
|
||||
Y = up (bone axis), Z = forward. We keep Y (vertical bob) and zero X/Z.
|
||||
"""Zero horizontal root motion so clips play in place (gameplay code
|
||||
moves the body). Applies to hips AND any dedicated root bone; keeps the
|
||||
vertical channel so bob/land weight survives.
|
||||
"""
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path.endswith('.location') and "Hips" in fc.data_path and fc.array_index in (0, 2):
|
||||
for fc in action_fcurves(action):
|
||||
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:
|
||||
kp.co.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.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(
|
||||
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:
|
||||
print(f"ERROR: no animation files in {ANIM_DIR}")
|
||||
@@ -176,21 +302,14 @@ def main():
|
||||
if not new_actions:
|
||||
print(" SKIP: no action found in file")
|
||||
else:
|
||||
action = max(new_actions, key=lambda a: len(a.fcurves))
|
||||
action.name = clip_name
|
||||
action = max(new_actions, key=lambda a: len(action_fcurves(a)))
|
||||
normalize_action_paths(action)
|
||||
if src_arm:
|
||||
ratio = target_hips / max(hips_height(src_arm), 1e-6)
|
||||
scale_location_curves(action, ratio)
|
||||
if STRIP_ROOT_MOTION:
|
||||
strip_hips_horizontal(action)
|
||||
|
||||
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
|
||||
_add_clip_track(target_arm, action, clip_name)
|
||||
merged += 1
|
||||
|
||||
# Remove the imported helper objects (keep the action).
|
||||
@@ -200,22 +319,7 @@ def main():
|
||||
if a.name != clip_name:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
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.")
|
||||
return merged
|
||||
|
||||
|
||||
main()
|
||||
|
||||
@@ -43,8 +43,14 @@ def get_token(cli_token: str | None) -> str:
|
||||
return tok
|
||||
token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token")
|
||||
if os.path.exists(token_file):
|
||||
with open(token_file, "r", encoding="utf-8") as f:
|
||||
tok = f.read().strip()
|
||||
with open(token_file, "rb") as f:
|
||||
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:
|
||||
return tok
|
||||
print("ERROR: no Sketchfab API token found.")
|
||||
|
||||
Reference in New Issue
Block a user