Release v0.1.3 2026-07-11
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"""Build three original IWT2-inspired, rigged low-poly boss assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT_ROOT = ROOT / "game_assets/models/original/bosses"
|
||||
FPS = 30
|
||||
PARTS: list[bpy.types.Object] = []
|
||||
RUNTIME_MATERIALS: list[bpy.types.Material] = []
|
||||
|
||||
|
||||
def reset_scene() -> None:
|
||||
global PARTS, RUNTIME_MATERIALS
|
||||
PARTS = []
|
||||
RUNTIME_MATERIALS = []
|
||||
if bpy.context.object and bpy.context.object.mode != "OBJECT":
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.cameras, bpy.data.lights, bpy.data.actions):
|
||||
for block in list(blocks):
|
||||
blocks.remove(block)
|
||||
|
||||
|
||||
def material(name, color, metallic=0.0, roughness=0.5, emission=None, strength=0.0):
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
mat.diffuse_color = color
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = color
|
||||
bsdf.inputs["Metallic"].default_value = metallic
|
||||
bsdf.inputs["Roughness"].default_value = roughness
|
||||
if emission:
|
||||
bsdf.inputs["Emission Color"].default_value = emission
|
||||
bsdf.inputs["Emission Strength"].default_value = strength
|
||||
return mat
|
||||
|
||||
|
||||
def prepare_materials(specs):
|
||||
mats = {name: material(f"M_{name}", **settings) for name, settings in specs.items()}
|
||||
RUNTIME_MATERIALS.extend(mats.values())
|
||||
return mats
|
||||
|
||||
|
||||
def finish(obj, name, mat, bone, smooth=True):
|
||||
obj.name = name
|
||||
obj.data.name = f"{name}_Mesh"
|
||||
for runtime_mat in RUNTIME_MATERIALS:
|
||||
obj.data.materials.append(runtime_mat)
|
||||
mat_index = next(index for index, candidate in enumerate(RUNTIME_MATERIALS) if candidate.name == mat.name)
|
||||
for polygon in obj.data.polygons:
|
||||
polygon.material_index = mat_index
|
||||
polygon.use_smooth = smooth
|
||||
bone_group = obj.vertex_groups.new(name=bone)
|
||||
bone_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
|
||||
mat_group = obj.vertex_groups.new(name=f"__MAT_{mat_index}")
|
||||
mat_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
PARTS.append(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def ellipsoid(name, location, scale, mat, bone, subdivisions=1, rotation=(0, 0, 0)):
|
||||
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1, location=location, rotation=rotation)
|
||||
obj = bpy.context.object
|
||||
obj.scale = scale
|
||||
return finish(obj, name, mat, bone)
|
||||
|
||||
|
||||
def cone(name, start, end, radius_start, radius_end, mat, bone, vertices=7):
|
||||
start_v, end_v = Vector(start), Vector(end)
|
||||
direction = end_v - start_v
|
||||
bpy.ops.mesh.primitive_cone_add(
|
||||
vertices=vertices,
|
||||
radius1=radius_start,
|
||||
radius2=radius_end,
|
||||
depth=direction.length,
|
||||
location=(start_v + end_v) * 0.5,
|
||||
)
|
||||
obj = bpy.context.object
|
||||
obj.rotation_mode = "QUATERNION"
|
||||
obj.rotation_quaternion = direction.to_track_quat("Z", "Y")
|
||||
obj.rotation_mode = "XYZ"
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
return finish(obj, name, mat, bone, smooth=False)
|
||||
|
||||
|
||||
def plate(name, location, scale, rotation, mat, bone, vertices=6):
|
||||
bpy.ops.mesh.primitive_cone_add(vertices=vertices, radius1=1, radius2=0.58, depth=0.42, location=location, rotation=rotation)
|
||||
obj = bpy.context.object
|
||||
obj.scale = scale
|
||||
return finish(obj, name, mat, bone, smooth=False)
|
||||
|
||||
|
||||
def armature(name, specs):
|
||||
data = bpy.data.armatures.new(f"{name}_Rig")
|
||||
rig = bpy.data.objects.new(f"{name}_Rig", data)
|
||||
bpy.context.collection.objects.link(rig)
|
||||
bpy.context.view_layer.objects.active = rig
|
||||
rig.select_set(True)
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
for bone_name, head, tail, parent in specs:
|
||||
bone = data.edit_bones.new(bone_name)
|
||||
bone.head, bone.tail = head, tail
|
||||
if parent:
|
||||
bone.parent = data.edit_bones[parent]
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
for bone in rig.pose.bones:
|
||||
bone.rotation_mode = "XYZ"
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
return rig
|
||||
|
||||
|
||||
def join_parts(name, rig):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in PARTS:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = PARTS[0]
|
||||
bpy.ops.object.join()
|
||||
body = bpy.context.object
|
||||
body.name = f"{name}_Body"
|
||||
body.data.name = f"{name}_Body_Mesh"
|
||||
material_groups = {g.index: int(g.name.removeprefix("__MAT_")) for g in body.vertex_groups if g.name.startswith("__MAT_")}
|
||||
face_materials = []
|
||||
for polygon in body.data.polygons:
|
||||
vertex = body.data.vertices[polygon.vertices[0]]
|
||||
face_materials.append(next(material_groups[a.group] for a in vertex.groups if a.group in material_groups))
|
||||
body.data.materials.clear()
|
||||
for mat in RUNTIME_MATERIALS:
|
||||
body.data.materials.append(mat)
|
||||
for polygon, index in zip(body.data.polygons, face_materials, strict=True):
|
||||
polygon.material_index = index
|
||||
for group in [g for g in body.vertex_groups if g.name.startswith("__MAT_")]:
|
||||
body.vertex_groups.remove(group)
|
||||
modifier = body.modifiers.new(f"{name}_Armature", "ARMATURE")
|
||||
modifier.object = rig
|
||||
body.parent = rig
|
||||
return body
|
||||
|
||||
|
||||
def reset_pose(rig):
|
||||
for bone in rig.pose.bones:
|
||||
bone.location = (0, 0, 0)
|
||||
bone.rotation_euler = (0, 0, 0)
|
||||
bone.scale = (1, 1, 1)
|
||||
|
||||
|
||||
def key_pose(rig, frame, rotations=None, locations=None, scales=None):
|
||||
reset_pose(rig)
|
||||
for name, value in (rotations or {}).items():
|
||||
rig.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value)
|
||||
for name, value in (locations or {}).items():
|
||||
rig.pose.bones[name].location = value
|
||||
for name, value in (scales or {}).items():
|
||||
rig.pose.bones[name].scale = value
|
||||
for bone in rig.pose.bones:
|
||||
bone.keyframe_insert("location", frame=frame, group=bone.name)
|
||||
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
|
||||
bone.keyframe_insert("scale", frame=frame, group=bone.name)
|
||||
|
||||
|
||||
def actions(rig, definitions):
|
||||
rig.animation_data_create()
|
||||
metadata = {}
|
||||
for name, end_frame, loop, poses in definitions:
|
||||
action = bpy.data.actions.new(name)
|
||||
action.use_fake_user = True
|
||||
action.use_frame_range = True
|
||||
action.frame_start, action.frame_end, action.use_cyclic = 1, end_frame, loop
|
||||
rig.animation_data.action = action
|
||||
for pose in poses:
|
||||
key_pose(rig, **pose)
|
||||
rig.animation_data.action = None
|
||||
metadata[name] = {"frames": end_frame, "seconds": round(end_frame / FPS, 3), "loop": loop}
|
||||
return metadata
|
||||
|
||||
|
||||
def select_only(objects):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in objects:
|
||||
obj.hide_set(False)
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
|
||||
|
||||
def collision(asset_id, specs, out_dir):
|
||||
objects = []
|
||||
for name, location, scale in specs:
|
||||
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1, location=location)
|
||||
obj = bpy.context.object
|
||||
obj.name = f"COLLISION_{name}"
|
||||
obj.scale = scale
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
objects.append(obj)
|
||||
select_only(objects)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(out_dir / f"{asset_id}_collision.glb"), export_format="GLB", use_selection=True,
|
||||
export_animations=False, export_skins=False, export_materials="NONE", export_apply=True, export_yup=True,
|
||||
)
|
||||
for obj in objects:
|
||||
obj.hide_render = True
|
||||
obj.hide_set(True)
|
||||
return objects
|
||||
|
||||
|
||||
def look_at(obj, target):
|
||||
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
|
||||
def studio():
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = scene.render.resolution_y = 760
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.fps = FPS
|
||||
scene.world.use_nodes = True
|
||||
scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.005, 0.007, 0.012, 1)
|
||||
scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.3
|
||||
camera_data = bpy.data.cameras.new("PreviewCamera")
|
||||
camera = bpy.data.objects.new("PreviewCamera", camera_data)
|
||||
bpy.context.collection.objects.link(camera)
|
||||
scene.camera = camera
|
||||
for name, location, color, energy, size in [
|
||||
("Key", (5, -7, 7), (1, 0.34, 0.1), 1200, 5),
|
||||
("Fill", (-5, -3, 4), (0.12, 0.32, 1), 800, 4),
|
||||
("Rim", (1, 6, 6), (1, 0.12, 0.02), 1150, 3),
|
||||
]:
|
||||
data = bpy.data.lights.new(name, "AREA")
|
||||
data.energy, data.color, data.shape, data.size = energy, color, "DISK", size
|
||||
obj = bpy.data.objects.new(name, data)
|
||||
obj.location = location
|
||||
bpy.context.collection.objects.link(obj)
|
||||
look_at(obj, (0, 0, 1.5))
|
||||
floor_mat = material("M_PreviewFloor", color=(0.014, 0.018, 0.026, 1), roughness=0.85)
|
||||
bpy.ops.mesh.primitive_plane_add(size=28, location=(0, 0, 0))
|
||||
floor = bpy.context.object
|
||||
floor.data.materials.append(floor_mat)
|
||||
return camera
|
||||
|
||||
|
||||
def render_previews(rig, camera, out_dir, asset_id, target, distance, special_clip, special_frame):
|
||||
preview_dir = out_dir / "previews"
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename, position, clip, frame in [
|
||||
(f"{asset_id}_three_quarter.png", (distance * 0.62, -distance, distance * 0.48), "Idle", 15),
|
||||
(f"{asset_id}_side.png", (distance, 0, distance * 0.42), "Idle", 15),
|
||||
(f"{asset_id}_{special_clip.lower()}.png", (distance * 0.62, -distance, distance * 0.48), special_clip, special_frame),
|
||||
]:
|
||||
rig.animation_data.action = bpy.data.actions[clip]
|
||||
bpy.context.scene.frame_set(frame)
|
||||
camera.location = position
|
||||
camera.data.lens = 58
|
||||
look_at(camera, target)
|
||||
bpy.context.scene.render.filepath = str(preview_dir / filename)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
rig.animation_data.action = None
|
||||
|
||||
|
||||
def export_asset(asset_id, display_name, rig, body, clips, collision_specs, preview_target, preview_distance, special_clip, special_frame):
|
||||
out_dir = OUT_ROOT / asset_id
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
select_only([rig, body])
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(out_dir / f"{asset_id}.glb"), export_format="GLB", use_selection=True,
|
||||
export_animations=True, export_animation_mode="ACTIONS", export_frame_range=True,
|
||||
export_skins=True, export_morph=False, export_yup=True, export_apply=False,
|
||||
export_cameras=False, export_lights=False,
|
||||
)
|
||||
collision(asset_id, collision_specs, out_dir)
|
||||
triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons)
|
||||
metadata = {
|
||||
"name": display_name,
|
||||
"assetId": asset_id,
|
||||
"authoringTool": bpy.app.version_string,
|
||||
"format": "glTF 2.0 binary (GLB)",
|
||||
"runtime": {
|
||||
"renderMesh": body.name,
|
||||
"armature": rig.name,
|
||||
"collisionAsset": f"{asset_id}_collision.glb",
|
||||
"materials": [mat.name for mat in body.data.materials],
|
||||
"trianglesApprox": triangles,
|
||||
},
|
||||
"animations": clips,
|
||||
}
|
||||
(out_dir / f"{asset_id}.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
camera = studio()
|
||||
render_previews(rig, camera, out_dir, asset_id, preview_target, preview_distance, special_clip, special_frame)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(out_dir / f"{asset_id}.blend"))
|
||||
print(f"BUILT={asset_id} TRIANGLES={triangles} CLIPS={','.join(clips)}")
|
||||
|
||||
|
||||
def build_ram():
|
||||
reset_scene()
|
||||
mats = prepare_materials({
|
||||
"Obsidian": {"color": (0.025, 0.028, 0.034, 1), "metallic": 0.28, "roughness": 0.4},
|
||||
"Basalt": {"color": (0.10, 0.09, 0.09, 1), "metallic": 0.14, "roughness": 0.62},
|
||||
"Armor": {"color": (0.18, 0.16, 0.16, 1), "metallic": 0.22, "roughness": 0.48},
|
||||
"Lava": {"color": (1, 0.08, 0.003, 1), "roughness": 0.2, "emission": (1, 0.025, 0, 1), "strength": 9},
|
||||
})
|
||||
specs = [
|
||||
("Root", (0, 0, 0), (0, 0, 0.5), None), ("Body", (0, 0, 1.35), (0, 0, 2.5), "Root"),
|
||||
("Head", (0, -0.75, 2.05), (0, -1.75, 2.05), "Body"), ("Horn.L", (-0.45, -1.45, 2.35), (-1.2, -1.5, 2.55), "Head"),
|
||||
("Horn.R", (0.45, -1.45, 2.35), (1.2, -1.5, 2.55), "Head"), ("Leg.FL", (-0.7, -0.75, 1.45), (-0.78, -0.8, 0.35), "Body"),
|
||||
("Leg.FR", (0.7, -0.75, 1.45), (0.78, -0.8, 0.35), "Body"), ("Leg.BL", (-0.7, 0.8, 1.35), (-0.78, 0.8, 0.3), "Body"),
|
||||
("Leg.BR", (0.7, 0.8, 1.35), (0.78, 0.8, 0.3), "Body"), ("Tail", (0, 1.2, 1.75), (0, 2.25, 1.35), "Body"),
|
||||
]
|
||||
rig = armature("ObsidianRam", specs)
|
||||
ellipsoid("BodyCore", (0, 0.1, 1.95), (1.18, 1.48, 0.86), mats["Basalt"], "Body", 2)
|
||||
ellipsoid("ShoulderMass", (0, -0.62, 2.08), (1.28, 0.82, 1.0), mats["Obsidian"], "Body", 2)
|
||||
ellipsoid("Head", (0, -1.48, 1.93), (0.72, 0.78, 0.62), mats["Armor"], "Head", 2)
|
||||
cone("Snout", (0, -1.7, 1.92), (0, -2.25, 1.72), 0.48, 0.22, mats["Obsidian"], "Head")
|
||||
ellipsoid("Eye.L", (-0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
|
||||
ellipsoid("Eye.R", (0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
|
||||
for side, suffix in [(-1, "L"), (1, "R")]:
|
||||
points = [(side * 0.48, -1.55, 2.33), (side * 1.05, -1.62, 2.67), (side * 1.45, -1.44, 2.35), (side * 1.43, -1.25, 1.93), (side * 1.02, -1.45, 1.78)]
|
||||
for index in range(len(points) - 1):
|
||||
cone(f"Horn{suffix}_{index}", points[index], points[index + 1], 0.19 - index * 0.025, 0.15 - index * 0.025, mats["Obsidian"], f"Horn.{suffix}", 8)
|
||||
for front, y, bone in [(True, -0.72, f"Leg.F{suffix}"), (False, 0.82, f"Leg.B{suffix}")]:
|
||||
x = side * 0.78
|
||||
cone(f"Leg{suffix}_{'F' if front else 'B'}", (x, y, 1.45), (x * 1.05, y, 0.35), 0.34, 0.24, mats["Basalt"], bone, 7)
|
||||
ellipsoid(f"Hoof{suffix}_{'F' if front else 'B'}", (x * 1.06, y - 0.18, 0.25), (0.34, 0.46, 0.24), mats["Obsidian"], bone)
|
||||
cone("Tail", (0, 1.1, 1.72), (0, 2.22, 1.3), 0.38, 0.12, mats["Basalt"], "Tail", 7)
|
||||
for index, y in enumerate((-0.7, -0.15, 0.4, 0.95)):
|
||||
plate(f"BackPlate{index}", (0, y, 2.72 + index * 0.03), (0.92 - index * 0.08, 0.62, 0.32), (math.radians(82), 0, 0), mats["Armor"], "Body")
|
||||
cone(f"BackSpike{index}", (0, y, 2.86), (0, y + 0.05, 3.38 - index * 0.06), 0.17, 0, mats["Obsidian"], "Body", 6)
|
||||
for side in (-1, 1):
|
||||
cone(f"ShoulderSpike{side}", (side * 0.9, -0.55, 2.55), (side * 1.48, -0.5, 2.82), 0.18, 0, mats["Obsidian"], "Body", 6)
|
||||
for index, (start, end) in enumerate([((-0.12, -1.96, 2.22), (0.1, -1.98, 1.78)), ((-0.55, -0.98, 2.54), (-0.35, -0.99, 1.93)), ((0.48, 0.42, 2.55), (0.25, 0.45, 1.95))]):
|
||||
cone(f"LavaCrack{index}", start, end, 0.035, 0.02, mats["Lava"], "Head" if index == 0 else "Body", 5)
|
||||
body = join_parts("ObsidianRam", rig)
|
||||
clips = actions(rig, ram_actions())
|
||||
export_asset("obsidian-ram-golem", "Obsidian Ram Golem", rig, body, clips,
|
||||
[("Body", (0, 0, 1.7), (1.4, 1.75, 1.35)), ("Head", (0, -1.55, 1.85), (1.45, 0.85, 0.8))],
|
||||
(0, 0, 1.6), 8.5, "Quake", 16)
|
||||
|
||||
|
||||
def ram_actions():
|
||||
return [
|
||||
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 60}]),
|
||||
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"Leg.FL": (18, 0, 0), "Leg.BR": (18, 0, 0), "Leg.FR": (-18, 0, 0), "Leg.BL": (-18, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}]),
|
||||
("Charge", 30, False, [{"frame": 1}, {"frame": 8, "locations": {"Root": (0, 0.08, -0.12)}, "rotations": {"Head": (28, 0, 0), "Body": (8, 0, 0)}}, {"frame": 15, "locations": {"Root": (0, -0.18, 0.03)}, "rotations": {"Head": (18, 0, 0), "Leg.FL": (-24, 0, 0), "Leg.FR": (24, 0, 0)}}, {"frame": 23, "locations": {"Root": (0, -0.12, 0.08)}, "rotations": {"Head": (12, 0, 0), "Leg.FL": (24, 0, 0), "Leg.FR": (-24, 0, 0)}}, {"frame": 30}]),
|
||||
("Quake", 36, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0.08, 0.18)}, "rotations": {"Body": (-18, 0, 0), "Head": (-20, 0, 0)}}, {"frame": 17, "locations": {"Root": (0, -0.08, -0.18)}, "rotations": {"Body": (24, 0, 0), "Head": (34, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.03)}, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 36}]),
|
||||
("ArmorShatter", 42, False, [{"frame": 1}, {"frame": 10, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Head": (-12, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (0, 0, 10), "Horn.L": (0, -12, 0), "Horn.R": (0, 12, 0)}}, {"frame": 23, "rotations": {"Body": (0, 0, -8)}}, {"frame": 31, "rotations": {"Body": (0, 0, 4)}}, {"frame": 42}]),
|
||||
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.16, -0.08)}, "rotations": {"Body": (-15, 0, 12), "Head": (20, 0, -10)}}, {"frame": 13, "rotations": {"Body": (7, 0, -6)}}, {"frame": 26}]),
|
||||
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.15, 0.1, -0.35)}, "rotations": {"Root": (0, 35, 32), "Head": (26, 0, 10)}}, {"frame": 42, "locations": {"Root": (0.25, 0.1, -1.15)}, "rotations": {"Root": (0, 58, 82), "Head": (42, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}, {"frame": 70, "locations": {"Root": (0.25, 0.1, -1.2)}, "rotations": {"Root": (0, 58, 82), "Head": (44, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}]),
|
||||
]
|
||||
|
||||
|
||||
def build_cinderback():
|
||||
reset_scene()
|
||||
mats = prepare_materials({
|
||||
"Charcoal": {"color": (0.035, 0.03, 0.03, 1), "metallic": 0.18, "roughness": 0.55},
|
||||
"Plate": {"color": (0.16, 0.10, 0.075, 1), "metallic": 0.2, "roughness": 0.48},
|
||||
"Underbody": {"color": (0.18, 0.018, 0.01, 1), "roughness": 0.66},
|
||||
"Lava": {"color": (1, 0.07, 0.002, 1), "roughness": 0.18, "emission": (1, 0.02, 0, 1), "strength": 10},
|
||||
"Claw": {"color": (0.55, 0.32, 0.15, 1), "roughness": 0.4},
|
||||
})
|
||||
specs = [
|
||||
("Root", (0, 0, 0), (0, 0, 0.45), None), ("Body", (0, 0, 1.0), (0, 0, 1.95), "Root"),
|
||||
("Head", (0, -1.0, 1.25), (0, -1.9, 1.18), "Body"), ("Tail.1", (0, 1.05, 1.2), (0, 1.8, 1.05), "Body"),
|
||||
("Tail.2", (0, 1.75, 1.05), (0, 2.45, 0.8), "Tail.1"),
|
||||
("Leg.FL", (-0.65, -0.75, 0.95), (-0.82, -0.82, 0.25), "Body"), ("Leg.FR", (0.65, -0.75, 0.95), (0.82, -0.82, 0.25), "Body"),
|
||||
("Leg.BL", (-0.65, 0.65, 0.95), (-0.82, 0.7, 0.25), "Body"), ("Leg.BR", (0.65, 0.65, 0.95), (0.82, 0.7, 0.25), "Body"),
|
||||
]
|
||||
rig = armature("Cinderback", specs)
|
||||
ellipsoid("Underbody", (0, 0, 1.12), (1.05, 1.62, 0.65), mats["Underbody"], "Body", 2)
|
||||
ellipsoid("Head", (0, -1.42, 1.2), (0.68, 0.78, 0.54), mats["Plate"], "Head", 2)
|
||||
cone("Snout", (0, -1.58, 1.2), (0, -2.05, 1.05), 0.4, 0.18, mats["Charcoal"], "Head")
|
||||
ellipsoid("Eye.L", (-0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
|
||||
ellipsoid("Eye.R", (0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
|
||||
for index, y in enumerate((-1.02, -0.58, -0.12, 0.34, 0.78, 1.16)):
|
||||
width = 0.93 - abs(index - 2.5) * 0.055
|
||||
plate(f"ShellPlate{index}", (0, y, 1.73 + math.sin(index / 5 * math.pi) * 0.18), (width, 0.53, 0.28), (math.radians(82), 0, 0), mats["Plate"], "Body")
|
||||
cone(f"ShellSpike{index}", (0, y, 1.9), (0, y, 2.35 + math.sin(index / 5 * math.pi) * 0.15), 0.13, 0, mats["Charcoal"], "Body", 6)
|
||||
for side, suffix in [(-1, "L"), (1, "R")]:
|
||||
for front, y, bone in [(True, -0.68, f"Leg.F{suffix}"), (False, 0.68, f"Leg.B{suffix}")]:
|
||||
x = side * 0.7
|
||||
ellipsoid(f"LegMass{suffix}{front}", (x, y, 0.78), (0.38, 0.48, 0.42), mats["Charcoal"], bone)
|
||||
cone(f"Leg{suffix}{front}", (x, y, 0.72), (side * 0.92, y - 0.12, 0.22), 0.22, 0.14, mats["Underbody"], bone)
|
||||
for toe in (-0.12, 0, 0.12):
|
||||
cone(f"Toe{suffix}{front}{toe}", (side * 0.92 + toe, y - 0.22, 0.2), (side * 0.95 + toe, y - 0.58, 0.11), 0.05, 0, mats["Claw"], bone, 5)
|
||||
cone("Tail1", (0, 1.0, 1.12), (0, 1.85, 0.88), 0.42, 0.27, mats["Plate"], "Tail.1")
|
||||
cone("Tail2", (0, 1.78, 0.88), (0, 2.48, 0.68), 0.29, 0.06, mats["Charcoal"], "Tail.2")
|
||||
for index, (x, y, z) in enumerate([(-0.48, -0.82, 1.56), (0.45, -0.28, 1.68), (-0.42, 0.28, 1.72), (0.38, 0.8, 1.55)]):
|
||||
cone(f"LavaCrack{index}", (x, y, z), (x * 0.65, y + 0.18, z - 0.32), 0.035, 0.018, mats["Lava"], "Body", 5)
|
||||
body = join_parts("Cinderback", rig)
|
||||
clips = actions(rig, cinderback_actions())
|
||||
export_asset("cinderback-ricochet", "Cinderback Ricochet", rig, body, clips,
|
||||
[("Body", (0, 0, 1.05), (1.25, 1.85, 0.95)), ("Roll", (0, 0, 1.2), (1.45, 1.45, 1.45))],
|
||||
(0, 0, 1.15), 8.3, "ArmorSlam", 15)
|
||||
|
||||
|
||||
def cinderback_actions():
|
||||
return [
|
||||
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 60}]),
|
||||
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 17, "rotations": {"Leg.FL": (14, 0, 0), "Leg.BR": (14, 0, 0), "Leg.FR": (-14, 0, 0), "Leg.BL": (-14, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 32, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}]),
|
||||
("Curl", 28, False, [{"frame": 1}, {"frame": 9, "locations": {"Root": (0, 0, 0.12)}, "rotations": {"Head": (-42, 0, 0), "Tail.1": (50, 0, 0), "Tail.2": (65, 0, 0), "Leg.FL": (45, 0, 0), "Leg.FR": (45, 0, 0), "Leg.BL": (-45, 0, 0), "Leg.BR": (-45, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.02, 1.02, 1.02)}, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}, {"frame": 28, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}]),
|
||||
("Ricochet", 30, True, [{"frame": 1, "rotations": {"Root": (0, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 8, "rotations": {"Root": (90, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 16, "rotations": {"Root": (180, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 23, "rotations": {"Root": (270, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 30, "rotations": {"Root": (360, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}]),
|
||||
("ArmorSlam", 38, False, [{"frame": 1}, {"frame": 11, "locations": {"Root": (0, 0.1, 0.22)}, "rotations": {"Body": (-22, 0, 0), "Head": (-18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.08, -0.16)}, "rotations": {"Body": (28, 0, 0), "Head": (34, 0, 0)}}, {"frame": 24, "rotations": {"Body": (-8, 0, 0)}}, {"frame": 38}]),
|
||||
("Recover", 24, False, [{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (18, 0, 0)}}, {"frame": 8, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 16, "rotations": {"Body": (2, 0, 0)}}, {"frame": 24}]),
|
||||
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.14, -0.08)}, "rotations": {"Body": (-12, 0, 11), "Head": (22, 0, -10)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
|
||||
("Death", 66, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.25)}, "rotations": {"Root": (0, 28, 35), "Head": (24, 0, 0)}}, {"frame": 42, "locations": {"Root": (0.18, 0.1, -0.8)}, "rotations": {"Root": (0, 50, 78), "Head": (38, 0, 0), "Tail.1": (-25, 0, 0)}}, {"frame": 66, "locations": {"Root": (0.18, 0.1, -0.84)}, "rotations": {"Root": (0, 50, 78), "Head": (40, 0, 0), "Tail.1": (-28, 0, 0)}}]),
|
||||
]
|
||||
|
||||
|
||||
def build_scorpion():
|
||||
reset_scene()
|
||||
mats = prepare_materials({
|
||||
"Obsidian": {"color": (0.025, 0.025, 0.03, 1), "metallic": 0.2, "roughness": 0.5},
|
||||
"Ochre": {"color": (0.42, 0.19, 0.045, 1), "metallic": 0.16, "roughness": 0.44},
|
||||
"Gold": {"color": (0.85, 0.38, 0.035, 1), "metallic": 0.3, "roughness": 0.28},
|
||||
"Glass": {"color": (1, 0.38, 0.02, 1), "roughness": 0.12, "emission": (1, 0.16, 0.01, 1), "strength": 4},
|
||||
"Bone": {"color": (0.58, 0.38, 0.17, 1), "roughness": 0.38},
|
||||
})
|
||||
specs = [
|
||||
("Root", (0, 0, 0), (0, 0, 0.4), None), ("Body", (0, 0, 0.85), (0, 0, 1.55), "Root"),
|
||||
("Head", (0, -0.65, 1.0), (0, -1.35, 0.92), "Body"), ("Claw.L", (-0.55, -0.8, 1.0), (-1.45, -1.6, 0.9), "Body"),
|
||||
("Claw.R", (0.55, -0.8, 1.0), (1.45, -1.6, 0.9), "Body"), ("LegFront.L", (-0.5, -0.25, 0.8), (-1.4, -0.55, 0.25), "Body"),
|
||||
("LegFront.R", (0.5, -0.25, 0.8), (1.4, -0.55, 0.25), "Body"), ("LegBack.L", (-0.5, 0.45, 0.8), (-1.4, 0.75, 0.25), "Body"),
|
||||
("LegBack.R", (0.5, 0.45, 0.8), (1.4, 0.75, 0.25), "Body"),
|
||||
("Tail.1", (0, 0.65, 1.0), (0, 1.35, 1.35), "Body"), ("Tail.2", (0, 1.3, 1.35), (0, 1.75, 2.0), "Tail.1"),
|
||||
("Tail.3", (0, 1.7, 2.0), (0, 1.5, 2.68), "Tail.2"), ("Tail.4", (0, 1.5, 2.68), (0, 0.82, 3.0), "Tail.3"),
|
||||
("Stinger", (0, 0.82, 3.0), (0, 0.18, 2.52), "Tail.4"),
|
||||
]
|
||||
rig = armature("SandglassScorpion", specs)
|
||||
ellipsoid("Abdomen", (0, 0.28, 1.0), (0.92, 1.12, 0.54), mats["Obsidian"], "Body", 2)
|
||||
ellipsoid("Torso", (0, -0.55, 0.98), (0.76, 0.7, 0.48), mats["Ochre"], "Body", 2)
|
||||
ellipsoid("Head", (0, -1.12, 0.9), (0.52, 0.48, 0.36), mats["Obsidian"], "Head", 1)
|
||||
ellipsoid("Hourglass", (0, 0.15, 1.48), (0.42, 0.55, 0.24), mats["Glass"], "Body", 2)
|
||||
ellipsoid("HourglassWaist", (0, 0.15, 1.5), (0.44, 0.13, 0.26), mats["Obsidian"], "Body", 1)
|
||||
for side, suffix in [(-1, "L"), (1, "R")]:
|
||||
ellipsoid(f"Eye{suffix}", (side * 0.31, -1.42, 1.02), (0.06, 0.04, 0.06), mats["Glass"], "Head")
|
||||
cone(f"ClawArm{suffix}", (side * 0.45, -0.65, 1.0), (side * 1.25, -1.3, 0.9), 0.22, 0.16, mats["Ochre"], f"Claw.{suffix}")
|
||||
ellipsoid(f"ClawBody{suffix}", (side * 1.55, -1.55, 0.9), (0.62, 0.75, 0.42), mats["Gold"], f"Claw.{suffix}", 1, rotation=(0, 0, math.radians(side * 8)))
|
||||
ellipsoid(f"ClawGlass{suffix}", (side * 1.55, -1.68, 0.95), (0.37, 0.42, 0.22), mats["Glass"], f"Claw.{suffix}")
|
||||
cone(f"ClawTipOuter{suffix}", (side * 1.62, -1.98, 0.92), (side * 1.9, -2.45, 0.8), 0.2, 0, mats["Bone"], f"Claw.{suffix}")
|
||||
cone(f"ClawTipInner{suffix}", (side * 1.42, -1.98, 0.88), (side * 1.25, -2.32, 0.76), 0.14, 0, mats["Bone"], f"Claw.{suffix}")
|
||||
for index, (y, z) in enumerate([(-0.65, 0.7), (-0.18, 0.62), (0.32, 0.62), (0.78, 0.68)]):
|
||||
bone = f"LegFront.{suffix}" if index < 2 else f"LegBack.{suffix}"
|
||||
start = (side * 0.52, y, z + 0.18)
|
||||
joint = (side * (1.08 + index * 0.08), y + (index - 1.5) * 0.12, z)
|
||||
end = (side * (1.45 + index * 0.09), y + (index - 1.5) * 0.24, 0.16)
|
||||
cone(f"Leg{suffix}{index}A", start, joint, 0.14, 0.1, mats["Ochre"], bone, 6)
|
||||
cone(f"Leg{suffix}{index}B", joint, end, 0.1, 0.035, mats["Obsidian"], bone, 6)
|
||||
tail_points = [(0, 0.62, 1.02), (0, 1.35, 1.36), (0, 1.75, 2.0), (0, 1.5, 2.68), (0, 0.82, 3.0), (0, 0.18, 2.52)]
|
||||
tail_bones = ["Tail.1", "Tail.2", "Tail.3", "Tail.4", "Stinger"]
|
||||
for index, bone in enumerate(tail_bones):
|
||||
cone(f"TailSegment{index}", tail_points[index], tail_points[index + 1], 0.32 - index * 0.045, 0.25 - index * 0.04, mats["Ochre"] if index < 4 else mats["Gold"], bone, 8)
|
||||
if index < 4:
|
||||
plate(f"TailPlate{index}", tail_points[index + 1], (0.38 - index * 0.03, 0.32, 0.2), (0, 0, 0), mats["Gold"], bone)
|
||||
cone("StingerTip", (0, 0.18, 2.52), (0, -0.15, 2.18), 0.18, 0, mats["Glass"], "Stinger", 7)
|
||||
body = join_parts("SandglassScorpion", rig)
|
||||
clips = actions(rig, scorpion_actions())
|
||||
export_asset("sandglass-scorpion", "Sandglass Scorpion", rig, body, clips,
|
||||
[("Body", (0, -0.1, 0.85), (1.15, 1.45, 0.8)), ("Claws", (0, -1.55, 0.9), (2.15, 1.0, 0.65)), ("Tail", (0, 1.0, 2.0), (0.65, 1.4, 1.35))],
|
||||
(0, -0.1, 1.3), 9.5, "Eruption", 16)
|
||||
|
||||
|
||||
def scorpion_actions():
|
||||
return [
|
||||
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, 4), "Claw.L": (0, 0, -3), "Claw.R": (0, 0, 3)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, -4), "Claw.L": (0, 0, 3), "Claw.R": (0, 0, -3)}}, {"frame": 60}]),
|
||||
("Walk", 32, True, [{"frame": 1, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"LegFront.L": (16, 0, -5), "LegBack.R": (16, 0, 5), "LegFront.R": (-16, 0, 5), "LegBack.L": (-16, 0, -5)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}]),
|
||||
("ClawAttack", 28, False, [{"frame": 1}, {"frame": 9, "rotations": {"Claw.L": (-10, 18, 30), "Claw.R": (-10, -18, -30)}}, {"frame": 14, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Claw.L": (16, -10, -38), "Claw.R": (16, 10, 38), "Head": (-8, 0, 0)}}, {"frame": 21, "rotations": {"Claw.L": (5, 0, -12), "Claw.R": (5, 0, 12)}}, {"frame": 28}]),
|
||||
("Burrow", 34, False, [{"frame": 1}, {"frame": 10, "locations": {"Root": (0, 0, -0.15)}, "rotations": {"Claw.L": (35, 0, 0), "Claw.R": (35, 0, 0), "Tail.1": (-20, 0, 0)}}, {"frame": 20, "locations": {"Root": (0, 0, -0.62)}, "scales": {"Body": (0.92, 0.92, 0.92)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}, {"frame": 34, "locations": {"Root": (0, 0, -0.72)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}]),
|
||||
("Eruption", 38, False, [{"frame": 1}, {"frame": 11, "rotations": {"Tail.2": (-18, 0, 0), "Tail.3": (-22, 0, 0), "Tail.4": (24, 0, 0), "Stinger": (18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.05, 0.1)}, "rotations": {"Tail.2": (22, 0, 0), "Tail.3": (34, 0, 0), "Tail.4": (-42, 0, 0), "Stinger": (-38, 0, 0)}}, {"frame": 24, "rotations": {"Tail.3": (10, 0, 0), "Tail.4": (-12, 0, 0)}}, {"frame": 38}]),
|
||||
("Hourglass", 44, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "rotations": {"Body": (-10, 0, 0), "Claw.L": (0, 0, -24), "Claw.R": (0, 0, 24), "Tail.3": (-18, 0, 0)}}, {"frame": 20, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Claw.L": (0, 0, 38), "Claw.R": (0, 0, -38), "Tail.3": (22, 0, 0)}}, {"frame": 30, "scales": {"Body": (0.96, 0.96, 0.96)}, "rotations": {"Claw.L": (0, 0, -16), "Claw.R": (0, 0, 16)}}, {"frame": 44}]),
|
||||
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.12, -0.08)}, "rotations": {"Body": (-12, 0, 10), "Claw.L": (-15, 0, -18), "Claw.R": (-15, 0, 18), "Tail.2": (18, 0, 0)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
|
||||
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.3)}, "rotations": {"Root": (0, 30, 38), "Tail.2": (-28, 0, 0), "Claw.L": (25, 0, -30), "Claw.R": (-10, 0, 30)}}, {"frame": 44, "locations": {"Root": (0.18, 0.1, -0.78)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-48, 0, 0), "Tail.3": (-32, 0, 0), "Claw.L": (38, 0, -42), "Claw.R": (12, 0, 38)}}, {"frame": 70, "locations": {"Root": (0.18, 0.1, -0.82)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-50, 0, 0), "Tail.3": (-34, 0, 0), "Claw.L": (40, 0, -44), "Claw.R": (14, 0, 40)}}]),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
bpy.context.preferences.filepaths.save_version = 0
|
||||
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
build_ram()
|
||||
build_cinderback()
|
||||
build_scorpion()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Build animation-ready GLBs for the locally-authored inspired creature set.
|
||||
|
||||
The legacy source FBX files use ASCII encoding, which modern Blender cannot read.
|
||||
Assimp converts each DAE to glTF without changing the source assets; Blender then
|
||||
adds reusable actions and exports a texture-packed GLB beside each source model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
|
||||
FPS = 30
|
||||
CLIP_SPECS = {
|
||||
"Idle": {"frames": 60, "loop": True},
|
||||
"Move": {"frames": 36, "loop": True},
|
||||
"Attack": {"frames": 36, "loop": False},
|
||||
"HitReact": {"frames": 24, "loop": False},
|
||||
"Death": {"frames": 72, "loop": False},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetProfile:
|
||||
asset_id: str
|
||||
display_name: str
|
||||
body: tuple[str, ...]
|
||||
creature: str
|
||||
red_eyes_roles: bool = False
|
||||
|
||||
|
||||
PROFILES = (
|
||||
AssetProfile("blue-eyes-ultimate-dragon", "Tri-Headed Ivory Dragon", ("BEUD_UpperBody", "BEUD_Lumbar1"), "dragon"),
|
||||
AssetProfile("blue-eyes-white-dragon", "Ivory-Eyed Sky Dragon", ("MMD_004007_UpperBody", "MMD_004007_Lumbar1"), "dragon"),
|
||||
AssetProfile("gandora-the-dragon-of-destruction", "Ruin-Orb Black Dragon", ("UpperBody", "Lumber1"), "dragon"),
|
||||
AssetProfile("gate-guardian", "Tri-Element Gate Sentinel", ("Spine", "Spine1", "Spine2"), "guardian"),
|
||||
AssetProfile("insect-queen", "Royal Carapace Matriarch", ("UpperBody", "Spine1"), "insect"),
|
||||
AssetProfile("pumpking-the-king-of-ghosts", "Crowned Vine Wraith", ("hips", "upper_01", "upper_02"), "wraith"),
|
||||
AssetProfile("red-eyes-black-dragon", "Crimson-Eyed Night Dragon", ("bone0000", "bone0076", "bone0009"), "dragon", True),
|
||||
)
|
||||
|
||||
|
||||
RED_EYES = {
|
||||
"body": ("bone0000", "bone0076", "bone0009", "bone0078", "bone0057"),
|
||||
"head": ("bone0025", "bone0031", "bone0047", "bone0073"),
|
||||
"jaw": ("bone0001", "bone0002", "bone0029", "bone0010", "bone0020"),
|
||||
"wing_l": ("bone0016", "bone0006", "bone0049", "bone0032", "bone0003", "bone0058", "bone0008", "bone0063", "bone0079", "bone0069", "bone0013", "bone0030", "bone0077"),
|
||||
"wing_r": ("bone0033", "bone0012", "bone0055", "bone0046", "bone0021", "bone0045", "bone0041", "bone0060", "bone0075", "bone0070", "bone0042", "bone0024", "bone0040"),
|
||||
"arm_l": ("bone0048", "bone0061", "bone0005", "bone0015", "bone0038", "bone0017", "bone0028", "bone0023"),
|
||||
"arm_r": ("bone0059", "bone0027", "bone0071", "bone0022", "bone0052", "bone0064", "bone0056", "bone0074", "bone0067"),
|
||||
"leg_l": ("bone0004", "bone0039", "bone0051", "bone0014", "bone0035", "bone0050", "bone0066"),
|
||||
"leg_r": ("bone0007", "bone0043", "bone0011", "bone0036", "bone0026", "bone0037", "bone0062"),
|
||||
"tail": ("bone0034", "bone0053", "bone0019", "bone0044", "bone0054", "bone0065", "bone0072", "bone0018"),
|
||||
}
|
||||
|
||||
|
||||
ULTIMATE_HEAD_CHAINS = {
|
||||
"head_l": tuple(f"BEUD_LNeck{index}" for index in range(1, 7)) + ("BEUD_LHead",),
|
||||
"head_c": tuple(f"BEUD_Neck{index}" for index in range(1, 7)) + ("BEUD_Head",),
|
||||
"head_r": tuple(f"BEUD_RNeck{index}" for index in range(1, 7)) + ("BEUD_RHead",),
|
||||
}
|
||||
|
||||
|
||||
def reset_scene() -> None:
|
||||
if bpy.context.object and bpy.context.object.mode != "OBJECT":
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
for obj in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.images, bpy.data.actions, bpy.data.cameras, bpy.data.lights):
|
||||
for block in list(blocks):
|
||||
blocks.remove(block)
|
||||
|
||||
|
||||
def parse_smd_bones(path: Path, count: int) -> list[str]:
|
||||
nodes: list[tuple[int, str, int]] = []
|
||||
in_nodes = False
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
if line == "nodes":
|
||||
in_nodes = True
|
||||
continue
|
||||
if in_nodes and line == "end":
|
||||
break
|
||||
if not in_nodes:
|
||||
continue
|
||||
match = re.fullmatch(r'(\d+)\s+"(.+)"\s+(-?\d+)', line)
|
||||
if match:
|
||||
nodes.append((int(match.group(1)), match.group(2), int(match.group(3))))
|
||||
if len(nodes) < count:
|
||||
raise RuntimeError(f"{path}: found {len(nodes)} nodes for {count} Blender bones")
|
||||
return [name for _, name, _ in nodes[-count:]]
|
||||
|
||||
|
||||
def convert_and_import(profile: AssetProfile) -> tuple[bpy.types.Object, list[bpy.types.Object], Path]:
|
||||
source_dir = MODEL_ROOT / profile.asset_id
|
||||
dae_path = next(source_dir.glob("MMD_*.dae"))
|
||||
smd_path = dae_path.with_suffix(".smd")
|
||||
with tempfile.TemporaryDirectory(prefix=f"thor-{profile.asset_id}-") as temp_dir_name:
|
||||
temp_dir = Path(temp_dir_name)
|
||||
gltf_path = temp_dir / "model.gltf"
|
||||
subprocess.run(
|
||||
["assimp", "export", str(dae_path), str(gltf_path), "-f", "gltf2"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
for image_path in source_dir.glob("*.png"):
|
||||
shutil.copy2(image_path, temp_dir / image_path.name)
|
||||
bpy.ops.import_scene.gltf(filepath=str(gltf_path), import_pack_images=True)
|
||||
|
||||
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
|
||||
if len(armatures) != 1:
|
||||
raise RuntimeError(f"{profile.asset_id}: expected one armature")
|
||||
rig = armatures[0]
|
||||
meshes = [
|
||||
obj for obj in bpy.context.scene.objects
|
||||
if obj.type == "MESH" and (
|
||||
obj.parent == rig
|
||||
or any(modifier.type == "ARMATURE" and modifier.object == rig for modifier in obj.modifiers)
|
||||
)
|
||||
]
|
||||
if not meshes:
|
||||
raise RuntimeError(f"{profile.asset_id}: expected one armature and at least one mesh")
|
||||
for helper in [obj for obj in bpy.context.scene.objects if obj.type == "MESH" and obj not in meshes]:
|
||||
bpy.data.objects.remove(helper, do_unlink=True)
|
||||
source_names = parse_smd_bones(smd_path, len(rig.data.bones))
|
||||
old_names = [bone.name for bone in rig.data.bones]
|
||||
for old_name, source_name in zip(old_names, source_names, strict=True):
|
||||
rig.data.bones[old_name].name = source_name
|
||||
for mesh in meshes:
|
||||
group = mesh.vertex_groups.get(old_name)
|
||||
if group:
|
||||
group.name = source_name
|
||||
|
||||
rig.name = f"{profile.asset_id}_Rig"
|
||||
rig.data.name = f"{profile.asset_id}_Armature"
|
||||
for index, mesh in enumerate(meshes):
|
||||
mesh.name = f"{profile.asset_id}_Mesh_{index:02d}"
|
||||
mesh.data.name = f"{profile.asset_id}_Geometry_{index:02d}"
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
rig.animation_data_clear()
|
||||
return rig, meshes, dae_path
|
||||
|
||||
|
||||
def side(name: str) -> str | None:
|
||||
lower = name.lower()
|
||||
if "left" in lower or re.search(r"(^|[_\.])l(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
|
||||
return "l"
|
||||
if "right" in lower or re.search(r"(^|[_\.])r(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
|
||||
return "r"
|
||||
if lower.startswith("l") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
|
||||
return "l"
|
||||
if lower.startswith("r") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
|
||||
return "r"
|
||||
return None
|
||||
|
||||
|
||||
def build_roles(profile: AssetProfile, rig: bpy.types.Object) -> dict[str, list[str]]:
|
||||
names = [bone.name for bone in rig.data.bones]
|
||||
if profile.red_eyes_roles:
|
||||
return {role: [name for name in role_names if name in names] for role, role_names in RED_EYES.items()}
|
||||
roles = {key: [] for key in ("body", "head", "jaw", "wing_l", "wing_r", "arm_l", "arm_r", "leg_l", "leg_r", "tail")}
|
||||
roles["body"] = [name for name in profile.body if name in names]
|
||||
for name in names:
|
||||
lower = name.lower()
|
||||
bone_side = side(name)
|
||||
if "wing" in lower:
|
||||
if bone_side:
|
||||
roles[f"wing_{bone_side}"].append(name)
|
||||
elif any(token in lower for token in ("arm", "hand", "shoulder", "bicep", "forearm")):
|
||||
if bone_side:
|
||||
roles[f"arm_{bone_side}"].append(name)
|
||||
elif any(token in lower for token in ("foot", "thigh", "shin", "upleg")) or (profile.creature == "insect" and "foot" in lower):
|
||||
if bone_side:
|
||||
roles[f"leg_{bone_side}"].append(name)
|
||||
if "tail" in lower:
|
||||
roles["tail"].append(name)
|
||||
if "head" in lower or (profile.creature == "insect" and lower in {"neck1", "neck2"}):
|
||||
roles["head"].append(name)
|
||||
if any(token in lower for token in ("jaw", "chin", "mandible", "mouth")):
|
||||
roles["jaw"].append(name)
|
||||
if profile.creature == "wraith":
|
||||
roles["arm_l"] = [name for name in names if name.lower().startswith("l_arm")]
|
||||
roles["arm_r"] = [name for name in names if name.lower().startswith("r_arm")]
|
||||
roles["head"].extend(name for name in ("eye", "crown", "eye_upper", "eye_lower") if name in names)
|
||||
roles["jaw"].extend(name for name in ("chin_upper", "chin_lower", "under") if name in names)
|
||||
if profile.creature == "insect":
|
||||
roles["head"].extend(name for name in names if "sensor" in name.lower())
|
||||
if profile.asset_id == "blue-eyes-ultimate-dragon":
|
||||
for role, chain in ULTIMATE_HEAD_CHAINS.items():
|
||||
roles[role] = [name for name in chain if name in names]
|
||||
return {role: list(dict.fromkeys(role_names)) for role, role_names in roles.items()}
|
||||
|
||||
|
||||
def rotate_role(roles: dict[str, list[str]], role: str, degrees: tuple[float, float, float], limit: int = 99, falloff: float = 0.72) -> dict[str, tuple[float, float, float]]:
|
||||
result = {}
|
||||
for index, name in enumerate(roles.get(role, [])[:limit]):
|
||||
factor = falloff ** index
|
||||
result[name] = tuple(component * factor for component in degrees)
|
||||
return result
|
||||
|
||||
|
||||
def merge(*maps: dict[str, tuple[float, float, float]]) -> dict[str, tuple[float, float, float]]:
|
||||
merged: dict[str, tuple[float, float, float]] = {}
|
||||
for values in maps:
|
||||
for name, rotation in values.items():
|
||||
previous = merged.get(name, (0.0, 0.0, 0.0))
|
||||
merged[name] = tuple(previous[index] + rotation[index] for index in range(3))
|
||||
return merged
|
||||
|
||||
|
||||
def dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
|
||||
neutral: dict[str, tuple[float, float, float]] = {}
|
||||
wings_up = merge(rotate_role(roles, "wing_l", (0, -14, 18), 5), rotate_role(roles, "wing_r", (0, 14, -18), 5))
|
||||
wings_down = merge(rotate_role(roles, "wing_l", (0, 18, -14), 5), rotate_role(roles, "wing_r", (0, -18, 14), 5))
|
||||
tail_l = rotate_role(roles, "tail", (0, 0, 8), 8, 0.82)
|
||||
tail_r = rotate_role(roles, "tail", (0, 0, -8), 8, 0.82)
|
||||
legs_a = merge(rotate_role(roles, "leg_l", (10, 0, 0), 2), rotate_role(roles, "leg_r", (-10, 0, 0), 2))
|
||||
legs_b = merge(rotate_role(roles, "leg_l", (-10, 0, 0), 2), rotate_role(roles, "leg_r", (10, 0, 0), 2))
|
||||
return {
|
||||
"Idle": [(1, neutral), (16, merge(wings_up, tail_l, rotate_role(roles, "head", (2, 0, 0), 3))), (31, neutral), (46, merge(wings_down, tail_r, rotate_role(roles, "head", (-2, 0, 0), 3))), (60, neutral)],
|
||||
"Move": [(1, merge(wings_up, legs_a, tail_l)), (10, neutral), (19, merge(wings_down, legs_b, tail_r)), (28, neutral), (36, merge(wings_up, legs_a, tail_l))],
|
||||
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), wings_up, rotate_role(roles, "head", (-14, 0, 0), 4), rotate_role(roles, "jaw", (-8, 0, 0), 3))), (16, merge(rotate_role(roles, "body", (17, 0, 0), 2), wings_down, rotate_role(roles, "head", (24, 0, 0), 5), rotate_role(roles, "jaw", (24, 0, 0), 4), rotate_role(roles, "arm_l", (18, 0, -14), 3), rotate_role(roles, "arm_r", (18, 0, 14), 3))), (25, merge(rotate_role(roles, "head", (5, 0, 0), 4), rotate_role(roles, "jaw", (8, 0, 0), 3))), (36, neutral)],
|
||||
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), rotate_role(roles, "head", (-22, 0, -10), 5), wings_up)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
|
||||
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), rotate_role(roles, "head", (18, 0, 8), 5), wings_down)), (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (28, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (30, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5)))],
|
||||
}
|
||||
|
||||
|
||||
def ultimate_head_pose(
|
||||
roles: dict[str, list[str]],
|
||||
left: tuple[float, float, float],
|
||||
center: tuple[float, float, float],
|
||||
right: tuple[float, float, float],
|
||||
) -> dict[str, tuple[float, float, float]]:
|
||||
"""Bend each long neck as a chain; rotating only head bones reads as static."""
|
||||
result: dict[str, tuple[float, float, float]] = {}
|
||||
weights = (0.10, 0.14, 0.17, 0.18, 0.16, 0.12, 0.22)
|
||||
for role, motion in zip(("head_l", "head_c", "head_r"), (left, center, right), strict=True):
|
||||
pitch, yaw, roll = motion
|
||||
for index, name in enumerate(roles.get(role, [])):
|
||||
weight = weights[min(index, len(weights) - 1)]
|
||||
result[name] = (roll * weight, pitch * weight, yaw * weight)
|
||||
return result
|
||||
|
||||
|
||||
def ultimate_living_heads(
|
||||
roles: dict[str, list[str]],
|
||||
progress: float,
|
||||
intensity: float = 1.0,
|
||||
) -> dict[str, tuple[float, float, float]]:
|
||||
"""Use distinct harmonic timing so three heads never bob in lockstep."""
|
||||
phase = math.tau * progress
|
||||
left = (
|
||||
intensity * (2.8 * math.sin(phase) + 0.8 * math.sin(phase * 2 + 0.35)),
|
||||
intensity * (2.0 * math.sin(phase * 2 + 0.75)),
|
||||
intensity * 0.8 * math.sin(phase + 0.2),
|
||||
)
|
||||
center = (
|
||||
intensity * (2.5 * math.sin(phase + 2.05) + 0.65 * math.sin(phase * 3 + 0.25)),
|
||||
intensity * (1.5 * math.sin(phase * 3 + 1.4)),
|
||||
intensity * 0.65 * math.sin(phase + 2.6),
|
||||
)
|
||||
right = (
|
||||
intensity * (2.7 * math.sin(phase + 4.15) + 0.75 * math.sin(phase * 2 + 1.15)),
|
||||
intensity * (1.9 * math.sin(phase * 2 + 3.7)),
|
||||
intensity * 0.75 * math.sin(phase + 4.55),
|
||||
)
|
||||
return ultimate_head_pose(roles, left, center, right)
|
||||
|
||||
|
||||
def ultimate_wings(flare: float) -> dict[str, tuple[float, float, float]]:
|
||||
"""Flap around local Z only; local Y sweeps these wings around body."""
|
||||
result: dict[str, tuple[float, float, float]] = {}
|
||||
for prefix, sign in (("BEUD_LWing", 1), ("BEUD_RWing", -1)):
|
||||
for index, weight in ((1, 1.0), (2, 0.34), (3, 0.12)):
|
||||
result[f"{prefix}{index}"] = (0.0, 0.0, sign * flare * weight)
|
||||
return result
|
||||
|
||||
|
||||
def ultimate_jaws(left: float, center: float, right: float) -> dict[str, tuple[float, float, float]]:
|
||||
"""Jaw hinges use local Y; local X would twist along snout."""
|
||||
return {
|
||||
"BEUD_LChin": (0.0, left, 0.0),
|
||||
"BEUD_Chin": (0.0, center, 0.0),
|
||||
"BEUD_RChin": (0.0, right, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def ultimate_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
|
||||
idle = []
|
||||
for frame in (1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 60):
|
||||
progress = (frame - 1) / 59
|
||||
idle.append((frame, merge(
|
||||
ultimate_living_heads(roles, progress),
|
||||
ultimate_wings(1.8 * math.sin(math.tau * progress)),
|
||||
rotate_role(roles, "tail", (0, 0, 4.0 * math.sin(math.tau * progress + 0.6)), 8, 0.82),
|
||||
)))
|
||||
|
||||
move = []
|
||||
for frame in (1, 5, 9, 13, 17, 21, 25, 29, 33, 36):
|
||||
progress = (frame - 1) / 35
|
||||
stride = math.sin(math.tau * progress)
|
||||
move.append((frame, merge(
|
||||
ultimate_living_heads(roles, progress, 1.2),
|
||||
ultimate_wings(7.0 * stride),
|
||||
rotate_role(roles, "tail", (0, 0, 5.5 * math.sin(math.tau * progress + 0.8)), 8, 0.82),
|
||||
rotate_role(roles, "leg_l", (5.0 * stride, 0, 0), 2),
|
||||
rotate_role(roles, "leg_r", (-5.0 * stride, 0, 0), 2),
|
||||
)))
|
||||
|
||||
attack_specs = (
|
||||
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
|
||||
(6, (-4, 2, -1), (-2, -1, 0), (-1, -2, 1), (0, 0, 0), 2),
|
||||
(10, (-8, 3, -1), (-5, 0, 0), (-3, -3, 1), (4, 0, 0), 4),
|
||||
(14, (11, -2, 1), (-8, 1, 0), (-5, -1, 1), (24, 3, 0), -3),
|
||||
(17, (6, 1, 0), (12, 0, 0), (-8, 2, -1), (8, 25, 3), -4),
|
||||
(21, (3, -1, 0), (7, -1, 0), (13, 2, -1), (2, 9, 25), -2),
|
||||
(28, (-1, 1, 0), (2, 0, 0), (5, -1, 0), (0, 2, 8), 1),
|
||||
(36, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
|
||||
)
|
||||
attack = [
|
||||
(frame, merge(
|
||||
ultimate_head_pose(roles, left, center, right),
|
||||
ultimate_jaws(*jaws),
|
||||
ultimate_wings(wing),
|
||||
))
|
||||
for frame, left, center, right, jaws, wing in attack_specs
|
||||
]
|
||||
|
||||
hit_specs = (
|
||||
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
|
||||
(4, (-10, -3, 2), (-6, 2, -1), (-4, 3, -2), (-14, 0, 10), 4),
|
||||
(7, (-5, 2, -1), (-11, -2, 1), (-7, -3, 2), (-8, 0, 6), 2),
|
||||
(11, (3, -1, 0), (-4, 1, 0), (-10, 2, -1), (5, 0, -4), -1),
|
||||
(16, (-1, 1, 0), (3, -1, 0), (-2, 0, 0), (2, 0, -2), 0),
|
||||
(24, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
|
||||
)
|
||||
hit_react = [
|
||||
(frame, merge(
|
||||
ultimate_head_pose(roles, left, center, right),
|
||||
rotate_role(roles, "body", body, 2),
|
||||
ultimate_wings(wing),
|
||||
))
|
||||
for frame, left, center, right, body, wing in hit_specs
|
||||
]
|
||||
|
||||
death_specs = (
|
||||
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
|
||||
(14, (-5, -3, 2), (-3, 2, -1), (-7, 2, -2), (-10, 0, 8), 2),
|
||||
(25, (-14, -7, 4), (-8, 4, -2), (-18, 6, -5), (-16, 8, 18), -3),
|
||||
(42, (-23, -10, 7), (-17, 6, -4), (-28, 9, -8), (8, 48, 58), -7),
|
||||
(58, (-30, -13, 9), (-24, 8, -6), (-34, 11, -10), (10, 62, 70), -9),
|
||||
(72, (-32, -14, 10), (-27, 9, -7), (-36, 12, -11), (10, 62, 70), -9),
|
||||
)
|
||||
death = [
|
||||
(frame, merge(
|
||||
ultimate_head_pose(roles, left, center, right),
|
||||
rotate_role(roles, "body", body, 2),
|
||||
ultimate_wings(wing),
|
||||
))
|
||||
for frame, left, center, right, body, wing in death_specs
|
||||
]
|
||||
|
||||
return {"Idle": idle, "Move": move, "Attack": attack, "HitReact": hit_react, "Death": death}
|
||||
|
||||
|
||||
def red_eyes_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
|
||||
"""Author Red-Eyes motion around its unusually oriented local bone axes.
|
||||
|
||||
This rig's wing bones do not share the local axes used by the named dragon
|
||||
rigs. Rotating every wing segment as one role makes each membrane chain curl
|
||||
forward like an arm. Instead, rotate the two shoulder roots in mirrored
|
||||
armature-space directions and add only a small elbow fold. Both sides use
|
||||
identical keyframe timing so they read as one aerodynamic flap.
|
||||
"""
|
||||
neutral: dict[str, tuple[float, float, float]] = {}
|
||||
wings_up = {
|
||||
"bone0016": (38.0, 32.8, 38.3),
|
||||
"bone0033": (-38.0, -32.8, 38.3),
|
||||
"bone0006": (-11.3, 14.0, -0.4),
|
||||
"bone0012": (11.3, -14.0, -0.4),
|
||||
}
|
||||
wings_down = {
|
||||
"bone0016": (-12.8, -28.5, -13.1),
|
||||
"bone0033": (12.8, 28.5, -13.1),
|
||||
"bone0006": (7.6, -9.3, -1.3),
|
||||
"bone0012": (-7.6, 9.3, -1.3),
|
||||
}
|
||||
wings_recover = {
|
||||
"bone0016": (15.5, 16.0, 15.7),
|
||||
"bone0033": (-15.5, -16.0, 15.7),
|
||||
"bone0006": (-5.0, 6.0, -0.2),
|
||||
"bone0012": (5.0, -6.0, -0.2),
|
||||
}
|
||||
tail_l = rotate_role(roles, "tail", (0, 0, 7), 5, 0.78)
|
||||
tail_r = rotate_role(roles, "tail", (0, 0, -7), 5, 0.78)
|
||||
legs_a = merge(rotate_role(roles, "leg_l", (8, 0, 0), 2), rotate_role(roles, "leg_r", (-8, 0, 0), 2))
|
||||
legs_b = merge(rotate_role(roles, "leg_l", (-8, 0, 0), 2), rotate_role(roles, "leg_r", (8, 0, 0), 2))
|
||||
|
||||
# The neck runs forward/down in armature space. Its local Z axis is the
|
||||
# pitch axis: negative lifts the head for anticipation; positive drives the
|
||||
# snout down through the bite. Jaw roots open during the windup, then close
|
||||
# at impact instead of swinging the forelimbs as the attack silhouette.
|
||||
head_lift = {"bone0025": (0, 0, 45), "bone0031": (0, 0, 15)}
|
||||
head_bite = {"bone0025": (0, 0, -32), "bone0031": (0, 0, -10)}
|
||||
head_follow_through = {"bone0025": (0, 0, -18), "bone0031": (0, 0, -6)}
|
||||
jaw_open = {"bone0001": (24, 0, 0), "bone0029": (24, 0, 0), "bone0020": (20, 0, 0)}
|
||||
jaw_closed = {"bone0001": (-4, 0, 0), "bone0029": (-4, 0, 0), "bone0020": (-3, 0, 0)}
|
||||
|
||||
return {
|
||||
"Idle": [
|
||||
(1, neutral),
|
||||
(14, wings_up),
|
||||
(28, neutral),
|
||||
(42, wings_down),
|
||||
(54, wings_recover),
|
||||
(60, neutral),
|
||||
],
|
||||
"Move": [
|
||||
(1, merge(wings_up, legs_a, tail_l)),
|
||||
(9, neutral),
|
||||
(18, merge(wings_down, legs_b, tail_r)),
|
||||
(27, neutral),
|
||||
(36, merge(wings_up, legs_a, tail_l)),
|
||||
],
|
||||
"Attack": [
|
||||
(1, neutral),
|
||||
(8, merge(wings_up, head_lift, jaw_open)),
|
||||
(14, merge(wings_down, head_bite, jaw_open)),
|
||||
(17, merge(wings_down, head_bite, jaw_closed)),
|
||||
(23, merge(wings_recover, head_follow_through)),
|
||||
(30, neutral),
|
||||
(36, neutral),
|
||||
],
|
||||
"HitReact": [
|
||||
(1, neutral),
|
||||
(5, merge(rotate_role(roles, "body", (-18, 0, 12), 2), head_lift, wings_up)),
|
||||
(13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)),
|
||||
(24, neutral),
|
||||
],
|
||||
"Death": [
|
||||
(1, neutral),
|
||||
(18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), head_follow_through, wings_down)),
|
||||
(44, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down, rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))),
|
||||
(72, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down)),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def guardian_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
|
||||
neutral: dict[str, tuple[float, float, float]] = {}
|
||||
arms_open = merge(rotate_role(roles, "arm_l", (0, -8, -12), 3), rotate_role(roles, "arm_r", (0, 8, 12), 3))
|
||||
arms_close = merge(rotate_role(roles, "arm_l", (0, 5, 8), 3), rotate_role(roles, "arm_r", (0, -5, -8), 3))
|
||||
step_a = merge(rotate_role(roles, "leg_l", (-20, 0, 0), 3), rotate_role(roles, "leg_r", (20, 0, 0), 3), rotate_role(roles, "arm_l", (14, 0, 0), 3), rotate_role(roles, "arm_r", (-14, 0, 0), 3))
|
||||
step_b = merge(rotate_role(roles, "leg_l", (20, 0, 0), 3), rotate_role(roles, "leg_r", (-20, 0, 0), 3), rotate_role(roles, "arm_l", (-14, 0, 0), 3), rotate_role(roles, "arm_r", (14, 0, 0), 3))
|
||||
return {
|
||||
"Idle": [(1, neutral), (16, merge(arms_open, rotate_role(roles, "body", (2, 0, 0), 2))), (31, neutral), (46, merge(arms_close, rotate_role(roles, "body", (-2, 0, 0), 2))), (60, neutral)],
|
||||
"Move": [(1, step_a), (10, neutral), (19, step_b), (28, neutral), (36, step_a)],
|
||||
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (0, -18, -8), 3), rotate_role(roles, "arm_r", (-35, 15, 26), 5))), (15, merge(rotate_role(roles, "body", (8, 20, 8), 3), rotate_role(roles, "arm_r", (42, -12, -35), 5), rotate_role(roles, "arm_l", (12, 0, 8), 3), rotate_role(roles, "jaw", (16, 0, 0), 2))), (25, rotate_role(roles, "arm_r", (8, 0, -8), 4)), (36, neutral)],
|
||||
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), arms_open)), (13, merge(rotate_role(roles, "body", (8, 0, -6), 2), arms_close)), (24, neutral)],
|
||||
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-24, 0, 18), 3), arms_open)), (44, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (24, 0, -38), 5), rotate_role(roles, "arm_r", (-18, 0, 34), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (26, 0, -40), 5), rotate_role(roles, "arm_r", (-20, 0, 36), 5)))],
|
||||
}
|
||||
|
||||
|
||||
def insect_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
|
||||
neutral: dict[str, tuple[float, float, float]] = {}
|
||||
legs_a = merge(rotate_role(roles, "leg_l", (12, 0, -10), 4), rotate_role(roles, "leg_r", (-12, 0, 10), 4))
|
||||
legs_b = merge(rotate_role(roles, "leg_l", (-12, 0, 10), 4), rotate_role(roles, "leg_r", (12, 0, -10), 4))
|
||||
wings_up = merge(rotate_role(roles, "wing_l", (0, -18, 20), 3), rotate_role(roles, "wing_r", (0, 18, -20), 3))
|
||||
wings_down = merge(rotate_role(roles, "wing_l", (0, 22, -16), 3), rotate_role(roles, "wing_r", (0, -22, 16), 3))
|
||||
return {
|
||||
"Idle": [(1, neutral), (16, merge(wings_up, rotate_role(roles, "head", (0, 0, 5), 4))), (31, neutral), (46, merge(wings_down, rotate_role(roles, "head", (0, 0, -5), 4))), (60, neutral)],
|
||||
"Move": [(1, merge(legs_a, wings_up)), (10, neutral), (19, merge(legs_b, wings_down)), (28, neutral), (36, merge(legs_a, wings_up))],
|
||||
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), rotate_role(roles, "arm_l", (-20, 0, -20), 3), rotate_role(roles, "arm_r", (-20, 0, 20), 3), wings_up)), (15, merge(rotate_role(roles, "body", (18, 0, 0), 2), rotate_role(roles, "head", (22, 0, 0), 4), rotate_role(roles, "jaw", (25, 0, 0), 4), rotate_role(roles, "arm_l", (28, 0, 22), 3), rotate_role(roles, "arm_r", (28, 0, -22), 3))), (25, rotate_role(roles, "jaw", (8, 0, 0), 3)), (36, neutral)],
|
||||
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), wings_up, legs_b)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
|
||||
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-18, 0, 16), 3), wings_down)), (44, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5), rotate_role(roles, "wing_l", (0, 32, -28), 3), rotate_role(roles, "wing_r", (0, -32, 28), 3))), (72, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5)))],
|
||||
}
|
||||
|
||||
|
||||
def wraith_tentacles(
|
||||
roles: dict[str, list[str]],
|
||||
sweep: float,
|
||||
curl: float,
|
||||
lift: float = 0,
|
||||
phase: float = 0,
|
||||
) -> dict[str, tuple[float, float, float]]:
|
||||
"""Pose every independently rigged vine chain instead of one flattened pair."""
|
||||
result: dict[str, tuple[float, float, float]] = {}
|
||||
for role, side_sign in (("arm_l", -1), ("arm_r", 1)):
|
||||
chains: dict[str, list[str]] = {}
|
||||
for name in roles.get(role, []):
|
||||
match = re.match(r"([LR]_arm\d+)_", name, re.IGNORECASE)
|
||||
if match:
|
||||
chains.setdefault(match.group(1).lower(), []).append(name)
|
||||
for chain_index, chain_name in enumerate(sorted(chains)):
|
||||
chain = chains[chain_name]
|
||||
pair_direction = -1 if chain_index % 2 else 1
|
||||
chain_phase = phase + chain_index * 0.85
|
||||
for bone_index, name in enumerate(chain):
|
||||
progress = bone_index / max(1, len(chain) - 1)
|
||||
tip_weight = 0.38 + progress * 0.62
|
||||
wave = math.sin(chain_phase + progress * math.pi * 1.65)
|
||||
result[name] = (
|
||||
lift * pair_direction * (1 - progress * 0.45),
|
||||
side_sign * sweep * tip_weight * 0.42,
|
||||
side_sign * (curl * wave + sweep * pair_direction * 0.22) * tip_weight,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def wraith_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
|
||||
neutral: dict[str, tuple[float, float, float]] = {}
|
||||
idle_a = wraith_tentacles(roles, sweep=3.5, curl=5.5, lift=1.5, phase=0.2)
|
||||
idle_b = wraith_tentacles(roles, sweep=-3.5, curl=5.5, lift=-1.5, phase=math.pi + 0.2)
|
||||
drift_a = wraith_tentacles(roles, sweep=6, curl=8, lift=2.5, phase=0.65)
|
||||
drift_b = wraith_tentacles(roles, sweep=-6, curl=8, lift=-2.5, phase=math.pi + 0.65)
|
||||
windup = wraith_tentacles(roles, sweep=-8, curl=10, lift=-4, phase=1.1)
|
||||
lash = wraith_tentacles(roles, sweep=15, curl=17, lift=7, phase=2.35)
|
||||
recoil = wraith_tentacles(roles, sweep=-11, curl=13, lift=-6, phase=1.75)
|
||||
collapse = wraith_tentacles(roles, sweep=-14, curl=7, lift=18, phase=math.pi * 0.5)
|
||||
return {
|
||||
"Idle": [(1, neutral), (16, merge(idle_a, rotate_role(roles, "body", (0, 0, 2), 3))), (31, neutral), (46, merge(idle_b, rotate_role(roles, "body", (0, 0, -2), 3))), (60, neutral)],
|
||||
"Move": [(1, drift_a), (10, merge(idle_a, rotate_role(roles, "body", (0, 0, 5), 3))), (19, drift_b), (28, merge(idle_b, rotate_role(roles, "body", (0, 0, -5), 3))), (36, drift_a)],
|
||||
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-12, 0, 0), 3), windup)), (16, merge(rotate_role(roles, "body", (18, 0, 0), 3), lash, rotate_role(roles, "jaw", (24, 0, 0), 3))), (25, drift_a), (36, neutral)],
|
||||
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), recoil)), (13, merge(rotate_role(roles, "body", (7, 0, -6), 3), idle_a)), (24, neutral)],
|
||||
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-22, 0, 20), 3), recoil)), (44, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse, rotate_role(roles, "jaw", (30, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse))],
|
||||
}
|
||||
|
||||
|
||||
def create_actions(profile: AssetProfile, rig: bpy.types.Object, roles: dict[str, list[str]]) -> dict[str, dict[str, object]]:
|
||||
pose_factory = {
|
||||
"dragon": dragon_poses,
|
||||
"guardian": guardian_poses,
|
||||
"insect": insect_poses,
|
||||
"wraith": wraith_poses,
|
||||
}[profile.creature]
|
||||
if profile.asset_id == "blue-eyes-ultimate-dragon":
|
||||
definitions = ultimate_dragon_poses(roles)
|
||||
elif profile.red_eyes_roles:
|
||||
definitions = red_eyes_dragon_poses(roles)
|
||||
else:
|
||||
definitions = pose_factory(roles)
|
||||
rig.animation_data_create()
|
||||
metadata = {}
|
||||
for clip_name, poses in definitions.items():
|
||||
action = bpy.data.actions.new(clip_name)
|
||||
action.use_fake_user = True
|
||||
action.use_frame_range = True
|
||||
action.frame_start = 1
|
||||
action.frame_end = CLIP_SPECS[clip_name]["frames"]
|
||||
action.use_cyclic = CLIP_SPECS[clip_name]["loop"]
|
||||
rig.animation_data.action = action
|
||||
animated_names = sorted({name for _, rotations in poses for name in rotations})
|
||||
for frame, rotations in poses:
|
||||
for name in animated_names:
|
||||
bone = rig.pose.bones.get(name)
|
||||
if not bone:
|
||||
continue
|
||||
bone.rotation_mode = "XYZ"
|
||||
degrees = rotations.get(name, (0.0, 0.0, 0.0))
|
||||
bone.rotation_euler = tuple(math.radians(component) for component in degrees)
|
||||
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
|
||||
metadata[clip_name] = {
|
||||
"frames": CLIP_SPECS[clip_name]["frames"],
|
||||
"seconds": CLIP_SPECS[clip_name]["frames"] / FPS,
|
||||
"loop": CLIP_SPECS[clip_name]["loop"],
|
||||
}
|
||||
rig.animation_data.action = None
|
||||
for bone in rig.pose.bones:
|
||||
bone.rotation_euler = (0, 0, 0)
|
||||
return metadata
|
||||
|
||||
|
||||
def select_only(objects: list[bpy.types.Object]) -> None:
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
|
||||
|
||||
def world_bounds(meshes: list[bpy.types.Object]) -> tuple[Vector, Vector]:
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
points: list[Vector] = []
|
||||
for mesh in meshes:
|
||||
evaluated = mesh.evaluated_get(depsgraph)
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
points.extend(evaluated.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices)
|
||||
evaluated.to_mesh_clear()
|
||||
minimum = Vector(tuple(min(point[index] for point in points) for index in range(3)))
|
||||
maximum = Vector(tuple(max(point[index] for point in points) for index in range(3)))
|
||||
return minimum, maximum
|
||||
|
||||
|
||||
def look_at(obj: bpy.types.Object, target: Vector) -> None:
|
||||
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
|
||||
def render_preview(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], out_path: Path, frame: int = 17) -> None:
|
||||
rig.animation_data.action = bpy.data.actions["Attack"]
|
||||
bpy.context.scene.frame_set(frame)
|
||||
bpy.context.view_layer.update()
|
||||
minimum, maximum = world_bounds(meshes)
|
||||
center = (minimum + maximum) * 0.5
|
||||
radius = (maximum - minimum).length * 0.5
|
||||
size = radius * 2
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = 640
|
||||
scene.render.resolution_y = 640
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.film_transparent = False
|
||||
scene.world.color = (0.008, 0.012, 0.02)
|
||||
camera_data = bpy.data.cameras.new("PreviewCamera")
|
||||
camera = bpy.data.objects.new("PreviewCamera", camera_data)
|
||||
bpy.context.collection.objects.link(camera)
|
||||
camera.data.lens = 65
|
||||
view_direction = Vector((0.8, -1.05, 0.42)).normalized()
|
||||
half_fov = math.atan(camera.data.sensor_width / (2 * camera.data.lens))
|
||||
camera.location = center + view_direction * (radius / math.sin(half_fov) * 0.45)
|
||||
look_at(camera, center)
|
||||
scene.camera = camera
|
||||
for name, offset, color, energy, radius in (
|
||||
("Key", (1.4, -1.2, 1.7), (1.0, 0.52, 0.28), 1000, 5.0),
|
||||
("Fill", (-1.3, -0.4, 0.8), (0.22, 0.42, 1.0), 800, 4.0),
|
||||
("Rim", (0.3, 1.4, 1.3), (0.65, 0.22, 1.0), 900, 3.5),
|
||||
):
|
||||
light_data = bpy.data.lights.new(name, "AREA")
|
||||
light_data.energy = energy
|
||||
light_data.color = color
|
||||
light_data.shape = "DISK"
|
||||
light_data.size = size * radius
|
||||
light = bpy.data.objects.new(name, light_data)
|
||||
light.location = center + Vector(offset) * size
|
||||
bpy.context.collection.objects.link(light)
|
||||
look_at(light, center)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
scene.render.filepath = str(out_path)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
rig.animation_data.action = None
|
||||
|
||||
|
||||
def export_asset(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], source_path: Path, clips: dict[str, dict[str, object]], roles: dict[str, list[str]]) -> None:
|
||||
source_dir = MODEL_ROOT / profile.asset_id
|
||||
output_path = source_dir / f"{profile.asset_id}-animated.glb"
|
||||
select_only([rig, *meshes])
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(output_path),
|
||||
export_format="GLB",
|
||||
use_selection=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="ACTIONS",
|
||||
export_frame_range=True,
|
||||
export_skins=True,
|
||||
export_morph=True,
|
||||
export_yup=True,
|
||||
export_apply=False,
|
||||
export_cameras=False,
|
||||
export_lights=False,
|
||||
export_image_format="AUTO",
|
||||
)
|
||||
triangles = sum(len(polygon.vertices) - 2 for mesh in meshes for polygon in mesh.data.polygons)
|
||||
metadata = {
|
||||
"name": profile.display_name,
|
||||
"assetId": profile.asset_id,
|
||||
"source": source_path.name,
|
||||
"format": "glTF 2.0 binary (GLB)",
|
||||
"authoringTool": bpy.app.version_string,
|
||||
"fps": FPS,
|
||||
"trianglesApprox": triangles,
|
||||
"animations": clips,
|
||||
"animatedBones": {role: names for role, names in roles.items() if names},
|
||||
}
|
||||
(source_dir / f"{profile.asset_id}-animated.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
render_preview(profile, rig, meshes, source_dir / "previews" / f"{profile.asset_id}-attack.png")
|
||||
print(f"BUILT={profile.asset_id} BONES={len(rig.data.bones)} TRIANGLES={triangles} CLIPS={','.join(clips)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
bpy.context.preferences.filepaths.save_version = 0
|
||||
bpy.context.scene.render.fps = FPS
|
||||
requested_ids = set(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else set()
|
||||
unknown_ids = requested_ids - {profile.asset_id for profile in PROFILES}
|
||||
if unknown_ids:
|
||||
raise RuntimeError(f"Unknown asset ids: {', '.join(sorted(unknown_ids))}")
|
||||
selected_profiles = [profile for profile in PROFILES if not requested_ids or profile.asset_id in requested_ids]
|
||||
for profile in selected_profiles:
|
||||
reset_scene()
|
||||
rig, meshes, source_path = convert_and_import(profile)
|
||||
roles = build_roles(profile, rig)
|
||||
clips = create_actions(profile, rig, roles)
|
||||
export_asset(profile, rig, meshes, source_path, clips, roles)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Print rig and mesh details for the locally-authored inspired creature models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
|
||||
|
||||
|
||||
def reset_scene() -> None:
|
||||
if bpy.context.object and bpy.context.object.mode != "OBJECT":
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
|
||||
def inspect_model(path: Path) -> dict[str, object]:
|
||||
reset_scene()
|
||||
source_path = path.with_suffix(".smd")
|
||||
with tempfile.TemporaryDirectory(prefix="thor-yugioh-inspect-") as temp_dir:
|
||||
converted_path = Path(temp_dir) / "model.glb"
|
||||
subprocess.run(
|
||||
["assimp", "export", str(source_path), str(converted_path), "-f", "glb2"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
bpy.ops.import_scene.gltf(filepath=str(converted_path))
|
||||
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
|
||||
meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
||||
bounds = []
|
||||
for mesh in meshes:
|
||||
bounds.extend(mesh.matrix_world @ Vector(corner) for corner in mesh.bound_box)
|
||||
return {
|
||||
"asset": path.parent.name,
|
||||
"source": source_path.name,
|
||||
"armatures": [
|
||||
{
|
||||
"name": rig.name,
|
||||
"bones": [bone.name for bone in rig.data.bones],
|
||||
"actions": [action.name for action in bpy.data.actions],
|
||||
}
|
||||
for rig in armatures
|
||||
],
|
||||
"meshes": [mesh.name for mesh in meshes],
|
||||
"materials": sorted({slot.material.name for mesh in meshes for slot in mesh.material_slots if slot.material}),
|
||||
"bounds": {
|
||||
"min": [round(min(point[index] for point in bounds), 4) for index in range(3)],
|
||||
"max": [round(max(point[index] for point in bounds), 4) for index in range(3)],
|
||||
} if bounds else None,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
report = [inspect_model(path) for path in sorted(MODEL_ROOT.glob("*/MMD_*.fbx"))]
|
||||
print("YUGIOH_MODEL_REPORT=" + json.dumps(report, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Re-import and validate every generated creature animation GLB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
|
||||
EXPECTED_CLIPS = {"Idle", "Move", "Attack", "HitReact", "Death"}
|
||||
|
||||
|
||||
def read_glb_json(path: Path) -> dict[str, object]:
|
||||
data = path.read_bytes()
|
||||
magic, version, total_length = struct.unpack_from("<4sII", data, 0)
|
||||
if magic != b"glTF" or version != 2 or total_length != len(data):
|
||||
raise RuntimeError(f"{path}: invalid GLB header")
|
||||
chunk_length, chunk_type = struct.unpack_from("<II", data, 12)
|
||||
if chunk_type != 0x4E4F534A:
|
||||
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
|
||||
return json.loads(data[20:20 + chunk_length].decode("utf-8"))
|
||||
|
||||
|
||||
def read_glb_binary(path: Path) -> bytes:
|
||||
data = path.read_bytes()
|
||||
json_length, json_type = struct.unpack_from("<II", data, 12)
|
||||
if json_type != 0x4E4F534A:
|
||||
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
|
||||
binary_header = 20 + json_length
|
||||
binary_length, binary_type = struct.unpack_from("<II", data, binary_header)
|
||||
if binary_type != 0x004E4942:
|
||||
raise RuntimeError(f"{path}: binary data is not the second GLB chunk")
|
||||
binary_start = binary_header + 8
|
||||
return data[binary_start:binary_start + binary_length]
|
||||
|
||||
|
||||
def float_accessor(document: dict[str, object], binary: bytes, index: int) -> list[tuple[float, ...]]:
|
||||
accessor = document["accessors"][index]
|
||||
if accessor["componentType"] != 5126:
|
||||
raise RuntimeError(f"Accessor {index}: expected float data")
|
||||
component_counts = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}
|
||||
component_count = component_counts[accessor["type"]]
|
||||
view = document["bufferViews"][accessor["bufferView"]]
|
||||
element_size = component_count * 4
|
||||
stride = view.get("byteStride", element_size)
|
||||
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
||||
return [
|
||||
struct.unpack_from(f"<{component_count}f", binary, start + item * stride)
|
||||
for item in range(accessor["count"])
|
||||
]
|
||||
|
||||
|
||||
def values_vary(values: list[tuple[float, ...]], tolerance: float = 1e-5) -> bool:
|
||||
first = values[0]
|
||||
return any(
|
||||
abs(component - first[index]) > tolerance
|
||||
for value in values[1:]
|
||||
for index, component in enumerate(value)
|
||||
)
|
||||
|
||||
|
||||
def validate_pumpking_tentacles(path: Path) -> int:
|
||||
document = read_glb_json(path)
|
||||
nodes = document.get("nodes", [])
|
||||
animations = document.get("animations", [])
|
||||
tentacle_nodes = {
|
||||
index for index, node in enumerate(nodes)
|
||||
if re.fullmatch(r"[LR]_arm\d+_\d+", node.get("name", ""), re.IGNORECASE)
|
||||
}
|
||||
if not tentacle_nodes:
|
||||
raise RuntimeError(f"{path}: no Pumpking tentacle nodes")
|
||||
for animation in animations:
|
||||
targeted = {channel["target"]["node"] for channel in animation.get("channels", [])}
|
||||
missing = tentacle_nodes - targeted
|
||||
if missing:
|
||||
missing_names = [nodes[index].get("name", str(index)) for index in sorted(missing)]
|
||||
raise RuntimeError(f"{path}: {animation.get('name')} misses tentacles {missing_names}")
|
||||
return len(tentacle_nodes)
|
||||
|
||||
|
||||
def validate_ultimate_dragon(path: Path) -> dict[str, int]:
|
||||
document = read_glb_json(path)
|
||||
binary = read_glb_binary(path)
|
||||
nodes = document.get("nodes", [])
|
||||
animations = document.get("animations", [])
|
||||
head_chains = (
|
||||
{f"BEUD_LNeck{index}" for index in range(1, 7)} | {"BEUD_LHead"},
|
||||
{f"BEUD_Neck{index}" for index in range(1, 7)} | {"BEUD_Head"},
|
||||
{f"BEUD_RNeck{index}" for index in range(1, 7)} | {"BEUD_RHead"},
|
||||
)
|
||||
animated_wings = {
|
||||
f"BEUD_{side}Wing{index}"
|
||||
for side in ("L", "R")
|
||||
for index in range(1, 4)
|
||||
}
|
||||
static_wings = {
|
||||
f"BEUD_{side}Wing{index}"
|
||||
for side in ("L", "R")
|
||||
for index in range(4, 9)
|
||||
}
|
||||
head_tips = {"BEUD_LHead", "BEUD_Head", "BEUD_RHead"}
|
||||
for animation in animations:
|
||||
variation = {}
|
||||
rotation_values = {}
|
||||
for channel in animation.get("channels", []):
|
||||
if channel["target"].get("path") != "rotation":
|
||||
continue
|
||||
name = nodes[channel["target"]["node"]].get("name", "")
|
||||
accessor_index = animation["samplers"][channel["sampler"]]["output"]
|
||||
values = float_accessor(document, binary, accessor_index)
|
||||
rotation_values[name] = values
|
||||
variation[name] = values_vary(values)
|
||||
for chain in head_chains:
|
||||
static = {name for name in chain if not variation.get(name, False)}
|
||||
if static:
|
||||
raise RuntimeError(f"{path}: {animation.get('name')} has static head-chain bones {sorted(static)}")
|
||||
static_roots = {name for name in animated_wings if not variation.get(name, False)}
|
||||
moving_tips = {name for name in static_wings if variation.get(name, False)}
|
||||
if static_roots or moving_tips:
|
||||
raise RuntimeError(
|
||||
f"{path}: {animation.get('name')} static wing roots {sorted(static_roots)}, "
|
||||
f"moving wing tips {sorted(moving_tips)}"
|
||||
)
|
||||
if animation.get("name") in {"Idle", "Move", "Attack"}:
|
||||
peak_frames = {}
|
||||
for name in head_tips:
|
||||
values = rotation_values[name]
|
||||
start = values[0]
|
||||
distances = [1 - min(1.0, abs(sum(component * start[index] for index, component in enumerate(value)))) for value in values]
|
||||
peak_frames[name] = distances.index(max(distances))
|
||||
if len(set(peak_frames.values())) != len(head_tips):
|
||||
raise RuntimeError(f"{path}: {animation.get('name')} head cadences overlap at {peak_frames}")
|
||||
return {
|
||||
"headChainBonesPerClip": sum(len(chain) for chain in head_chains),
|
||||
"wingBonesPerClip": len(animated_wings),
|
||||
"staggeredHeadClips": 3,
|
||||
}
|
||||
|
||||
|
||||
def reset_scene() -> None:
|
||||
if bpy.context.object and bpy.context.object.mode != "OBJECT":
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
for obj in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
for image in list(bpy.data.images):
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
|
||||
def validate_red_eyes_motion(rig: bpy.types.Object) -> dict[str, float]:
|
||||
"""Regression-check the paired flap and bite silhouette in the shipped GLB."""
|
||||
rig.animation_data.action = bpy.data.actions["Attack"]
|
||||
|
||||
def sample(frame: int) -> dict[str, Vector]:
|
||||
bpy.context.scene.frame_set(frame)
|
||||
bpy.context.view_layer.update()
|
||||
return {
|
||||
"wing_l": rig.pose.bones["bone0079"].tail.copy(),
|
||||
"wing_r": rig.pose.bones["bone0075"].tail.copy(),
|
||||
"head": rig.pose.bones["bone0073"].tail.copy(),
|
||||
}
|
||||
|
||||
windup = sample(8)
|
||||
downstroke = sample(14)
|
||||
impact = sample(17)
|
||||
left_flap = windup["wing_l"].z - downstroke["wing_l"].z
|
||||
right_flap = windup["wing_r"].z - downstroke["wing_r"].z
|
||||
head_drop = windup["head"].z - impact["head"].z
|
||||
head_lunge = windup["head"].y - impact["head"].y
|
||||
if left_flap < 0.2 or right_flap < 0.2:
|
||||
raise RuntimeError(f"Red-Eyes wings do not complete a paired flap: {left_flap=:.4f}, {right_flap=:.4f}")
|
||||
if head_drop < 0.01 or head_lunge < 0.05:
|
||||
raise RuntimeError(f"Red-Eyes bite lacks head lift/lunge: {head_drop=:.4f}, {head_lunge=:.4f}")
|
||||
return {
|
||||
"leftWingDrop": round(left_flap, 4),
|
||||
"rightWingDrop": round(right_flap, 4),
|
||||
"headDrop": round(head_drop, 4),
|
||||
"headLunge": round(head_lunge, 4),
|
||||
}
|
||||
|
||||
|
||||
def validate(path: Path) -> dict[str, object]:
|
||||
reset_scene()
|
||||
bpy.context.scene.render.fps = 30
|
||||
bpy.ops.import_scene.gltf(filepath=str(path))
|
||||
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
|
||||
meshes = [
|
||||
obj for obj in bpy.context.scene.objects
|
||||
if obj.type == "MESH" and any(
|
||||
modifier.type == "ARMATURE" and modifier.object in armatures for modifier in obj.modifiers
|
||||
)
|
||||
]
|
||||
clips = {action.name: tuple(round(value, 3) for value in action.frame_range) for action in bpy.data.actions}
|
||||
if len(armatures) != 1:
|
||||
raise RuntimeError(f"{path}: expected one armature, found {len(armatures)}")
|
||||
if not meshes:
|
||||
raise RuntimeError(f"{path}: no render meshes")
|
||||
if set(clips) != EXPECTED_CLIPS:
|
||||
raise RuntimeError(f"{path}: clips {sorted(clips)} != {sorted(EXPECTED_CLIPS)}")
|
||||
if any(end <= start for start, end in clips.values()):
|
||||
raise RuntimeError(f"{path}: empty animation range in {clips}")
|
||||
if not bpy.data.images:
|
||||
raise RuntimeError(f"{path}: no packed texture images")
|
||||
corners = [mesh.matrix_world @ Vector(corner) for mesh in meshes for corner in mesh.bound_box]
|
||||
dimensions = [round(max(point[index] for point in corners) - min(point[index] for point in corners), 4) for index in range(3)]
|
||||
report = {
|
||||
"asset": path.parent.name,
|
||||
"sizeBytes": path.stat().st_size,
|
||||
"bones": len(armatures[0].data.bones),
|
||||
"meshes": len(meshes),
|
||||
"images": len(bpy.data.images),
|
||||
"dimensions": dimensions,
|
||||
"clips": clips,
|
||||
}
|
||||
if path.parent.name == "pumpking-the-king-of-ghosts":
|
||||
report["tentacleBonesPerClip"] = validate_pumpking_tentacles(path)
|
||||
if path.parent.name == "blue-eyes-ultimate-dragon":
|
||||
report.update(validate_ultimate_dragon(path))
|
||||
if path.parent.name == "red-eyes-black-dragon":
|
||||
report["motion"] = validate_red_eyes_motion(armatures[0])
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
paths = sorted(MODEL_ROOT.glob("*/*-animated.glb"))
|
||||
if len(paths) != 7:
|
||||
raise RuntimeError(f"Expected 7 generated GLBs, found {len(paths)}")
|
||||
report = [validate(path) for path in paths]
|
||||
print("YUGIOH_ANIMATION_VALIDATION=" + json.dumps(report, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user