118 lines
4.6 KiB
Python
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}")
|