feat: Sketchfab-to-game character pipeline, FP/TP animated views, client-auth netcode, audio system
Character pipeline (tools/): - sketchfab_import.py: search/download via Sketchfab Download API with license/attribution tracking (SKETCHFAB_API_TOKEN) - autorig.py: headless Blender auto-rig — fits a Mixamo-named skeleton to unrigged humanoids, binds automatic weights - merge_animations.py: merges the shared Mixamo-skeleton animation library onto any rigged character, strips root motion, canonical names - pipeline.py: one command chains download -> rig -> animate -> register In-game skin system: - SkinManager autoload reads skins.json (auto-written by the pipeline) - SkinnedPlayerModel rewritten: canonical clips with fallback chains, blend times, speed-scaled locomotion, weapon bone attachment - First-person: full animated body for the owner, head hidden via SkeletonModifier3D; third-person: full model for other players - Skin selector in main menu; skin id synced in multiplayer - Fixed GLBLoader crash (GLTFDocument.get_animation_count doesn't exist) Multiplayer sync overhaul: - Movement is now client-authoritative: the owning peer simulates locally (no input round-trip), server keeps health/kills/death - Remote players interpolate synced_position/velocity with extrapolation and snap-on-teleport - Knockback/impulses routed to the simulating peer Audio: - AudioManager autoload: SFX/Weapons/Footsteps/UI/Music buses, pooled 3D players, variation + pitch randomization, auto-registration from assets/sounds Docs: 3D_ASSET_PIPELINE.md rewritten end-to-end, new ASSET_SOURCES.md (non-procedural animation/map/sound sources) and SOUND_DESIGN.md. Verified with debug/spawn_smoke_test.gd (headless: 24/24 checks pass). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
552338112e
commit
d05477c135
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless Blender auto-rigger for Papaya-Shooter.
|
||||
|
||||
Takes an UNRIGGED humanoid mesh (GLB/glTF/FBX/OBJ), fits a Mixamo-compatible
|
||||
skeleton to it, binds with automatic weights, and exports a rigged GLB.
|
||||
|
||||
Because the output skeleton uses standard Mixamo bone names, ANY Mixamo
|
||||
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]
|
||||
|
||||
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
|
||||
re-exported normalized. Use tools/merge_animations.py next either way.
|
||||
|
||||
Heuristics assume a roughly upright humanoid in T-pose or A-pose. For models
|
||||
that fail (extreme proportions, non-humanoids), use an external auto-rigger
|
||||
(Mixamo web, AccuRig, Tripo/UniRig) and feed the rigged GLB straight to
|
||||
merge_animations.py instead.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: blender --background --python tools/autorig.py -- <input> <output.glb> [target_height]")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT = argv[0]
|
||||
OUTPUT = argv[1]
|
||||
TARGET_HEIGHT = float(argv[2]) if len(argv) > 2 else 1.75
|
||||
|
||||
# Mixamo bone names Godot/our animation library expects.
|
||||
MIX = "mixamorig:"
|
||||
|
||||
# Common bone-name aliases -> Mixamo names, used when a rig already exists.
|
||||
BONE_ALIASES = {
|
||||
"hips": "Hips", "pelvis": "Hips",
|
||||
"spine": "Spine", "spine1": "Spine1", "spine2": "Spine2",
|
||||
"chest": "Spine1", "upperchest": "Spine2",
|
||||
"neck": "Neck", "head": "Head",
|
||||
"leftshoulder": "LeftShoulder", "rightshoulder": "RightShoulder",
|
||||
"leftarm": "LeftArm", "leftupperarm": "LeftArm",
|
||||
"rightarm": "RightArm", "rightupperarm": "RightArm",
|
||||
"leftforearm": "LeftForeArm", "leftlowerarm": "LeftForeArm",
|
||||
"rightforearm": "RightForeArm", "rightlowerarm": "RightForeArm",
|
||||
"lefthand": "LeftHand", "righthand": "RightHand",
|
||||
"leftupleg": "LeftUpLeg", "leftupperleg": "LeftUpLeg", "leftthigh": "LeftUpLeg",
|
||||
"rightupleg": "RightUpLeg", "rightupperleg": "RightUpLeg", "rightthigh": "RightUpLeg",
|
||||
"leftleg": "LeftLeg", "leftlowerleg": "LeftLeg", "leftshin": "LeftLeg", "leftcalf": "LeftLeg",
|
||||
"rightleg": "RightLeg", "rightlowerleg": "RightLeg", "rightshin": "RightLeg", "rightcalf": "RightLeg",
|
||||
"leftfoot": "LeftFoot", "rightfoot": "RightFoot",
|
||||
"lefttoebase": "LeftToeBase", "lefttoe": "LeftToeBase",
|
||||
"righttoebase": "RightToeBase", "righttoe": "RightToeBase",
|
||||
}
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.actions):
|
||||
for block in list(block_list):
|
||||
if block.users == 0:
|
||||
block_list.remove(block)
|
||||
|
||||
|
||||
def import_model(path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".glb", ".gltf"):
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
elif ext == ".fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
elif ext == ".obj":
|
||||
if hasattr(bpy.ops.wm, "obj_import"):
|
||||
bpy.ops.wm.obj_import(filepath=path)
|
||||
else:
|
||||
bpy.ops.import_scene.obj(filepath=path)
|
||||
else:
|
||||
print(f"ERROR: unsupported format {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def normalize_alias(name):
|
||||
key = name.lower().replace("mixamorig:", "").replace("_", "").replace(" ", "").replace(".", "")
|
||||
key = key.replace("l_", "left").replace("r_", "right")
|
||||
return BONE_ALIASES.get(key)
|
||||
|
||||
|
||||
def rename_existing_rig(arm):
|
||||
renamed = 0
|
||||
for bone in arm.data.bones:
|
||||
target = normalize_alias(bone.name)
|
||||
if target and not bone.name.startswith(MIX):
|
||||
bone.name = MIX + target
|
||||
renamed += 1
|
||||
print(f"Renamed {renamed} bones to Mixamo convention")
|
||||
|
||||
|
||||
def mesh_slice_width(obj, y_frac, height, min_z):
|
||||
"""Max |x| of vertices within a thin horizontal slice at y_frac of height."""
|
||||
z_lo = min_z + height * (y_frac - 0.03)
|
||||
z_hi = min_z + height * (y_frac + 0.03)
|
||||
max_x = 0.0
|
||||
mat = obj.matrix_world
|
||||
for v in obj.data.vertices:
|
||||
co = mat @ v.co
|
||||
if z_lo <= co.z <= z_hi:
|
||||
max_x = max(max_x, abs(co.x))
|
||||
return max_x
|
||||
|
||||
|
||||
def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
|
||||
"""Create a Mixamo-named humanoid armature fitted to the mesh bounds.
|
||||
|
||||
Proportions are standard humanoid ratios of total height; good enough for
|
||||
automatic weights on typical game characters.
|
||||
"""
|
||||
bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0))
|
||||
arm = bpy.context.active_object
|
||||
arm.name = "Armature"
|
||||
eb = arm.data.edit_bones
|
||||
for b in list(eb):
|
||||
eb.remove(b)
|
||||
|
||||
def z(frac):
|
||||
return min_z + height * frac
|
||||
|
||||
def add(name, head, tail, parent=None, connect=False):
|
||||
b = eb.new(MIX + name)
|
||||
b.head = Vector(head)
|
||||
b.tail = Vector(tail)
|
||||
if parent:
|
||||
b.parent = eb[MIX + parent]
|
||||
b.use_connect = connect
|
||||
return b
|
||||
|
||||
sw = shoulder_w * 0.75 # shoulder joint x
|
||||
hw = max(hip_w * 0.45, height * 0.055) # hip joint x
|
||||
|
||||
add("Hips", (0, 0, z(0.53)), (0, 0, z(0.58)))
|
||||
add("Spine", (0, 0, z(0.58)), (0, 0, z(0.66)), "Hips", True)
|
||||
add("Spine1", (0, 0, z(0.66)), (0, 0, z(0.74)), "Spine", True)
|
||||
add("Spine2", (0, 0, z(0.74)), (0, 0, z(0.82)), "Spine1", True)
|
||||
add("Neck", (0, 0, z(0.82)), (0, 0, z(0.87)), "Spine2", True)
|
||||
add("Head", (0, 0, z(0.87)), (0, 0, z(1.00)), "Neck", True)
|
||||
|
||||
for side, sx in (("Left", 1), ("Right", -1)):
|
||||
add(f"{side}Shoulder", (sx * sw * 0.25, 0, z(0.80)), (sx * sw, 0, z(0.80)), "Spine2")
|
||||
# Arms along +/-X (T-pose-ish); automatic weights tolerate A-pose meshes.
|
||||
arm_len = height * 0.16
|
||||
add(f"{side}Arm", (sx * sw, 0, z(0.80)), (sx * (sw + arm_len), 0, z(0.78)), f"{side}Shoulder", True)
|
||||
add(f"{side}ForeArm", (sx * (sw + arm_len), 0, z(0.78)), (sx * (sw + arm_len * 2), 0, z(0.76)), f"{side}Arm", True)
|
||||
add(f"{side}Hand", (sx * (sw + arm_len * 2), 0, z(0.76)), (sx * (sw + arm_len * 2.4), 0, z(0.75)), f"{side}ForeArm", True)
|
||||
|
||||
add(f"{side}UpLeg", (sx * hw, 0, z(0.52)), (sx * hw, 0, z(0.29)), "Hips")
|
||||
add(f"{side}Leg", (sx * hw, 0, z(0.29)), (sx * hw, 0, z(0.06)), f"{side}UpLeg", True)
|
||||
add(f"{side}Foot", (sx * hw, 0, z(0.06)), (sx * hw, -height * 0.08, z(0.015)), f"{side}Leg", True)
|
||||
add(f"{side}ToeBase", (sx * hw, -height * 0.08, z(0.015)), (sx * hw, -height * 0.13, z(0.015)), f"{side}Foot", True)
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
return arm
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing {INPUT}...")
|
||||
import_model(INPUT)
|
||||
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not meshes:
|
||||
print("ERROR: no mesh in input")
|
||||
sys.exit(1)
|
||||
|
||||
# Join meshes so weights/normalization apply uniformly.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
mesh = bpy.context.active_object
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
if armatures:
|
||||
print("Input already rigged — keeping existing skeleton, renaming bones")
|
||||
rename_existing_rig(armatures[0])
|
||||
else:
|
||||
# Normalize: feet on ground, centered, target height.
|
||||
bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box]
|
||||
min_z = min(c.z for c in bb)
|
||||
max_z = max(c.z for c in bb)
|
||||
cur_h = max_z - min_z
|
||||
if cur_h <= 0:
|
||||
print("ERROR: degenerate mesh bounds")
|
||||
sys.exit(1)
|
||||
s = TARGET_HEIGHT / cur_h
|
||||
mesh.scale = (s, s, s)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box]
|
||||
min_z = min(c.z for c in bb)
|
||||
cx = (min(c.x for c in bb) + max(c.x for c in bb)) / 2
|
||||
cy = (min(c.y for c in bb) + max(c.y for c in bb)) / 2
|
||||
mesh.location.x -= cx
|
||||
mesh.location.y -= cy
|
||||
mesh.location.z -= min_z
|
||||
bpy.ops.object.transform_apply(location=True)
|
||||
min_z = 0.0
|
||||
height = TARGET_HEIGHT
|
||||
|
||||
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")
|
||||
|
||||
print(f"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_apply=True,
|
||||
export_animations=True,
|
||||
export_skins=True,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless Blender animation merger for Papaya-Shooter.
|
||||
|
||||
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:
|
||||
idle.fbx -> Idle
|
||||
run.fbx -> Run
|
||||
crouch_walk.fbx -> CrouchWalk
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/merge_animations.py -- \
|
||||
<rigged_character.glb> <animations_dir> <output.glb> [--keep-root-motion]
|
||||
|
||||
Locomotion clips are exported in place (hips horizontal motion stripped)
|
||||
unless --keep-root-motion is given, since the gameplay code moves the body.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 3:
|
||||
print("Usage: blender --background --python tools/merge_animations.py -- "
|
||||
"<character.glb> <animations_dir> <output.glb> [--keep-root-motion]")
|
||||
sys.exit(1)
|
||||
|
||||
CHARACTER = argv[0]
|
||||
ANIM_DIR = argv[1]
|
||||
OUTPUT = argv[2]
|
||||
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
|
||||
MIX = "mixamorig:"
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
|
||||
|
||||
def to_pascal(stem):
|
||||
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_"))
|
||||
|
||||
|
||||
def find_armature(objects):
|
||||
for o in objects:
|
||||
if o.type == "ARMATURE":
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def normalize_prefix(name):
|
||||
"""mixamorig1:Hips / mixamorig_Hips / Hips -> mixamorig:Hips"""
|
||||
base = name
|
||||
for i in range(10):
|
||||
base = base.replace(f"mixamorig{i}:", MIX)
|
||||
base = base.replace("mixamorig_", MIX)
|
||||
if ":" not in base and base in CORE_BONES:
|
||||
base = MIX + base
|
||||
return base
|
||||
|
||||
|
||||
CORE_BONES = {
|
||||
"Hips", "Spine", "Spine1", "Spine2", "Neck", "Head",
|
||||
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
|
||||
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
|
||||
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase",
|
||||
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase",
|
||||
}
|
||||
|
||||
|
||||
def normalize_action_paths(action):
|
||||
for fc in action.fcurves:
|
||||
if 'pose.bones["' in fc.data_path:
|
||||
start = fc.data_path.index('"') + 1
|
||||
end = fc.data_path.index('"', start)
|
||||
bone = fc.data_path[start:end]
|
||||
fixed = normalize_prefix(bone)
|
||||
if fixed != bone:
|
||||
fc.data_path = fc.data_path[:start] + fixed + fc.data_path[end:]
|
||||
|
||||
|
||||
def hips_height(arm):
|
||||
for b in arm.data.bones:
|
||||
if b.name.endswith("Hips"):
|
||||
return (arm.matrix_world @ b.head_local).z
|
||||
return 1.0
|
||||
|
||||
|
||||
def scale_location_curves(action, ratio):
|
||||
if abs(ratio - 1.0) < 0.01:
|
||||
return
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path.endswith(".location"):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y *= ratio
|
||||
kp.handle_left.y *= ratio
|
||||
kp.handle_right.y *= 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.
|
||||
"""
|
||||
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 kp in fc.keyframe_points:
|
||||
kp.co.y = 0.0
|
||||
kp.handle_left.y = 0.0
|
||||
kp.handle_right.y = 0.0
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing character {CHARACTER}...")
|
||||
bpy.ops.import_scene.gltf(filepath=CHARACTER)
|
||||
target_arm = find_armature(bpy.data.objects)
|
||||
if not target_arm:
|
||||
print("ERROR: character has no armature — run tools/autorig.py first")
|
||||
sys.exit(1)
|
||||
|
||||
# Normalize character bone names too.
|
||||
for bone in target_arm.data.bones:
|
||||
fixed = normalize_prefix(bone.name)
|
||||
if fixed != bone.name:
|
||||
bone.name = fixed
|
||||
|
||||
target_hips = hips_height(target_arm)
|
||||
print(f"Character hips height: {target_hips:.3f}")
|
||||
|
||||
if not target_arm.animation_data:
|
||||
target_arm.animation_data_create()
|
||||
|
||||
# Drop any pre-existing animation so only canonical clips ship.
|
||||
for track in list(target_arm.animation_data.nla_tracks):
|
||||
target_arm.animation_data.nla_tracks.remove(track)
|
||||
target_arm.animation_data.action = None
|
||||
|
||||
anim_files = sorted(
|
||||
f for f in os.listdir(ANIM_DIR)
|
||||
if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith(".")
|
||||
)
|
||||
if not anim_files:
|
||||
print(f"ERROR: no animation files in {ANIM_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
merged = 0
|
||||
for fname in anim_files:
|
||||
clip_name = to_pascal(os.path.splitext(fname)[0])
|
||||
path = os.path.join(ANIM_DIR, fname)
|
||||
print(f"--- {fname} -> '{clip_name}'")
|
||||
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
try:
|
||||
if fname.lower().endswith(".fbx"):
|
||||
bpy.ops.import_scene.fbx(filepath=path, ignore_leaf_bones=True)
|
||||
else:
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
except Exception as e:
|
||||
print(f" SKIP: import failed: {e}")
|
||||
continue
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
merged += 1
|
||||
|
||||
# Remove the imported helper objects (keep the action).
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for a in new_actions:
|
||||
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.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-command character pipeline for Papaya-Shooter:
|
||||
|
||||
Sketchfab model -> auto-rig -> merge animation library -> in game.
|
||||
|
||||
Examples:
|
||||
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN):
|
||||
python tools/pipeline.py --uid a1b2c3... --name space_marine
|
||||
|
||||
# From a local unrigged mesh:
|
||||
python tools/pipeline.py --input downloads/robot.glb --name robot
|
||||
|
||||
# From an ALREADY-RIGGED model (Mixamo/AccuRig/Tripo output) — skips autorig:
|
||||
python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged
|
||||
|
||||
The result is assets/characters/skins/<name>.glb with the full canonical
|
||||
animation set, plus a registry entry in assets/characters/skins/skins.json
|
||||
that SkinManager picks up automatically — no code changes needed.
|
||||
|
||||
Requires Blender 3.6+ on PATH or BLENDER_PATH set.
|
||||
Animation library: assets/characters/animations/ (see docs/ASSET_SOURCES.md).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TOOLS = os.path.join(PROJECT_ROOT, "tools")
|
||||
SKINS_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "skins")
|
||||
ANIM_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "animations")
|
||||
STAGING = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
|
||||
SKINS_JSON = os.path.join(SKINS_DIR, "skins.json")
|
||||
|
||||
|
||||
def find_blender() -> str:
|
||||
env = os.environ.get("BLENDER_PATH")
|
||||
if env and os.path.exists(env):
|
||||
return env
|
||||
on_path = shutil.which("blender")
|
||||
if on_path:
|
||||
return on_path
|
||||
candidates = []
|
||||
for pf in (r"C:\Program Files\Blender Foundation", r"C:\Program Files (x86)\Blender Foundation"):
|
||||
if os.path.isdir(pf):
|
||||
for d in sorted(os.listdir(pf), reverse=True):
|
||||
exe = os.path.join(pf, d, "blender.exe")
|
||||
if os.path.exists(exe):
|
||||
candidates.append(exe)
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
print("ERROR: Blender not found. Install Blender or set BLENDER_PATH to blender.exe")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(cmd: list[str], step: str) -> None:
|
||||
print(f"\n=== {step} ===")
|
||||
print(" ".join(f'"{c}"' if " " in c else c for c in cmd))
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR: step '{step}' failed (exit {result.returncode})")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def register_skin(name: str, display_name: str, model_res_path: str, description: str) -> None:
|
||||
registry = {"skins": []}
|
||||
if os.path.exists(SKINS_JSON):
|
||||
with open(SKINS_JSON, "r", encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
skins = registry.setdefault("skins", [])
|
||||
entry = {
|
||||
"id": name,
|
||||
"name": display_name,
|
||||
"description": description,
|
||||
"model": model_res_path,
|
||||
"unlocked": True,
|
||||
}
|
||||
for i, s in enumerate(skins):
|
||||
if s.get("id") == name:
|
||||
skins[i] = entry
|
||||
break
|
||||
else:
|
||||
skins.append(entry)
|
||||
os.makedirs(SKINS_DIR, exist_ok=True)
|
||||
with open(SKINS_JSON, "w", encoding="utf-8") as f:
|
||||
json.dump(registry, f, indent=2)
|
||||
print(f"Registered skin '{name}' in {os.path.relpath(SKINS_JSON, PROJECT_ROOT)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Sketchfab -> rigged, animated, in-game character")
|
||||
src = p.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("--uid", help="Sketchfab model UID to download")
|
||||
src.add_argument("--input", help="local model file (glb/gltf/fbx/obj)")
|
||||
p.add_argument("--name", required=True, help="skin id (snake_case)")
|
||||
p.add_argument("--display-name", help="name shown in menus (default: from --name)")
|
||||
p.add_argument("--description", default="", help="skin description")
|
||||
p.add_argument("--rigged", action="store_true", help="input is already rigged — skip autorig")
|
||||
p.add_argument("--height", type=float, default=1.75, help="target character height in meters")
|
||||
p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips")
|
||||
p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory")
|
||||
args = p.parse_args()
|
||||
|
||||
name = args.name
|
||||
display_name = args.display_name or name.replace("_", " ").title()
|
||||
os.makedirs(STAGING, exist_ok=True)
|
||||
|
||||
# 1. Acquire the model.
|
||||
if args.uid:
|
||||
run([sys.executable, os.path.join(TOOLS, "sketchfab_import.py"),
|
||||
"download", args.uid, "--name", name, "--out", STAGING],
|
||||
"Download from Sketchfab")
|
||||
input_path = os.path.join(STAGING, f"{name}.glb")
|
||||
if not os.path.exists(input_path):
|
||||
# glTF-zip fallback path used by the importer
|
||||
alt = os.path.join(STAGING, name)
|
||||
gltfs = [f for f in os.listdir(alt) if f.endswith((".gltf", ".glb"))] if os.path.isdir(alt) else []
|
||||
if not gltfs:
|
||||
print("ERROR: downloaded model not found in staging dir")
|
||||
sys.exit(1)
|
||||
input_path = os.path.join(alt, gltfs[0])
|
||||
else:
|
||||
input_path = os.path.abspath(args.input)
|
||||
if not os.path.exists(input_path):
|
||||
print(f"ERROR: input not found: {input_path}")
|
||||
sys.exit(1)
|
||||
|
||||
blender = find_blender()
|
||||
print(f"Using Blender: {blender}")
|
||||
|
||||
# 2. Auto-rig (or pass through if already rigged).
|
||||
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
|
||||
if args.rigged:
|
||||
rigged_path = input_path
|
||||
print("Skipping autorig (--rigged)")
|
||||
else:
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
|
||||
"--", input_path, rigged_path, str(args.height)],
|
||||
"Auto-rig (Blender)")
|
||||
|
||||
# 3. Merge the shared animation library.
|
||||
if not os.path.isdir(args.anim_dir) or not any(
|
||||
f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)):
|
||||
print(f"ERROR: animation library is empty: {args.anim_dir}")
|
||||
print("Fill it with one clip per file (idle.fbx, run.fbx, ...) — see docs/ASSET_SOURCES.md")
|
||||
sys.exit(1)
|
||||
|
||||
final_path = os.path.join(SKINS_DIR, f"{name}.glb")
|
||||
merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"),
|
||||
"--", rigged_path, args.anim_dir, final_path]
|
||||
if args.keep_root_motion:
|
||||
merge_cmd.append("--keep-root-motion")
|
||||
run(merge_cmd, "Merge animation library (Blender)")
|
||||
|
||||
# 4. Carry the license file along if the model came from Sketchfab.
|
||||
lic_src = os.path.splitext(input_path)[0] + ".license.json"
|
||||
if os.path.exists(lic_src):
|
||||
shutil.copyfile(lic_src, os.path.join(SKINS_DIR, f"{name}.license.json"))
|
||||
|
||||
# 5. Register so the game sees it.
|
||||
register_skin(name, display_name, f"res://assets/characters/skins/{name}.glb", args.description)
|
||||
|
||||
print(f"\nDONE: {os.path.relpath(final_path, PROJECT_ROOT)}")
|
||||
print(f"'{display_name}' is now selectable in-game (SkinManager reads skins.json).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sketchfab model importer for Papaya-Shooter.
|
||||
|
||||
Searches and downloads models via the official Sketchfab Download API,
|
||||
extracts the GLB/glTF, and stages it for the rigging pipeline.
|
||||
|
||||
Auth (any one of):
|
||||
- env var SKETCHFAB_API_TOKEN
|
||||
- --token <token>
|
||||
- a file named .sketchfab_token in the project root (gitignored)
|
||||
|
||||
Usage:
|
||||
python tools/sketchfab_import.py search "anime character" [--limit 10]
|
||||
python tools/sketchfab_import.py download <model-uid> [--out assets/characters/incoming]
|
||||
python tools/sketchfab_import.py info <model-uid>
|
||||
|
||||
Notes:
|
||||
- Only models marked "downloadable" can be fetched (license permitting).
|
||||
- License + attribution metadata is saved next to the model as <name>.license.json.
|
||||
CC-BY requires crediting the author — keep those files!
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
API_BASE = "https://api.sketchfab.com/v3"
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_OUT = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
|
||||
|
||||
|
||||
def get_token(cli_token: str | None) -> str:
|
||||
if cli_token:
|
||||
return cli_token
|
||||
tok = os.environ.get("SKETCHFAB_API_TOKEN", "").strip()
|
||||
if tok:
|
||||
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()
|
||||
if tok:
|
||||
return tok
|
||||
print("ERROR: no Sketchfab API token found.")
|
||||
print("Set SKETCHFAB_API_TOKEN, pass --token, or create .sketchfab_token in the project root.")
|
||||
print("Your token: https://sketchfab.com/settings/password (API token section)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def api_get(path: str, token: str, params: dict | None = None) -> dict:
|
||||
url = API_BASE + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")[:500]
|
||||
print(f"ERROR: API request failed ({e.code}) for {url}\n{body}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
|
||||
return slug or "model"
|
||||
|
||||
|
||||
def cmd_search(args) -> None:
|
||||
token = get_token(args.token)
|
||||
params = {
|
||||
"type": "models",
|
||||
"q": args.query,
|
||||
"downloadable": "true",
|
||||
"count": str(args.limit),
|
||||
"sort_by": "-likeCount",
|
||||
}
|
||||
if args.animated:
|
||||
params["animated"] = "true"
|
||||
if args.rigged:
|
||||
params["rigged"] = "true"
|
||||
data = api_get("/search", token, params)
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
print("No downloadable results.")
|
||||
return
|
||||
print(f"{'UID':<34} {'License':<18} {'Faces':>9} Name")
|
||||
print("-" * 90)
|
||||
for m in results:
|
||||
lic = (m.get("license") or {}).get("label", "?")
|
||||
faces = m.get("faceCount", 0)
|
||||
print(f"{m['uid']:<34} {lic:<18} {faces:>9} {m['name']}")
|
||||
print("\nDownload one with: python tools/sketchfab_import.py download <UID>")
|
||||
|
||||
|
||||
def cmd_info(args) -> None:
|
||||
token = get_token(args.token)
|
||||
m = api_get(f"/models/{args.uid}", token)
|
||||
lic = m.get("license") or {}
|
||||
print(f"Name: {m.get('name')}")
|
||||
print(f"Author: {(m.get('user') or {}).get('displayName')}")
|
||||
print(f"License: {lic.get('label')} ({lic.get('slug')})")
|
||||
print(f"Downloadable:{m.get('isDownloadable')}")
|
||||
print(f"Faces: {m.get('faceCount')} Verts: {m.get('vertexCount')}")
|
||||
print(f"Animations: {m.get('animationCount')}")
|
||||
print(f"URL: {m.get('viewerUrl')}")
|
||||
|
||||
|
||||
def cmd_download(args) -> None:
|
||||
token = get_token(args.token)
|
||||
meta = api_get(f"/models/{args.uid}", token)
|
||||
name = args.name or slugify(meta.get("name", args.uid))
|
||||
out_dir = os.path.abspath(args.out)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
dl = api_get(f"/models/{args.uid}/download", token)
|
||||
|
||||
# Prefer the single-file GLB; fall back to the glTF zip.
|
||||
glb_path = os.path.join(out_dir, f"{name}.glb")
|
||||
if "glb" in dl and dl["glb"].get("url"):
|
||||
print(f"Downloading GLB ({dl['glb'].get('size', 0) / 1e6:.1f} MB)...")
|
||||
urllib.request.urlretrieve(dl["glb"]["url"], glb_path)
|
||||
elif "gltf" in dl and dl["gltf"].get("url"):
|
||||
print(f"Downloading glTF zip ({dl['gltf'].get('size', 0) / 1e6:.1f} MB)...")
|
||||
with urllib.request.urlopen(dl["gltf"]["url"], timeout=300) as resp:
|
||||
zdata = resp.read()
|
||||
extract_dir = os.path.join(out_dir, name)
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
with zipfile.ZipFile(io.BytesIO(zdata)) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
gltfs = [f for f in os.listdir(extract_dir) if f.endswith((".gltf", ".glb"))]
|
||||
if not gltfs:
|
||||
print(f"ERROR: no .gltf/.glb found in archive at {extract_dir}")
|
||||
sys.exit(1)
|
||||
glb_path = os.path.join(extract_dir, gltfs[0])
|
||||
else:
|
||||
print("ERROR: model has no downloadable GLB/glTF archive.")
|
||||
sys.exit(1)
|
||||
|
||||
# Save license/attribution metadata — required for CC-BY credit.
|
||||
lic = meta.get("license") or {}
|
||||
license_info = {
|
||||
"name": meta.get("name"),
|
||||
"uid": args.uid,
|
||||
"author": (meta.get("user") or {}).get("displayName"),
|
||||
"author_url": (meta.get("user") or {}).get("profileUrl"),
|
||||
"license": lic.get("label"),
|
||||
"license_slug": lic.get("slug"),
|
||||
"source_url": meta.get("viewerUrl"),
|
||||
}
|
||||
license_path = os.path.splitext(glb_path)[0] + ".license.json"
|
||||
with open(license_path, "w", encoding="utf-8") as f:
|
||||
json.dump(license_info, f, indent=2)
|
||||
|
||||
print(f"Saved: {glb_path}")
|
||||
print(f"License: {license_path} ({lic.get('label')})")
|
||||
print(f"\nNext step (auto-rig + animations + register in game):")
|
||||
print(f" python tools/pipeline.py --input \"{glb_path}\" --name {name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Sketchfab importer")
|
||||
p.add_argument("--token", help="Sketchfab API token (else SKETCHFAB_API_TOKEN)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("search", help="search downloadable models")
|
||||
s.add_argument("query")
|
||||
s.add_argument("--limit", type=int, default=10)
|
||||
s.add_argument("--animated", action="store_true", help="only models with animations")
|
||||
s.add_argument("--rigged", action="store_true", help="only rigged models")
|
||||
s.set_defaults(func=cmd_search)
|
||||
|
||||
i = sub.add_parser("info", help="show model metadata")
|
||||
i.add_argument("uid")
|
||||
i.set_defaults(func=cmd_info)
|
||||
|
||||
d = sub.add_parser("download", help="download a model by UID")
|
||||
d.add_argument("uid")
|
||||
d.add_argument("--out", default=DEFAULT_OUT)
|
||||
d.add_argument("--name", help="output base name (default: slugified model name)")
|
||||
d.set_defaults(func=cmd_download)
|
||||
|
||||
args = p.parse_args()
|
||||
# Propagate the global --token even when given after the subcommand.
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user