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()
|
||||
Reference in New Issue
Block a user