Files
healer-man/scripts/runewaker-pipeline/blender/render-animated-actor.py
T
2026-08-14 15:56:39 -04:00

118 lines
4.6 KiB
Python

"""Render diagnostic contact sheets for an animated RuneWaker actor GLB.
Usage (after Blender's `--` separator):
input.glb output-directory [action-name ...]
The input should be an uncompressed diagnostic GLB because Blender's importer
does not currently decode EXT_meshopt_compression. Each requested action is
rendered at its first, middle, and final frame so bind-pose or skinning failures
cannot hide behind an asset-existence/clip-name audit.
"""
from __future__ import annotations
import math
import os
import sys
import bpy
from mathutils import Vector
def arguments() -> tuple[str, str, list[str]]:
values = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
if len(values) < 2:
raise SystemExit("Expected input.glb and output-directory.")
return values[0], values[1], values[2:]
def look_at(obj: bpy.types.Object, point: Vector) -> None:
obj.rotation_euler = (point - obj.location).to_track_quat("-Z", "Y").to_euler()
input_glb, output_directory, requested_actions = arguments()
os.makedirs(output_directory, exist_ok=True)
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.materials, bpy.data.cameras, bpy.data.lights):
for datablock in list(datablocks):
if datablock.users == 0:
datablocks.remove(datablock)
bpy.ops.import_scene.gltf(filepath=input_glb)
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 512
scene.render.resolution_y = 512
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.world.color = (0.025, 0.03, 0.04)
mesh_objects = [obj for obj in scene.objects if obj.type == "MESH"]
armatures = [obj for obj in scene.objects if obj.type == "ARMATURE"]
if not mesh_objects or not armatures:
raise SystemExit("The imported GLB must contain a mesh and armature.")
armature = armatures[0]
corners = []
for obj in mesh_objects:
corners.extend(obj.matrix_world @ Vector(corner) for corner in obj.bound_box)
minimum = Vector((min(v.x for v in corners), min(v.y for v in corners), min(v.z for v in corners)))
maximum = Vector((max(v.x for v in corners), max(v.y for v in corners), max(v.z for v in corners)))
center = (minimum + maximum) * 0.5
height = max(maximum.z - minimum.z, maximum.y - minimum.y, 1.0)
camera_data = bpy.data.cameras.new("DiagnosticCamera")
camera = bpy.data.objects.new("DiagnosticCamera", camera_data)
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.lens = 58
camera.location = center + Vector((height * 1.7, -height * 2.6, height * 0.45))
look_at(camera, center + Vector((0.0, 0.0, height * 0.05)))
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = height * 2.0
key = bpy.data.objects.new("Key", key_data)
scene.collection.objects.link(key)
key.location = center + Vector((height * 1.8, -height * 1.5, height * 2.0))
look_at(key, center)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = height * 1.5
fill = bpy.data.objects.new("Fill", fill_data)
scene.collection.objects.link(fill)
fill.location = center + Vector((-height * 1.6, -height * 0.5, height * 0.7))
look_at(fill, center)
actions_by_name = {action.name: action for action in bpy.data.actions}
action_names = requested_actions or sorted(actions_by_name)
if armature.animation_data is None:
armature.animation_data_create()
for action_name in action_names:
if action_name == "Bind Pose":
armature.animation_data.action = None
scene.frame_set(0)
scene.render.filepath = os.path.join(output_directory, "Bind-Pose.png")
bpy.ops.render.render(write_still=True)
print(f"RENDERED Bind Pose -> {scene.render.filepath}")
continue
action = actions_by_name.get(action_name)
if action is None:
print(f"SKIP missing action: {action_name}")
continue
armature.animation_data.action = action
first, last = action.frame_range
frames = [first, (first + last) * 0.5, last]
safe_name = "".join(character if character.isalnum() else "-" for character in action_name).strip("-")
for label, frame in zip(("start", "middle", "end"), frames):
scene.frame_set(math.floor(frame), subframe=frame - math.floor(frame))
scene.render.filepath = os.path.join(output_directory, f"{safe_name}-{label}.png")
bpy.ops.render.render(write_still=True)
print(f"RENDERED {action_name} {label} frame={frame:.3f} -> {scene.render.filepath}")