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",
|
"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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
||||||
|
|
||||||
|
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)
|
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)
|
||||||
|
|
||||||
# Bind with automatic weights.
|
bind_mesh_to_armature(mesh, arm)
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|||||||
+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
|
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
|
||||||
|
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
|
idle.fbx -> Idle
|
||||||
run.fbx -> Run
|
run.fbx -> Run
|
||||||
crouch_walk.fbx -> CrouchWalk
|
crouch_walk.fbx -> CrouchWalk
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user