Release v0.1.1 2026-07-10
This commit is contained in:
@@ -0,0 +1,832 @@
|
||||
"""Build Ember Mantis Duelist as a rigged, animated, runtime-ready GLB.
|
||||
|
||||
Run with:
|
||||
blender --background --factory-startup --python scripts/blender/build_ember_mantis_duelist.py
|
||||
|
||||
The asset is an original 3D interpretation of the IWT2 Ember Mantis concept.
|
||||
"""
|
||||
|
||||
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_DIR = ROOT / "game_assets/models/original/bosses/ember-mantis-duelist"
|
||||
PREVIEW_DIR = OUT_DIR / "previews"
|
||||
BLEND_PATH = OUT_DIR / "ember_mantis_duelist.blend"
|
||||
GLB_PATH = OUT_DIR / "ember_mantis_duelist.glb"
|
||||
COLLISION_PATH = OUT_DIR / "ember_mantis_duelist_collision.glb"
|
||||
METADATA_PATH = OUT_DIR / "ember_mantis_duelist.asset.json"
|
||||
|
||||
FPS = 30
|
||||
TAU = math.tau
|
||||
PARTS: list[bpy.types.Object] = []
|
||||
RUNTIME_MATERIALS: list[bpy.types.Material] = []
|
||||
|
||||
|
||||
def reset_scene() -> None:
|
||||
bpy.ops.object.mode_set(mode="OBJECT") if bpy.context.object and bpy.context.object.mode != "OBJECT" else None
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for datablocks in (
|
||||
bpy.data.meshes,
|
||||
bpy.data.curves,
|
||||
bpy.data.armatures,
|
||||
bpy.data.materials,
|
||||
bpy.data.cameras,
|
||||
bpy.data.lights,
|
||||
bpy.data.actions,
|
||||
):
|
||||
for datablock in list(datablocks):
|
||||
datablocks.remove(datablock)
|
||||
|
||||
|
||||
def make_material(
|
||||
name: str,
|
||||
color: tuple[float, float, float, float],
|
||||
*,
|
||||
metallic: float = 0.0,
|
||||
roughness: float = 0.5,
|
||||
emission: tuple[float, float, float, float] | None = None,
|
||||
emission_strength: float = 0.0,
|
||||
) -> bpy.types.Material:
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
mat.diffuse_color = color
|
||||
mat.metallic = metallic
|
||||
mat.roughness = roughness
|
||||
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 = emission_strength
|
||||
return mat
|
||||
|
||||
|
||||
def assign_rigid_group(obj: bpy.types.Object, bone_name: str) -> bpy.types.Object:
|
||||
group = obj.vertex_groups.new(name=bone_name)
|
||||
group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
|
||||
PARTS.append(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def finish_mesh(
|
||||
obj: bpy.types.Object,
|
||||
name: str,
|
||||
material: bpy.types.Material,
|
||||
bone_name: str,
|
||||
*,
|
||||
smooth: bool = True,
|
||||
) -> bpy.types.Object:
|
||||
obj.name = name
|
||||
obj.data.name = f"{name}_Mesh"
|
||||
# Every source part uses the same ordered slots. Blender 5.1 otherwise keeps
|
||||
# joined material slots but resets joined face indices to slot zero.
|
||||
for runtime_material in RUNTIME_MATERIALS:
|
||||
obj.data.materials.append(runtime_material)
|
||||
material_index = next(
|
||||
index for index, runtime_material in enumerate(RUNTIME_MATERIALS) if runtime_material.name == material.name
|
||||
)
|
||||
for polygon in obj.data.polygons:
|
||||
polygon.material_index = material_index
|
||||
material_group = obj.vertex_groups.new(name=f"__MAT_{material_index}")
|
||||
material_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
if smooth:
|
||||
for polygon in obj.data.polygons:
|
||||
polygon.use_smooth = True
|
||||
return assign_rigid_group(obj, bone_name)
|
||||
|
||||
|
||||
def add_ellipsoid(
|
||||
name: str,
|
||||
location: tuple[float, float, float],
|
||||
scale: tuple[float, float, float],
|
||||
material: bpy.types.Material,
|
||||
bone_name: str,
|
||||
*,
|
||||
subdivisions: int = 2,
|
||||
rotation: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
) -> bpy.types.Object:
|
||||
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1.0, location=location, rotation=rotation)
|
||||
obj = bpy.context.object
|
||||
obj.scale = scale
|
||||
return finish_mesh(obj, name, material, bone_name)
|
||||
|
||||
|
||||
def add_cone_between(
|
||||
name: str,
|
||||
start: tuple[float, float, float],
|
||||
end: tuple[float, float, float],
|
||||
radius_start: float,
|
||||
radius_end: float,
|
||||
material: bpy.types.Material,
|
||||
bone_name: str,
|
||||
*,
|
||||
vertices: int = 8,
|
||||
) -> bpy.types.Object:
|
||||
start_v = Vector(start)
|
||||
end_v = Vector(end)
|
||||
direction = end_v - start_v
|
||||
midpoint = (start_v + end_v) * 0.5
|
||||
bpy.ops.mesh.primitive_cone_add(
|
||||
vertices=vertices,
|
||||
radius1=radius_start,
|
||||
radius2=radius_end,
|
||||
depth=direction.length,
|
||||
location=midpoint,
|
||||
)
|
||||
obj = bpy.context.object
|
||||
obj.rotation_mode = "QUATERNION"
|
||||
obj.rotation_quaternion = direction.to_track_quat("Z", "Y")
|
||||
obj.rotation_mode = "XYZ"
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
return finish_mesh(obj, name, material, bone_name)
|
||||
|
||||
|
||||
def add_blade(
|
||||
name: str,
|
||||
side: float,
|
||||
material: bpy.types.Material,
|
||||
bone_name: str,
|
||||
*,
|
||||
variant: str = "main",
|
||||
) -> bpy.types.Object:
|
||||
# Extruded hook polygon. Forward is Blender -Y; mirrored across X.
|
||||
x_center = side * 1.42
|
||||
thickness = {"main": 0.095, "edge": 0.104, "scar": 0.108}[variant]
|
||||
if variant == "edge":
|
||||
yz = [
|
||||
(-0.72, 2.78),
|
||||
(-1.34, 2.55),
|
||||
(-2.62, 1.72),
|
||||
(-2.42, 2.06),
|
||||
(-1.31, 2.78),
|
||||
]
|
||||
elif variant == "scar":
|
||||
yz = [
|
||||
(-0.82, 2.94),
|
||||
(-1.33, 2.72),
|
||||
(-2.30, 2.02),
|
||||
(-2.10, 2.33),
|
||||
(-1.33, 2.91),
|
||||
]
|
||||
else:
|
||||
yz = [
|
||||
(-0.50, 3.12),
|
||||
(-0.72, 2.78),
|
||||
(-1.34, 2.55),
|
||||
(-2.62, 1.72),
|
||||
(-2.31, 2.28),
|
||||
(-1.45, 3.18),
|
||||
(-0.82, 3.34),
|
||||
]
|
||||
vertices = []
|
||||
for x in (x_center - thickness, x_center + thickness):
|
||||
vertices.extend((x, y, z) for y, z in yz)
|
||||
count = len(yz)
|
||||
faces = [tuple(range(count)), tuple(range(count, count * 2))[::-1]]
|
||||
for index in range(count):
|
||||
nxt = (index + 1) % count
|
||||
faces.append((index, nxt, count + nxt, count + index))
|
||||
mesh = bpy.data.meshes.new(f"{name}_Mesh")
|
||||
mesh.from_pydata(vertices, [], faces)
|
||||
mesh.validate()
|
||||
mesh.update()
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
return finish_mesh(obj, name, material, bone_name, smooth=False)
|
||||
|
||||
|
||||
def add_plate(
|
||||
name: str,
|
||||
location: tuple[float, float, float],
|
||||
scale: tuple[float, float, float],
|
||||
rotation: tuple[float, float, float],
|
||||
material: bpy.types.Material,
|
||||
bone_name: str,
|
||||
) -> bpy.types.Object:
|
||||
bpy.ops.mesh.primitive_cone_add(vertices=6, radius1=1.0, radius2=0.72, depth=0.46, location=location, rotation=rotation)
|
||||
obj = bpy.context.object
|
||||
obj.scale = scale
|
||||
return finish_mesh(obj, name, material, bone_name, smooth=False)
|
||||
|
||||
|
||||
def create_armature() -> bpy.types.Object:
|
||||
arm_data = bpy.data.armatures.new("EmberMantis_Rig")
|
||||
armature = bpy.data.objects.new("EmberMantis_Rig", arm_data)
|
||||
bpy.context.collection.objects.link(armature)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
armature.select_set(True)
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
|
||||
bones = {
|
||||
"Root": ((0, 0, 0), (0, 0, 0.55), None),
|
||||
"Pelvis": ((0, 0, 1.70), (0, 0, 2.35), "Root"),
|
||||
"Abdomen": ((0, 0.05, 2.15), (0, 0.42, 2.78), "Pelvis"),
|
||||
"Tail": ((0, 0.38, 2.58), (0, 1.30, 2.18), "Abdomen"),
|
||||
"Chest": ((0, 0.02, 2.65), (0, -0.04, 3.48), "Abdomen"),
|
||||
"Neck": ((0, -0.03, 3.38), (0, -0.14, 3.88), "Chest"),
|
||||
"Head": ((0, -0.13, 3.78), (0, -0.34, 4.35), "Neck"),
|
||||
"UpperArm.L": ((-0.34, -0.02, 3.30), (-0.98, -0.27, 3.20), "Chest"),
|
||||
"Forearm.L": ((-0.98, -0.27, 3.20), (-1.38, -0.60, 3.02), "UpperArm.L"),
|
||||
"Blade.L": ((-1.38, -0.60, 3.02), (-1.46, -1.48, 2.55), "Forearm.L"),
|
||||
"UpperArm.R": ((0.34, -0.02, 3.30), (0.98, -0.27, 3.20), "Chest"),
|
||||
"Forearm.R": ((0.98, -0.27, 3.20), (1.38, -0.60, 3.02), "UpperArm.R"),
|
||||
"Blade.R": ((1.38, -0.60, 3.02), (1.46, -1.48, 2.55), "Forearm.R"),
|
||||
"Thigh.L": ((-0.30, 0.02, 2.00), (-0.66, 0.02, 1.18), "Pelvis"),
|
||||
"Shin.L": ((-0.66, 0.02, 1.18), (-0.76, -0.34, 0.38), "Thigh.L"),
|
||||
"Foot.L": ((-0.76, -0.34, 0.38), (-0.76, -0.96, 0.15), "Shin.L"),
|
||||
"Thigh.R": ((0.30, 0.02, 2.00), (0.66, 0.02, 1.18), "Pelvis"),
|
||||
"Shin.R": ((0.66, 0.02, 1.18), (0.76, -0.34, 0.38), "Thigh.R"),
|
||||
"Foot.R": ((0.76, -0.34, 0.38), (0.76, -0.96, 0.15), "Shin.R"),
|
||||
}
|
||||
for name, (head, tail, parent) in bones.items():
|
||||
bone = arm_data.edit_bones.new(name)
|
||||
bone.head = head
|
||||
bone.tail = tail
|
||||
if parent:
|
||||
bone.parent = arm_data.edit_bones[parent]
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
for pose_bone in armature.pose.bones:
|
||||
pose_bone.rotation_mode = "XYZ"
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
armature.show_in_front = True
|
||||
return armature
|
||||
|
||||
|
||||
def create_model(armature: bpy.types.Object, mats: dict[str, bpy.types.Material]) -> bpy.types.Object:
|
||||
chitin = mats["chitin"]
|
||||
crimson = mats["crimson"]
|
||||
deep_red = mats["deep_red"]
|
||||
ember = mats["ember"]
|
||||
bone = mats["bone"]
|
||||
eye = mats["eye"]
|
||||
|
||||
# Core insect anatomy.
|
||||
add_ellipsoid("Thorax", (0, 0.00, 3.05), (0.54, 0.47, 0.72), chitin, "Chest")
|
||||
add_ellipsoid("ChestArmor", (0, -0.35, 3.18), (0.47, 0.18, 0.57), crimson, "Chest")
|
||||
add_ellipsoid("Pelvis", (0, 0.08, 2.13), (0.48, 0.42, 0.55), deep_red, "Pelvis")
|
||||
add_ellipsoid("Abdomen", (0, 0.52, 2.34), (0.58, 0.82, 0.49), chitin, "Abdomen")
|
||||
add_ellipsoid("TailMass", (0, 1.18, 2.10), (0.56, 0.92, 0.40), deep_red, "Tail")
|
||||
add_cone_between("TailTip", (0, 1.44, 2.13), (0, 2.20, 1.88), 0.50, 0.06, crimson, "Tail", vertices=7)
|
||||
|
||||
# Overlapping abdominal armor plates.
|
||||
for index, y in enumerate((0.33, 0.72, 1.10, 1.47)):
|
||||
scale = 0.52 - index * 0.055
|
||||
add_plate(
|
||||
f"AbdomenPlate_{index + 1}",
|
||||
(0, y, 2.50 - index * 0.10),
|
||||
(scale, 0.42, 0.20),
|
||||
(math.radians(83), 0, 0),
|
||||
crimson if index % 2 == 0 else deep_red,
|
||||
"Abdomen" if index < 2 else "Tail",
|
||||
)
|
||||
for side in (-1, 1):
|
||||
add_cone_between(
|
||||
f"AbdomenSpike_{index}_{side:+d}",
|
||||
(side * scale * 0.72, y, 2.48 - index * 0.10),
|
||||
(side * (scale + 0.38), y + 0.08, 2.34 - index * 0.12),
|
||||
0.10,
|
||||
0.0,
|
||||
crimson,
|
||||
"Abdomen" if index < 2 else "Tail",
|
||||
vertices=6,
|
||||
)
|
||||
|
||||
# Neck, predatory head, jaws, eyes, crown spikes.
|
||||
add_cone_between("NeckCore", (0, -0.03, 3.40), (0, -0.20, 3.88), 0.30, 0.25, chitin, "Neck")
|
||||
add_ellipsoid("Head", (0, -0.34, 4.10), (0.43, 0.50, 0.38), crimson, "Head")
|
||||
add_ellipsoid("FaceMask", (0, -0.73, 4.05), (0.32, 0.19, 0.28), chitin, "Head")
|
||||
for side in (-1, 1):
|
||||
suffix = "L" if side < 0 else "R"
|
||||
add_ellipsoid(f"Eye_{suffix}", (side * 0.30, -0.67, 4.16), (0.09, 0.045, 0.075), eye, "Head", subdivisions=1)
|
||||
add_cone_between(f"Mandible_{suffix}", (side * 0.18, -0.70, 3.98), (side * 0.34, -1.05, 3.83), 0.10, 0.02, bone, "Head", vertices=6)
|
||||
add_cone_between(f"AntennaBase_{suffix}", (side * 0.22, -0.33, 4.34), (side * 0.46, -0.45, 4.82), 0.055, 0.035, deep_red, "Head", vertices=6)
|
||||
add_cone_between(f"AntennaTip_{suffix}", (side * 0.46, -0.45, 4.82), (side * 0.76, -0.88, 5.20), 0.04, 0.0, ember, "Head", vertices=6)
|
||||
add_cone_between(f"CrownSpike_{suffix}", (side * 0.23, -0.05, 4.31), (side * 0.48, 0.13, 4.90), 0.12, 0.0, crimson, "Head", vertices=6)
|
||||
add_cone_between("CrownSpike_Center", (0, 0.02, 4.34), (0, 0.32, 5.02), 0.13, 0.0, crimson, "Head", vertices=6)
|
||||
|
||||
# Scythe arms: dark joints, crimson armor, pale blades, emissive blade scars.
|
||||
for side in (-1, 1):
|
||||
suffix = "L" if side < 0 else "R"
|
||||
bones = (f"UpperArm.{suffix}", f"Forearm.{suffix}", f"Blade.{suffix}")
|
||||
add_cone_between(
|
||||
f"UpperArm_{suffix}",
|
||||
(side * 0.34, -0.02, 3.30),
|
||||
(side * 0.98, -0.27, 3.20),
|
||||
0.25,
|
||||
0.17,
|
||||
chitin,
|
||||
bones[0],
|
||||
)
|
||||
add_plate(
|
||||
f"ShoulderPlate_{suffix}",
|
||||
(side * 0.55, -0.07, 3.42),
|
||||
(0.27, 0.33, 0.24),
|
||||
(0, math.radians(side * 18), math.radians(side * 12)),
|
||||
crimson,
|
||||
bones[0],
|
||||
)
|
||||
add_cone_between(
|
||||
f"Forearm_{suffix}",
|
||||
(side * 0.98, -0.27, 3.20),
|
||||
(side * 1.40, -0.64, 3.02),
|
||||
0.19,
|
||||
0.13,
|
||||
deep_red,
|
||||
bones[1],
|
||||
)
|
||||
add_ellipsoid(f"BladeJoint_{suffix}", (side * 1.41, -0.62, 3.02), (0.22, 0.24, 0.22), crimson, bones[2], subdivisions=1)
|
||||
add_blade(f"ScytheBlade_{suffix}", side, deep_red, bones[2])
|
||||
add_blade(f"ScytheBoneEdge_{suffix}", side, bone, bones[2], variant="edge")
|
||||
add_blade(f"ScytheEmberScar_{suffix}", side, ember, bones[2], variant="scar")
|
||||
add_cone_between(
|
||||
f"ElbowSpike_{suffix}",
|
||||
(side * 1.00, -0.22, 3.30),
|
||||
(side * 1.28, 0.04, 3.55),
|
||||
0.09,
|
||||
0.0,
|
||||
crimson,
|
||||
bones[1],
|
||||
vertices=6,
|
||||
)
|
||||
|
||||
# Digitigrade legs and hooked feet.
|
||||
for side in (-1, 1):
|
||||
suffix = "L" if side < 0 else "R"
|
||||
thigh_bone, shin_bone, foot_bone = f"Thigh.{suffix}", f"Shin.{suffix}", f"Foot.{suffix}"
|
||||
add_cone_between(f"Thigh_{suffix}", (side * 0.30, 0.02, 2.00), (side * 0.66, 0.02, 1.18), 0.25, 0.18, chitin, thigh_bone)
|
||||
add_plate(
|
||||
f"ThighPlate_{suffix}",
|
||||
(side * 0.47, -0.02, 1.72),
|
||||
(0.28, 0.31, 0.34),
|
||||
(0, math.radians(side * 12), 0),
|
||||
crimson,
|
||||
thigh_bone,
|
||||
)
|
||||
add_cone_between(f"Shin_{suffix}", (side * 0.66, 0.02, 1.18), (side * 0.76, -0.34, 0.38), 0.18, 0.11, deep_red, shin_bone)
|
||||
add_cone_between(f"Foot_{suffix}", (side * 0.76, -0.34, 0.38), (side * 0.76, -0.96, 0.15), 0.14, 0.08, chitin, foot_bone)
|
||||
for toe_index, toe_x in enumerate((-0.13, 0.0, 0.13)):
|
||||
add_cone_between(
|
||||
f"Toe_{suffix}_{toe_index + 1}",
|
||||
(side * 0.76 + toe_x, -0.88, 0.15),
|
||||
(side * 0.76 + toe_x * 1.35, -1.25, 0.07),
|
||||
0.055,
|
||||
0.0,
|
||||
bone,
|
||||
foot_bone,
|
||||
vertices=5,
|
||||
)
|
||||
add_cone_between(
|
||||
f"KneeSpike_{suffix}",
|
||||
(side * 0.68, 0.00, 1.20),
|
||||
(side * 0.95, 0.28, 1.25),
|
||||
0.09,
|
||||
0.0,
|
||||
crimson,
|
||||
shin_bone,
|
||||
vertices=6,
|
||||
)
|
||||
|
||||
# Ember fissures: limited emissive geometry gives strong read without textures.
|
||||
fissures = [
|
||||
("ChestFissure", (-0.05, -0.535, 3.48), (0.12, -0.535, 2.92), "Chest"),
|
||||
("AbdomenFissure", (-0.07, -0.27, 2.47), (0.15, -0.31, 2.11), "Abdomen"),
|
||||
]
|
||||
for name, start, end, group in fissures:
|
||||
add_cone_between(name, start, end, 0.035, 0.02, ember, group, vertices=5)
|
||||
|
||||
# Join all render meshes into one skinned mesh; keep four material primitives.
|
||||
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 = "EmberMantis_Body"
|
||||
body.data.name = "EmberMantis_Body_Mesh"
|
||||
consolidate_material_slots(body)
|
||||
modifier = body.modifiers.new("EmberMantis_Armature", "ARMATURE")
|
||||
modifier.object = armature
|
||||
modifier.use_deform_preserve_volume = False
|
||||
body.parent = armature
|
||||
return body
|
||||
|
||||
|
||||
def consolidate_material_slots(obj: bpy.types.Object) -> None:
|
||||
old_materials = [slot.material for slot in obj.material_slots]
|
||||
unique: list[bpy.types.Material] = []
|
||||
new_index_by_name: dict[str, int] = {}
|
||||
old_to_new: dict[int, int] = {}
|
||||
for old_index, material in enumerate(old_materials):
|
||||
if material.name not in new_index_by_name:
|
||||
new_index_by_name[material.name] = len(unique)
|
||||
unique.append(material)
|
||||
old_to_new[old_index] = new_index_by_name[material.name]
|
||||
material_groups = {
|
||||
group.index: int(group.name.removeprefix("__MAT_"))
|
||||
for group in obj.vertex_groups
|
||||
if group.name.startswith("__MAT_")
|
||||
}
|
||||
face_material_ids: list[int] = []
|
||||
for polygon in obj.data.polygons:
|
||||
vertex = obj.data.vertices[polygon.vertices[0]]
|
||||
face_material_ids.append(next(
|
||||
material_groups[assignment.group]
|
||||
for assignment in vertex.groups
|
||||
if assignment.group in material_groups
|
||||
))
|
||||
obj.data.materials.clear()
|
||||
for material in unique:
|
||||
obj.data.materials.append(material)
|
||||
for polygon, material_id in zip(obj.data.polygons, face_material_ids, strict=True):
|
||||
polygon.material_index = material_id
|
||||
for group in [group for group in obj.vertex_groups if group.name.startswith("__MAT_")]:
|
||||
obj.vertex_groups.remove(group)
|
||||
|
||||
|
||||
def reset_pose(armature: bpy.types.Object) -> None:
|
||||
for bone in armature.pose.bones:
|
||||
bone.location = (0, 0, 0)
|
||||
bone.rotation_euler = (0, 0, 0)
|
||||
bone.scale = (1, 1, 1)
|
||||
|
||||
|
||||
def key_pose(
|
||||
armature: bpy.types.Object,
|
||||
frame: int,
|
||||
rotations: dict[str, tuple[float, float, float]] | None = None,
|
||||
locations: dict[str, tuple[float, float, float]] | None = None,
|
||||
scales: dict[str, tuple[float, float, float]] | None = None,
|
||||
) -> None:
|
||||
reset_pose(armature)
|
||||
for name, value in (rotations or {}).items():
|
||||
armature.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value)
|
||||
for name, value in (locations or {}).items():
|
||||
armature.pose.bones[name].location = value
|
||||
for name, value in (scales or {}).items():
|
||||
armature.pose.bones[name].scale = value
|
||||
for bone in armature.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 build_action(
|
||||
armature: bpy.types.Object,
|
||||
name: str,
|
||||
end_frame: int,
|
||||
poses: list[dict],
|
||||
*,
|
||||
loop: bool = False,
|
||||
) -> bpy.types.Action:
|
||||
action = bpy.data.actions.new(name)
|
||||
action.use_fake_user = True
|
||||
action.use_frame_range = True
|
||||
action.frame_start = 1
|
||||
action.frame_end = end_frame
|
||||
action.use_cyclic = loop
|
||||
armature.animation_data.action = action
|
||||
for pose in poses:
|
||||
key_pose(armature, **pose)
|
||||
armature.animation_data.action = None
|
||||
return action
|
||||
|
||||
|
||||
def create_animations(armature: bpy.types.Object) -> dict[str, dict]:
|
||||
armature.animation_data_create()
|
||||
clips: dict[str, dict] = {}
|
||||
|
||||
def add(name: str, frames: int, poses: list[dict], loop: bool = False) -> None:
|
||||
build_action(armature, name, frames, poses, loop=loop)
|
||||
clips[name] = {"frames": frames, "seconds": round(frames / FPS, 3), "loop": loop}
|
||||
|
||||
add(
|
||||
"Idle",
|
||||
60,
|
||||
[
|
||||
{"frame": 1, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
|
||||
{"frame": 16, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}},
|
||||
{"frame": 31, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
|
||||
{"frame": 46, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}},
|
||||
{"frame": 60, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
|
||||
],
|
||||
loop=True,
|
||||
)
|
||||
|
||||
walk_a = {"Thigh.L": (-24, 0, 4), "Shin.L": (18, 0, 0), "Foot.L": (-8, 0, 0), "Thigh.R": (22, 0, -4), "Shin.R": (-12, 0, 0), "Foot.R": (8, 0, 0), "UpperArm.L": (5, 0, 2), "UpperArm.R": (-5, 0, -2)}
|
||||
walk_b = {name: tuple(-component for component in rotation) for name, rotation in walk_a.items()}
|
||||
add(
|
||||
"Walk",
|
||||
30,
|
||||
[
|
||||
{"frame": 1, "rotations": walk_a},
|
||||
{"frame": 8, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}},
|
||||
{"frame": 16, "rotations": walk_b},
|
||||
{"frame": 23, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}},
|
||||
{"frame": 30, "rotations": walk_a},
|
||||
],
|
||||
loop=True,
|
||||
)
|
||||
|
||||
add(
|
||||
"Sidestep",
|
||||
18,
|
||||
[
|
||||
{"frame": 1, "rotations": {"Chest": (0, 0, 0)}},
|
||||
{"frame": 5, "locations": {"Root": (-0.10, 0, -0.03)}, "rotations": {"Chest": (0, -8, -12), "Thigh.L": (8, 0, 15), "Thigh.R": (-10, 0, 16), "Blade.L": (0, 0, 8), "Blade.R": (0, 0, 12)}},
|
||||
{"frame": 11, "locations": {"Root": (0.12, 0, 0.04)}, "rotations": {"Chest": (0, 7, 10), "Thigh.L": (-8, 0, -12), "Thigh.R": (10, 0, -15)}},
|
||||
{"frame": 18, "rotations": {"Chest": (0, 0, 0)}},
|
||||
],
|
||||
)
|
||||
|
||||
add(
|
||||
"Melee",
|
||||
24,
|
||||
[
|
||||
{"frame": 1},
|
||||
{"frame": 7, "rotations": {"Chest": (0, 0, 7), "UpperArm.R": (-12, 18, 25), "Forearm.R": (10, 0, 38), "Blade.R": (-16, 5, 42), "UpperArm.L": (5, 0, -8)}},
|
||||
{"frame": 11, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Chest": (9, 0, -12), "UpperArm.R": (18, -12, -52), "Forearm.R": (-24, 0, -62), "Blade.R": (30, 0, -58), "Head": (-8, 0, 0)}},
|
||||
{"frame": 16, "rotations": {"UpperArm.R": (6, 0, -20), "Forearm.R": (-10, 0, -25)}},
|
||||
{"frame": 24},
|
||||
],
|
||||
)
|
||||
|
||||
add(
|
||||
"LineSlash",
|
||||
32,
|
||||
[
|
||||
{"frame": 1},
|
||||
{"frame": 10, "locations": {"Root": (0, 0.05, -0.03)}, "rotations": {"Chest": (-7, 0, 10), "Head": (7, 0, -5), "UpperArm.L": (-15, 12, -30), "Forearm.L": (0, 0, -46), "Blade.L": (-20, 0, -35), "UpperArm.R": (8, 0, 8)}},
|
||||
{"frame": 15, "locations": {"Root": (0, -0.12, 0.03)}, "rotations": {"Chest": (12, 0, -16), "UpperArm.L": (24, -10, 58), "Forearm.L": (-18, 0, 74), "Blade.L": (34, 0, 70), "Head": (-9, 0, 5)}},
|
||||
{"frame": 21, "rotations": {"Chest": (5, 0, -6), "UpperArm.L": (8, 0, 20), "Forearm.L": (-5, 0, 28), "Blade.L": (10, 0, 20)}},
|
||||
{"frame": 32},
|
||||
],
|
||||
)
|
||||
|
||||
add(
|
||||
"CrossSlash",
|
||||
38,
|
||||
[
|
||||
{"frame": 1},
|
||||
{"frame": 11, "locations": {"Root": (0, 0.04, -0.05)}, "rotations": {"Chest": (-9, 0, 0), "UpperArm.L": (-8, 18, 38), "Forearm.L": (10, 0, 52), "Blade.L": (-15, 0, 32), "UpperArm.R": (-8, -18, -38), "Forearm.R": (10, 0, -52), "Blade.R": (-15, 0, -32)}},
|
||||
{"frame": 16, "locations": {"Root": (0, -0.10, 0.08)}, "rotations": {"Chest": (14, 0, 0), "UpperArm.L": (22, -14, -52), "Forearm.L": (-18, 0, -72), "Blade.L": (28, 0, -64), "UpperArm.R": (22, 14, 52), "Forearm.R": (-18, 0, 72), "Blade.R": (28, 0, 64), "Head": (-10, 0, 0)}},
|
||||
{"frame": 21, "locations": {"Root": (0, -0.04, 0.02)}, "rotations": {"Chest": (5, 0, 0), "UpperArm.L": (8, 0, -22), "Forearm.L": (-6, 0, -30), "UpperArm.R": (8, 0, 22), "Forearm.R": (-6, 0, 30)}},
|
||||
{"frame": 38},
|
||||
],
|
||||
)
|
||||
|
||||
add(
|
||||
"Recover",
|
||||
22,
|
||||
[
|
||||
{"frame": 1, "locations": {"Root": (0, -0.04, -0.05)}, "rotations": {"Chest": (14, 0, 0), "Head": (-9, 0, 0), "UpperArm.L": (12, 0, -18), "UpperArm.R": (12, 0, 18)}},
|
||||
{"frame": 8, "rotations": {"Chest": (-4, 0, 0), "Abdomen": (6, 0, 0), "Head": (4, 0, 0)}},
|
||||
{"frame": 15, "rotations": {"Chest": (2, 0, 0), "Abdomen": (-2, 0, 0)}},
|
||||
{"frame": 22},
|
||||
],
|
||||
)
|
||||
|
||||
add(
|
||||
"Stagger",
|
||||
28,
|
||||
[
|
||||
{"frame": 1},
|
||||
{"frame": 4, "locations": {"Root": (0, 0.10, -0.07)}, "rotations": {"Chest": (-18, 0, -10), "Head": (24, 0, 12), "UpperArm.L": (-16, 0, -22), "UpperArm.R": (-16, 0, 22), "Thigh.L": (12, 0, 0), "Thigh.R": (12, 0, 0)}},
|
||||
{"frame": 10, "rotations": {"Chest": (8, 0, 6), "Head": (-12, 0, -8), "Blade.L": (14, 0, 0), "Blade.R": (14, 0, 0)}},
|
||||
{"frame": 18, "rotations": {"Chest": (-3, 0, -2), "Head": (4, 0, 2)}},
|
||||
{"frame": 28},
|
||||
],
|
||||
)
|
||||
|
||||
add(
|
||||
"Death",
|
||||
72,
|
||||
[
|
||||
{"frame": 1},
|
||||
{"frame": 12, "locations": {"Root": (0, 0.08, -0.10)}, "rotations": {"Chest": (-20, 0, 9), "Head": (18, 0, -12), "UpperArm.L": (-20, 0, -25), "UpperArm.R": (-20, 0, 25), "Thigh.L": (14, 0, 0), "Thigh.R": (14, 0, 0)}},
|
||||
{"frame": 28, "locations": {"Root": (0.10, 0.20, -0.55)}, "rotations": {"Root": (0, 38, 74), "Chest": (-34, 8, -18), "Head": (28, 0, 15), "UpperArm.L": (30, 0, -48), "UpperArm.R": (-12, 0, 48), "Thigh.L": (28, 0, 22), "Thigh.R": (-14, 0, -18)}},
|
||||
{"frame": 48, "locations": {"Root": (0.18, 0.20, -1.12)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (38, 0, 22), "UpperArm.L": (42, 0, -62), "UpperArm.R": (8, 0, 58), "Thigh.L": (36, 0, 28), "Thigh.R": (-20, 0, -22)}},
|
||||
{"frame": 72, "locations": {"Root": (0.18, 0.20, -1.18)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (42, 0, 25), "UpperArm.L": (44, 0, -64), "UpperArm.R": (10, 0, 60), "Thigh.L": (38, 0, 28), "Thigh.R": (-20, 0, -22)}},
|
||||
],
|
||||
)
|
||||
|
||||
return clips
|
||||
|
||||
|
||||
def create_collision_meshes() -> list[bpy.types.Object]:
|
||||
collision_material = make_material("CollisionProxy", (0.05, 0.8, 0.2, 0.35), roughness=1.0)
|
||||
collision_parts: list[bpy.types.Object] = []
|
||||
specs = [
|
||||
("COLLISION_Body", (0, 0.18, 2.55), (0.72, 1.12, 1.55)),
|
||||
("COLLISION_Head", (0, -0.38, 4.08), (0.52, 0.62, 0.48)),
|
||||
("COLLISION_ScytheReach", (0, -1.35, 2.66), (1.82, 1.18, 0.84)),
|
||||
]
|
||||
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 = name
|
||||
obj.scale = scale
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
obj.data.materials.append(collision_material)
|
||||
collision_parts.append(obj)
|
||||
return collision_parts
|
||||
|
||||
|
||||
def select_only(objects: list[bpy.types.Object]) -> None:
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in objects:
|
||||
obj.hide_set(False)
|
||||
obj.select_set(True)
|
||||
if objects:
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
|
||||
|
||||
def export_glbs(armature: bpy.types.Object, body: bpy.types.Object, collision_parts: list[bpy.types.Object]) -> None:
|
||||
select_only([armature, body])
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(GLB_PATH),
|
||||
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,
|
||||
)
|
||||
select_only(collision_parts)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(COLLISION_PATH),
|
||||
export_format="GLB",
|
||||
use_selection=True,
|
||||
export_animations=False,
|
||||
export_skins=False,
|
||||
export_materials="NONE",
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
)
|
||||
for obj in collision_parts:
|
||||
obj.hide_render = True
|
||||
obj.hide_set(True)
|
||||
|
||||
|
||||
def look_at(obj: bpy.types.Object, target: tuple[float, float, float]) -> None:
|
||||
direction = Vector(target) - obj.location
|
||||
obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
|
||||
def setup_preview_scene() -> tuple[bpy.types.Object, list[bpy.types.Object]]:
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = 900
|
||||
scene.render.resolution_y = 900
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
scene.render.film_transparent = False
|
||||
scene.render.fps = FPS
|
||||
scene.world.color = (0.006, 0.008, 0.014)
|
||||
world_nodes = scene.world.node_tree.nodes if scene.world.use_nodes else None
|
||||
if not scene.world.use_nodes:
|
||||
scene.world.use_nodes = True
|
||||
world_nodes = scene.world.node_tree.nodes
|
||||
world_nodes["Background"].inputs["Color"].default_value = (0.006, 0.009, 0.018, 1)
|
||||
world_nodes["Background"].inputs["Strength"].default_value = 0.28
|
||||
|
||||
camera_data = bpy.data.cameras.new("PreviewCamera")
|
||||
camera = bpy.data.objects.new("PreviewCamera", camera_data)
|
||||
bpy.context.collection.objects.link(camera)
|
||||
camera_data.lens = 58
|
||||
scene.camera = camera
|
||||
|
||||
lights: list[bpy.types.Object] = []
|
||||
light_specs = [
|
||||
("Key", "AREA", (4.5, -6.5, 7.5), (1.0, 0.30, 0.10), 1150, 5.0),
|
||||
("Fill", "AREA", (-5.5, -3.0, 4.5), (0.10, 0.25, 1.0), 850, 4.0),
|
||||
("Rim", "AREA", (1.0, 5.5, 6.5), (1.0, 0.08, 0.02), 1300, 3.0),
|
||||
]
|
||||
for name, kind, location, color, energy, size in light_specs:
|
||||
data = bpy.data.lights.new(name, type=kind)
|
||||
data.energy = energy
|
||||
data.color = color
|
||||
data.shape = "DISK"
|
||||
data.size = size
|
||||
light = bpy.data.objects.new(name, data)
|
||||
light.location = location
|
||||
bpy.context.collection.objects.link(light)
|
||||
look_at(light, (0, 0, 2.5))
|
||||
lights.append(light)
|
||||
|
||||
floor_mat = make_material("PreviewFloor", (0.018, 0.022, 0.032, 1), metallic=0.05, roughness=0.82)
|
||||
bpy.ops.mesh.primitive_plane_add(size=30, location=(0, 0, 0))
|
||||
floor = bpy.context.object
|
||||
floor.name = "PreviewFloor"
|
||||
floor.data.materials.append(floor_mat)
|
||||
return camera, lights + [floor]
|
||||
|
||||
|
||||
def activate_action(armature: bpy.types.Object, name: str, frame: int) -> None:
|
||||
armature.animation_data.action = bpy.data.actions[name]
|
||||
bpy.context.scene.frame_set(frame)
|
||||
|
||||
|
||||
def render_previews(armature: bpy.types.Object) -> None:
|
||||
camera, _studio = setup_preview_scene()
|
||||
views = [
|
||||
("ember_mantis_three_quarter.png", (7.4, -10.5, 5.8), "Idle", 16, 58),
|
||||
("ember_mantis_front.png", (0.0, -12.5, 4.2), "Idle", 16, 62),
|
||||
("ember_mantis_side.png", (10.8, -0.2, 4.3), "Idle", 16, 60),
|
||||
("ember_mantis_cross_slash.png", (7.4, -10.5, 5.8), "CrossSlash", 16, 58),
|
||||
]
|
||||
for filename, camera_position, action_name, frame, lens in views:
|
||||
activate_action(armature, action_name, frame)
|
||||
camera.location = camera_position
|
||||
camera.data.lens = lens
|
||||
look_at(camera, (0, -0.05, 2.55))
|
||||
bpy.context.scene.render.filepath = str(PREVIEW_DIR / filename)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
armature.animation_data.action = None
|
||||
bpy.context.scene.frame_set(1)
|
||||
|
||||
|
||||
def write_metadata(body: bpy.types.Object, clips: dict[str, dict]) -> None:
|
||||
triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons)
|
||||
metadata = {
|
||||
"name": "Ember Mantis Duelist",
|
||||
"assetId": "ember-mantis-duelist",
|
||||
"license": "Original project asset",
|
||||
"sourceConcept": "IWT2 Ember Mantis Duelist side-cel concept",
|
||||
"authoringTool": bpy.app.version_string,
|
||||
"format": "glTF 2.0 binary (GLB)",
|
||||
"coordinateConvention": {"up": "+Y after glTF export", "blenderForward": "-Y", "units": "meters"},
|
||||
"runtime": {
|
||||
"renderMesh": "EmberMantis_Body",
|
||||
"armature": "EmberMantis_Rig",
|
||||
"collisionAsset": COLLISION_PATH.name,
|
||||
"materials": [material.name for material in body.data.materials],
|
||||
"trianglesApprox": triangles,
|
||||
},
|
||||
"animations": clips,
|
||||
}
|
||||
METADATA_PATH.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
reset_scene()
|
||||
mats = {
|
||||
"chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42),
|
||||
"crimson": make_material("M_CrimsonArmor", (0.34, 0.025, 0.018, 1), metallic=0.22, roughness=0.36),
|
||||
"deep_red": make_material("M_DeepRed", (0.13, 0.012, 0.012, 1), metallic=0.15, roughness=0.48),
|
||||
"bone": make_material("M_BoneBlade", (0.62, 0.45, 0.26, 1), metallic=0.12, roughness=0.28),
|
||||
"ember": make_material(
|
||||
"M_EmberGlow",
|
||||
(1.0, 0.075, 0.004, 1),
|
||||
roughness=0.2,
|
||||
emission=(1.0, 0.028, 0.0, 1),
|
||||
emission_strength=8.0,
|
||||
),
|
||||
"eye": make_material(
|
||||
"M_EyeGlow",
|
||||
(1.0, 0.33, 0.01, 1),
|
||||
roughness=0.16,
|
||||
emission=(1.0, 0.14, 0.0, 1),
|
||||
emission_strength=12.0,
|
||||
),
|
||||
}
|
||||
RUNTIME_MATERIALS.extend(
|
||||
[mats["chitin"], mats["crimson"], mats["deep_red"], mats["eye"], mats["bone"], mats["ember"]]
|
||||
)
|
||||
armature = create_armature()
|
||||
body = create_model(armature, mats)
|
||||
clips = create_animations(armature)
|
||||
collision_parts = create_collision_meshes()
|
||||
export_glbs(armature, body, collision_parts)
|
||||
write_metadata(body, clips)
|
||||
render_previews(armature)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH))
|
||||
print(f"BUILT={BLEND_PATH}")
|
||||
print(f"EXPORTED={GLB_PATH}")
|
||||
print(f"COLLISION={COLLISION_PATH}")
|
||||
print(f"ANIMATIONS={','.join(clips)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user