710 lines
36 KiB
Python
710 lines
36 KiB
Python
"""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/shelved/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()
|