squad-proto/tools/blender/agent_rig.py

950 lines
47 KiB
Python
Raw Permalink Normal View History

"""Blender-сторона конвейера модульных агентов. Запускается ВНУТРИ Blender.
blender --factory-startup -b --python tools/blender/agent_rig.py -- build --config cfg.json
blender --factory-startup -b rig.blend --python tools/blender/agent_rig.py -- render --config cfg.json
Скрипт ничего не знает про SpriteForge и про игру: он строит детерминированную
сцену (риг, тело, броня, оружие, точки привязки) и рендерит один компонент под
одной фиксированной ортографической изометрической камерой.
Правило выравнивания: камера СТОИТ, поворачивается модель. Начало координат
рига (точка между стопами) всегда проецируется ровно в центр кадра, поэтому
упаковщик на стороне Python режет все кадры одним и тем же прямоугольником и
никакого подгона по bbox для брони и оружия не существует в принципе.
PyYAML внутри Blender нет конфиг приходит одним JSON-файлом.
"""
from __future__ import annotations
import json
import math
import os
import sys
import bmesh
import bpy
from mathutils import Matrix, Quaternion, Vector
# ----------------------------------------------------------------------------
# Пропорции. Худой взрослый оперативник, 1.80 м, размах плеч 0.40 —
# это силуэт Splinter Cell, а не силовая броня.
# ----------------------------------------------------------------------------
BONES = [
# name, head, tail, parent
("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None),
("hips", (0.00, 0.00, 0.95), (0.00, 0.00, 1.08), "root"),
("spine", (0.00, 0.00, 1.08), (0.00, 0.00, 1.30), "hips"),
("chest", (0.00, 0.00, 1.30), (0.00, 0.00, 1.50), "spine"),
("neck", (0.00, 0.00, 1.50), (0.00, 0.00, 1.58), "chest"),
("head", (0.00, 0.00, 1.58), (0.00, 0.00, 1.80), "neck"),
("shoulder_r", (0.00, 0.00, 1.46), (-0.17, 0.00, 1.45), "chest"),
("upper_arm_r", (-0.19, 0.00, 1.44), (-0.20, 0.00, 1.15), "shoulder_r"),
("forearm_r", (-0.20, 0.00, 1.15), (-0.21, 0.00, 0.89), "upper_arm_r"),
("hand_r", (-0.21, 0.00, 0.89), (-0.21, 0.00, 0.80), "forearm_r"),
("shoulder_l", (0.00, 0.00, 1.46), (0.17, 0.00, 1.45), "chest"),
("upper_arm_l", (0.19, 0.00, 1.44), (0.20, 0.00, 1.15), "shoulder_l"),
("forearm_l", (0.20, 0.00, 1.15), (0.21, 0.00, 0.89), "upper_arm_l"),
("hand_l", (0.21, 0.00, 0.89), (0.21, 0.00, 0.80), "forearm_l"),
("thigh_r", (-0.09, 0.00, 0.93), (-0.10, 0.00, 0.50), "hips"),
("shin_r", (-0.10, 0.00, 0.50), (-0.10, 0.00, 0.08), "thigh_r"),
("foot_r", (-0.10, 0.00, 0.08), (-0.10, -0.16, 0.02), "shin_r"),
("thigh_l", (0.09, 0.00, 0.93), (0.10, 0.00, 0.50), "hips"),
("shin_l", (0.10, 0.00, 0.50), (0.10, 0.00, 0.08), "thigh_l"),
("foot_l", (0.10, 0.00, 0.08), (0.10, -0.16, 0.02), "shin_l"),
]
# Персонаж смотрит в -Y (на камеру) — это направление `south`.
FACE = Vector((0.0, -1.0, 0.0))
ATTACHMENTS = {
"hand_r": (-0.21, -0.02, 0.87),
"hand_l": (0.21, -0.02, 0.87),
"chest": (0.00, 0.10, 1.36),
"hips": (-0.12, 0.02, 0.98),
"head": (0.00, 0.00, 1.70),
}
MATERIALS = {
# name: (base color RGB, roughness, metallic)
"skin": ((0.42, 0.32, 0.27), 0.72, 0.00),
"bodysuit": ((0.035, 0.042, 0.052), 0.72, 0.02),
"webbing": ((0.06, 0.07, 0.08), 0.85, 0.00),
"cloth": ((0.13, 0.14, 0.15), 0.80, 0.00),
# Keep equipment inside the same near-black value family as the suit.
# Faction colour belongs on tiny markings; a bright whole torso destroys
# the low-resolution silhouette and reads as a cartoon T-shirt.
"ballistic": ((0.075, 0.085, 0.080), 0.64, 0.04),
"ceramic": ((0.17, 0.18, 0.19), 0.45, 0.02),
"shroud": ((0.07, 0.08, 0.10), 0.90, 0.00),
"gunmetal": ((0.08, 0.09, 0.10), 0.38, 0.75),
"polymer": ((0.10, 0.10, 0.11), 0.60, 0.00),
"blade": ((0.52, 0.55, 0.58), 0.22, 0.90),
"wood": ((0.16, 0.12, 0.09), 0.75, 0.00),
"helmet": ((0.055, 0.065, 0.075), 0.42, 0.18),
"visor": ((0.015, 0.20, 0.27), 0.14, 0.58),
"rubber": ((0.025, 0.028, 0.032), 0.88, 0.00),
"marking": ((0.32, 0.075, 0.055), 0.62, 0.04),
}
# Каждая деталь: (имя, кость, материал, форма, параметры).
# Формы: capsule(p0, p1, r0, r1) | box(center, size, yaw) | sphere(center, r, scale)
BODY_PARTS = [
# Fully covered covert operator: a readable helmet/visor/respirator cluster
# replaces the bald mannequin head and remains distinct at 28 px/m.
("head", "head", "rubber", ("sphere", ((0.00, -0.01, 1.68), 0.096, (0.90, 1.00, 1.08)))),
("helmet", "head", "helmet", ("sphere", ((0.00, 0.00, 1.715), 0.112, (1.03, 1.02, 0.78)))),
("visor", "head", "visor", ("box", ((0.00, -0.102, 1.695), (0.155, 0.035, 0.060), 0.0))),
("respirator", "head", "rubber", ("box", ((0.00, -0.105, 1.625), (0.105, 0.045, 0.072), 0.0))),
("neck", "neck", "rubber", ("capsule", ((0.00, 0.00, 1.49), (0.00, -0.01, 1.59), 0.052, 0.048))),
("chest", "chest", "bodysuit", ("capsule", ((0.00, 0.00, 1.27), (0.00, 0.00, 1.47), 0.145, 0.165))),
("shoulders", "chest", "bodysuit", ("capsule", ((-0.23, 0.00, 1.45), (0.23, 0.00, 1.45), 0.082, 0.082))),
("chest_rig", "chest", "webbing", ("box", ((0.00, -0.125, 1.34), (0.255, 0.050, 0.235), 0.0))),
("id_stripe", "chest", "marking", ("box", ((-0.105, -0.155, 1.40), (0.040, 0.018, 0.135), 0.0))),
("shoulder_shell_r", "shoulder_r", "helmet", ("box", ((-0.205, 0.00, 1.43), (0.115, 0.145, 0.095), 0.0))),
("shoulder_shell_l", "shoulder_l", "helmet", ("box", ((0.205, 0.00, 1.43), (0.115, 0.145, 0.095), 0.0))),
("backpack", "chest", "helmet", ("box", ((0.00, 0.135, 1.31), (0.245, 0.105, 0.285), 0.0))),
("abdomen", "spine", "bodysuit", ("capsule", ((0.00, 0.00, 1.09), (0.00, 0.00, 1.29), 0.122, 0.140))),
("belt", "hips", "webbing", ("box", ((0.00, 0.00, 1.055), (0.285, 0.215, 0.060), 0.0))),
("holster", "hips", "helmet", ("box", ((0.155, -0.015, 0.92), (0.085, 0.095, 0.185), 0.0))),
("pelvis", "hips", "bodysuit", ("capsule", ((0.00, 0.00, 0.94), (0.00, 0.00, 1.10), 0.128, 0.122))),
("upper_arm_r", "upper_arm_r", "bodysuit", ("capsule", ((-0.19, 0.00, 1.44), (-0.20, 0.00, 1.15), 0.072, 0.060))),
("forearm_r", "forearm_r", "bodysuit", ("capsule", ((-0.20, 0.00, 1.15), (-0.21, 0.00, 0.89), 0.064, 0.052))),
("hand_r", "hand_r", "rubber", ("capsule", ((-0.21, 0.00, 0.89), (-0.21, -0.01, 0.80), 0.042, 0.036))),
("upper_arm_l", "upper_arm_l", "bodysuit", ("capsule", ((0.19, 0.00, 1.44), (0.20, 0.00, 1.15), 0.072, 0.060))),
("forearm_l", "forearm_l", "bodysuit", ("capsule", ((0.20, 0.00, 1.15), (0.21, 0.00, 0.89), 0.064, 0.052))),
("hand_l", "hand_l", "rubber", ("capsule", ((0.21, 0.00, 0.89), (0.21, -0.01, 0.80), 0.042, 0.036))),
("thigh_r", "thigh_r", "bodysuit", ("capsule", ((-0.09, 0.00, 0.94), (-0.10, 0.00, 0.50), 0.094, 0.074))),
("shin_r", "shin_r", "bodysuit", ("capsule", ((-0.10, 0.00, 0.50), (-0.10, 0.00, 0.09), 0.074, 0.056))),
("knee_r", "shin_r", "helmet", ("box", ((-0.10, -0.055, 0.51), (0.105, 0.070, 0.105), 0.0))),
("foot_r", "foot_r", "rubber", ("box", ((-0.10, -0.05, 0.050), (0.115, 0.255, 0.105), 0.0))),
("thigh_l", "thigh_l", "bodysuit", ("capsule", ((0.09, 0.00, 0.94), (0.10, 0.00, 0.50), 0.094, 0.074))),
("shin_l", "shin_l", "bodysuit", ("capsule", ((0.10, 0.00, 0.50), (0.10, 0.00, 0.09), 0.074, 0.056))),
("knee_l", "shin_l", "helmet", ("box", ((0.10, -0.055, 0.51), (0.105, 0.070, 0.105), 0.0))),
("foot_l", "foot_l", "rubber", ("box", ((0.10, -0.05, 0.050), (0.115, 0.255, 0.105), 0.0))),
]
# Броня. Каждый слой — тонкая оболочка поверх тела, силуэт почти не растёт:
# отряд должен читаться по одному и тому же пятну в любой экипировке.
ARMOR_PARTS = {
"jacket": [
("torso", "chest", "cloth", ("capsule", ((0.00, 0.00, 1.12), (0.00, 0.00, 1.47), 0.140, 0.158))),
("sleeve_r", "upper_arm_r", "cloth", ("capsule", ((-0.19, 0.00, 1.45), (-0.20, 0.00, 1.18), 0.058, 0.050))),
("sleeve_l", "upper_arm_l", "cloth", ("capsule", ((0.19, 0.00, 1.45), (0.20, 0.00, 1.18), 0.058, 0.050))),
("strap_v", "chest", "webbing", ("box", ((-0.05, -0.13, 1.32), (0.045, 0.030, 0.300), 0.0))),
("strap_h", "chest", "webbing", ("box", ((0.00, 0.00, 1.19), (0.300, 0.230, 0.036), 0.0))),
("pouch", "hips", "webbing", ("box", ((0.10, -0.10, 1.06), (0.085, 0.060, 0.080), 0.0))),
],
"vest": [
("shell", "chest", "ballistic", ("capsule", ((0.00, 0.00, 1.18), (0.00, 0.00, 1.45), 0.138, 0.152))),
("collar", "chest", "ballistic", ("capsule", ((-0.09, 0.00, 1.47), (0.09, 0.00, 1.47), 0.048, 0.048))),
("mag_a", "chest", "polymer", ("box", ((-0.07, -0.14, 1.26), (0.070, 0.045, 0.110), 0.0))),
("mag_b", "chest", "polymer", ("box", ((0.03, -0.14, 1.26), (0.070, 0.045, 0.110), 0.0))),
("belt", "hips", "webbing", ("box", ((0.00, 0.00, 1.02), (0.250, 0.200, 0.045), 0.0))),
],
"plate": [
("chest_up", "chest", "ceramic", ("box", ((0.00, -0.06, 1.40), (0.290, 0.130, 0.115), 0.0))),
("chest_lo", "chest", "ceramic", ("box", ((0.00, -0.05, 1.26), (0.270, 0.130, 0.115), 0.0))),
("back", "chest", "ceramic", ("box", ((0.00, 0.07, 1.33), (0.280, 0.100, 0.250), 0.0))),
("cap_r", "shoulder_r", "ceramic", ("capsule", ((-0.13, 0.00, 1.47), (-0.21, 0.00, 1.42), 0.062, 0.052))),
("cap_l", "shoulder_l", "ceramic", ("capsule", ((0.13, 0.00, 1.47), (0.21, 0.00, 1.42), 0.062, 0.052))),
("skirt", "hips", "ceramic", ("box", ((0.00, 0.00, 1.02), (0.260, 0.215, 0.090), 0.0))),
],
"shroud": [
("torso", "chest", "shroud", ("capsule", ((0.00, 0.00, 1.08), (0.00, 0.00, 1.48), 0.133, 0.150))),
("hood", "head", "shroud", ("sphere", ((0.00, 0.02, 1.70), 0.112, (0.94, 1.06, 1.10)))),
("mask", "head", "shroud", ("box", ((0.00, -0.08, 1.65), (0.130, 0.060, 0.090), 0.0))),
("wrap_r", "forearm_r", "shroud", ("capsule", ((-0.20, 0.00, 1.16), (-0.21, 0.00, 0.94), 0.048, 0.040))),
("wrap_l", "forearm_l", "shroud", ("capsule", ((0.20, 0.00, 1.16), (0.21, 0.00, 0.94), 0.048, 0.040))),
],
}
# Оружие. Всё висит на кости hand_r, координаты — мировые в позе покоя.
HAND_R = Vector((-0.21, -0.02, 0.86))
WEAPON_PARTS = {
"sword": [
("grip", "hand_r", "wood", ("capsule", ((-0.21, 0.02, 0.90), (-0.21, -0.06, 0.84), 0.020, 0.020))),
("guard", "hand_r", "gunmetal", ("box", ((-0.21, -0.09, 0.83), (0.150, 0.030, 0.026), 0.0))),
("blade", "hand_r", "blade", ("capsule", ((-0.21, -0.10, 0.82), (-0.23, -0.82, 0.72), 0.021, 0.010))),
],
"dagger": [
("grip", "hand_r", "polymer", ("capsule", ((-0.21, 0.01, 0.89), (-0.21, -0.05, 0.85), 0.018, 0.018))),
("blade", "hand_r", "blade", ("capsule", ((-0.21, -0.06, 0.84), (-0.22, -0.28, 0.79), 0.017, 0.007))),
],
"rifle": [
# The weapon crosses the torso by ~12 degrees instead of pointing exactly
# along local -Y. The deliberate cheat keeps it readable in north/south
# sprite directions where a physically straight rifle collapses to 1 px.
("receiver", "hand_r", "gunmetal", ("box", ((-0.10, -0.20, 0.92), (0.090, 0.320, 0.110), -0.21))),
("barrel", "hand_r", "gunmetal", ("capsule", ((-0.13, -0.34, 0.93), (-0.27, -0.68, 0.93), 0.030, 0.024))),
("stock", "hand_r", "polymer", ("box", ((-0.02, 0.02, 0.90), (0.078, 0.210, 0.095), -0.21))),
("mag", "hand_r", "polymer", ("box", ((-0.09, -0.16, 0.83), (0.060, 0.080, 0.150), -0.21))),
("optic", "hand_r", "visor", ("capsule", ((-0.11, -0.24, 1.00), (-0.05, -0.09, 1.00), 0.024, 0.024))),
],
"shotgun": [
("receiver", "hand_r", "gunmetal", ("box", ((-0.10, -0.18, 0.92), (0.070, 0.270, 0.090), -0.21))),
("barrel", "hand_r", "gunmetal", ("capsule", ((-0.13, -0.30, 0.94), (-0.25, -0.61, 0.94), 0.026, 0.023))),
("tube", "hand_r", "gunmetal", ("capsule", ((-0.13, -0.30, 0.89), (-0.23, -0.57, 0.89), 0.020, 0.017))),
("stock", "hand_r", "wood", ("box", ((-0.02, 0.02, 0.89), (0.060, 0.180, 0.078), -0.21))),
],
"pistol": [
("slide", "hand_r", "gunmetal", ("box", ((-0.20, -0.11, 0.92), (0.032, 0.150, 0.048), 0.0))),
("can", "hand_r", "gunmetal", ("capsule", ((-0.20, -0.18, 0.92), (-0.20, -0.32, 0.92), 0.021, 0.021))),
("grip", "hand_r", "polymer", ("box", ((-0.20, -0.04, 0.84), (0.030, 0.052, 0.110), 0.0))),
],
"bow": [
("limb_up", "hand_r", "polymer", ("capsule", ((-0.21, -0.06, 0.90), (-0.24, -0.20, 1.14), 0.017, 0.012))),
("limb_lo", "hand_r", "polymer", ("capsule", ((-0.21, -0.06, 0.84), (-0.24, -0.20, 0.60), 0.017, 0.012))),
("riser", "hand_r", "gunmetal", ("capsule", ((-0.21, -0.05, 0.94), (-0.21, -0.05, 0.80), 0.021, 0.021))),
("cam_up", "hand_r", "gunmetal", ("capsule", ((-0.245, -0.22, 1.15), (-0.235, -0.18, 1.15), 0.028, 0.028))),
("cam_lo", "hand_r", "gunmetal", ("capsule", ((-0.245, -0.22, 0.59), (-0.235, -0.18, 0.59), 0.028, 0.028))),
("string", "hand_r", "webbing", ("capsule", ((-0.24, -0.21, 1.15), (-0.24, -0.21, 0.59), 0.005, 0.005))),
],
}
# Fitted modules for the imported universal humanoid. Coordinates are metres
# in its authored T-pose; every object is rigidly attached to the deform rig,
# so the same source actions drive body, armor and held equipment.
SOURCE_BODY_PARTS = [
("helmet", "DEF-head", "helmet", ("sphere", ((0.00, -0.005, 1.735), 0.116, (0.92, 1.02, 0.86)))),
("visor", "DEF-head", "visor", ("box", ((0.00, -0.108, 1.720), (0.158, 0.030, 0.052), 0.0))),
("respirator", "DEF-head", "rubber", ("box", ((0.00, -0.112, 1.650), (0.105, 0.050, 0.070), 0.0))),
("collar", "DEF-spine.003", "rubber", ("capsule", ((-0.105, 0.00, 1.515), (0.105, 0.00, 1.515), 0.045, 0.045))),
]
SOURCE_ARMOR_PARTS = {
"jacket": [
("torso", "DEF-spine.002", "cloth", ("capsule", ((0.0, 0.0, 1.18), (0.0, 0.0, 1.45), 0.145, 0.175))),
("harness_v", "DEF-spine.003", "webbing", ("box", ((-0.055, -0.145, 1.355), (0.045, 0.030, 0.285), 0.0))),
("harness_h", "DEF-spine.002", "webbing", ("box", ((0.0, -0.138, 1.245), (0.310, 0.035, 0.040), 0.0))),
],
"vest": [
("shell", "DEF-spine.002", "ballistic", ("capsule", ((0.0, 0.0, 1.235), (0.0, 0.0, 1.435), 0.125, 0.150))),
("chest_plate", "DEF-spine.003", "ceramic", ("box", ((0.0, -0.118, 1.370), (0.240, 0.035, 0.145), 0.0))),
("mag_l", "DEF-spine.002", "polymer", ("box", ((-0.052, -0.137, 1.270), (0.050, 0.035, 0.090), 0.0))),
("mag_r", "DEF-spine.002", "polymer", ("box", ((0.025, -0.137, 1.270), (0.050, 0.035, 0.090), 0.0))),
],
"plate": [
("chest", "DEF-spine.003", "ceramic", ("box", ((0.0, -0.125, 1.390), (0.330, 0.105, 0.205), 0.0))),
("abdomen", "DEF-spine.002", "ceramic", ("box", ((0.0, -0.115, 1.210), (0.285, 0.095, 0.135), 0.0))),
("pauldron_l", "DEF-upper_arm.L", "ceramic", ("sphere", ((0.225, 0.045, 1.445), 0.090, (1.20, 0.90, 0.68)))),
("pauldron_r", "DEF-upper_arm.R", "ceramic", ("sphere", ((-0.225, 0.045, 1.445), 0.090, (1.20, 0.90, 0.68)))),
],
"shroud": [
("torso", "DEF-spine.002", "shroud", ("capsule", ((0.0, 0.0, 1.16), (0.0, 0.0, 1.47), 0.145, 0.170))),
("hood", "DEF-head", "shroud", ("sphere", ((0.0, 0.005, 1.735), 0.122, (0.98, 1.04, 0.98)))),
("mask", "DEF-head", "rubber", ("box", ((0.0, -0.105, 1.675), (0.135, 0.040, 0.105), 0.0))),
],
}
SOURCE_WEAPON_PARTS = {
"pistol": [
("slide", "DEF-hand.R", "gunmetal", ("box", ((-0.775, -0.055, 1.445), (0.050, 0.210, 0.060), 0.0))),
("suppressor", "DEF-hand.R", "gunmetal", ("capsule", ((-0.775, -0.145, 1.445), (-0.775, -0.330, 1.445), 0.025, 0.022))),
("grip", "DEF-hand.R", "polymer", ("box", ((-0.775, 0.015, 1.385), (0.045, 0.065, 0.125), 0.0))),
],
"rifle": [
("receiver", "DEF-hand.R", "gunmetal", ("box", ((-0.700, -0.105, 1.445), (0.085, 0.330, 0.105), -0.12))),
("barrel", "DEF-hand.R", "gunmetal", ("capsule", ((-0.720, -0.250, 1.455), (-0.790, -0.650, 1.455), 0.028, 0.021))),
("stock", "DEF-hand.R", "polymer", ("box", ((-0.650, 0.105, 1.430), (0.080, 0.230, 0.095), -0.12))),
("optic", "DEF-hand.R", "visor", ("capsule", ((-0.690, -0.145, 1.520), (-0.665, -0.015, 1.520), 0.024, 0.024))),
],
"shotgun": [
("receiver", "DEF-hand.R", "gunmetal", ("box", ((-0.700, -0.100, 1.445), (0.080, 0.300, 0.100), -0.10))),
("barrel", "DEF-hand.R", "gunmetal", ("capsule", ((-0.720, -0.240, 1.455), (-0.780, -0.575, 1.455), 0.030, 0.025))),
("tube", "DEF-hand.R", "gunmetal", ("capsule", ((-0.720, -0.235, 1.405), (-0.770, -0.535, 1.405), 0.020, 0.018))),
],
"dagger": [
("grip", "DEF-hand.R", "polymer", ("capsule", ((-0.775, 0.02, 1.44), (-0.775, -0.07, 1.44), 0.020, 0.018))),
("blade", "DEF-hand.R", "blade", ("capsule", ((-0.775, -0.08, 1.44), (-0.790, -0.33, 1.42), 0.018, 0.006))),
],
"sword": [
("grip", "DEF-hand.R", "polymer", ("capsule", ((-0.775, 0.03, 1.44), (-0.775, -0.10, 1.44), 0.022, 0.020))),
("blade", "DEF-hand.R", "blade", ("capsule", ((-0.775, -0.11, 1.44), (-0.800, -0.78, 1.36), 0.023, 0.008))),
],
"bow": [
("riser", "DEF-hand.R", "gunmetal", ("capsule", ((-0.775, -0.05, 1.58), (-0.775, -0.05, 1.28), 0.020, 0.020))),
("limb", "DEF-hand.R", "polymer", ("capsule", ((-0.775, -0.05, 1.58), (-0.800, -0.15, 1.80), 0.015, 0.010))),
("limb_low", "DEF-hand.R", "polymer", ("capsule", ((-0.775, -0.05, 1.28), (-0.800, -0.15, 1.06), 0.015, 0.010))),
],
}
# ----------------------------------------------------------------------------
# Мелкие утилиты
# ----------------------------------------------------------------------------
def log(message):
print("[agent_rig] " + message)
def collection(name, parent):
existing = bpy.data.collections.get(name)
if existing is not None:
return existing
made = bpy.data.collections.new(name)
parent.children.link(made)
return made
def material(name):
existing = bpy.data.materials.get(name)
if existing is not None:
return existing
color, roughness, metallic = MATERIALS[name]
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
_set_socket(bsdf, "Base Color", (color[0], color[1], color[2], 1.0))
_set_socket(bsdf, "Roughness", roughness)
_set_socket(bsdf, "Metallic", metallic)
# В 4.x «Specular» переименован; гасим блик мягко и молча, если сокета нет.
_set_socket(bsdf, "Specular IOR Level", 0.35)
_set_socket(bsdf, "Specular", 0.35)
# High-frequency material breakup survives the pre-render/downsample
# step as LoD-like grime and fabric/ceramic separation. Geometry still
# owns the silhouette; this is deliberately not noisy pixel confetti.
nodes = mat.node_tree.nodes
links = mat.node_tree.links
noise = nodes.new("ShaderNodeTexNoise")
noise.inputs["Scale"].default_value = 22.0
noise.inputs["Detail"].default_value = 3.0
noise.inputs["Roughness"].default_value = 0.72
ramp = nodes.new("ShaderNodeValToRGB")
dark = tuple(max(0.0, channel * 0.58) for channel in color)
light = tuple(min(1.0, channel * 1.35 + 0.015) for channel in color)
ramp.color_ramp.elements[0].color = (*dark, 1.0)
ramp.color_ramp.elements[1].color = (*light, 1.0)
bump = nodes.new("ShaderNodeBump")
bump.inputs["Strength"].default_value = 0.16
bump.inputs["Distance"].default_value = 0.035
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
links.new(noise.outputs["Fac"], bump.inputs["Height"])
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
mat.diffuse_color = (color[0], color[1], color[2], 1.0)
return mat
def _set_socket(node, name, value):
socket = node.inputs.get(name)
if socket is None:
return
try:
socket.default_value = value
except (TypeError, ValueError):
pass
def mesh_object(name, bm, mat, coll):
mesh = bpy.data.meshes.new(name)
bm.to_mesh(mesh)
bm.free()
mesh.materials.append(mat)
for polygon in mesh.polygons:
polygon.use_smooth = False
obj = bpy.data.objects.new(name, mesh)
coll.objects.link(obj)
return obj
def build_shape(kind, params):
"""Возвращает bmesh детали в МИРОВЫХ координатах позы покоя."""
bm = bmesh.new()
if kind == "capsule":
p0, p1, r0, r1 = Vector(params[0]), Vector(params[1]), params[2], params[3]
axis = p1 - p0
length = axis.length
bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=12,
radius1=r0, radius2=r1, depth=length)
rot = axis.to_track_quat("Z", "Y").to_matrix().to_4x4()
bmesh.ops.transform(bm, matrix=rot, verts=bm.verts)
bmesh.ops.translate(bm, vec=(p0 + p1) * 0.5, verts=bm.verts)
elif kind == "box":
center, size, yaw = Vector(params[0]), Vector(params[1]), params[2]
bmesh.ops.create_cube(bm, size=1.0)
matrix = (Matrix.Translation(center)
@ Matrix.Rotation(yaw, 4, "Z")
@ Matrix.Diagonal(size.to_4d()))
bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts)
elif kind == "sphere":
center, radius, scale = Vector(params[0]), params[1], Vector(params[2])
bmesh.ops.create_uvsphere(bm, u_segments=14, v_segments=8, radius=radius)
matrix = Matrix.Translation(center) @ Matrix.Diagonal(scale.to_4d())
bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts)
else:
raise ValueError("unknown shape: " + kind)
return bm
def bone_parent(obj, armature, bone_name):
"""Жёстко цепляет объект к кости, сохраняя его положение в позе покоя.
Присваивание matrix_world после установки родителя заставляет Blender
пересчитать matrix_basis относительно родителя так деталь остаётся ровно
там, где её построили, но начинает ездить вместе с костью.
"""
world = obj.matrix_world.copy()
obj.parent = armature
obj.parent_type = "BONE"
obj.parent_bone = bone_name
bpy.context.view_layer.update()
obj.matrix_world = world
# ----------------------------------------------------------------------------
# Сборка сцены
# ----------------------------------------------------------------------------
def wipe_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
def build_armature(rig_coll):
data = bpy.data.armatures.new("agent_rig")
armature = bpy.data.objects.new("agent_rig", data)
rig_coll.objects.link(armature)
bpy.context.view_layer.objects.active = armature
bpy.ops.object.mode_set(mode="EDIT")
made = {}
for name, head, tail, parent in BONES:
bone = data.edit_bones.new(name)
bone.head = head
bone.tail = tail
bone.roll = 0.0
bone.use_connect = False
if parent is not None:
bone.parent = made[parent]
made[name] = bone
bpy.ops.object.mode_set(mode="OBJECT")
for bone in armature.pose.bones:
bone.rotation_mode = "QUATERNION"
return armature
def import_source_body(source_path, source_cfg, rig_coll, body_coll, pivot):
"""Import the authored CC0 humanoid and normalize it to the SpriteForge rig.
The source uses Z-up but its shoulder axis is Y. A single alignment empty
turns it into the project convention (X right, -Y forward), places its feet
at Z=0 and scales the 1.94 m mesh to a lean 1.75 m operative.
"""
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=os.path.abspath(source_path))
imported = [obj for obj in bpy.data.objects if obj not in before]
armatures = [obj for obj in imported if obj.type == "ARMATURE"]
meshes = [obj for obj in imported if obj.type == "MESH" and obj.parent in armatures]
if len(armatures) != 1 or len(meshes) != 1:
raise SystemExit("[agent_rig] source model must contain one skinned mesh and armature")
armature, body = armatures[0], meshes[0]
armature.name = "agent_rig"
body.name = "body_sleek_mesh"
# The loose icosphere in the public source is a modelling reference, not
# part of the skinned character.
for obj in imported:
if obj not in {armature, body} and obj.type == "MESH":
bpy.data.objects.remove(obj, do_unlink=True)
for owner in (armature, body):
for coll in list(owner.users_collection):
coll.objects.unlink(owner)
rig_coll.objects.link(armature)
body_coll.objects.link(body)
align = bpy.data.objects.new("agent_source_align", None)
rig_coll.objects.link(align)
rotation = float(source_cfg.get("source_rotation_deg", -90.0))
scale = float(source_cfg.get("source_scale", 0.90))
align.rotation_euler = (0.0, 0.0, math.radians(rotation))
align.scale = (scale, scale, scale)
align.location = (0.0, 0.0, float(source_cfg.get("source_ground_z", 0.897)))
align.parent = pivot
armature.parent = align
# Dark textile base. Authored armor modules cover it later, while this
# mesh supplies real anatomy and joint deformation.
body.data.materials.clear()
body.data.materials.append(material("bodysuit"))
# A physically thin limb can collapse to a single sub-pixel line in a
# 70x90 orthographic cell. Inflate the *deformed surface* by a small,
# configurable world-space amount instead of scaling the skeleton: joint
# positions and every imported action stay untouched while the silhouette
# survives palette quantisation. The modifier follows the Armature one.
silhouette = body.modifiers.new("SpriteForge silhouette thickness", "SOLIDIFY")
silhouette.thickness = float(source_cfg.get("source_silhouette_thickness", 0.018))
silhouette.offset = 0.0
silhouette.use_rim = True
for bone in armature.pose.bones:
bone.rotation_mode = "QUATERNION"
return armature
def install_source_actions():
mapping = {
"idle": "Pistol_Idle_Loop",
"walk": "Walk_Loop",
"attack": "Pistol_Shoot",
}
for target, source in mapping.items():
original = bpy.data.actions.get(source)
if original is None:
raise SystemExit("[agent_rig] source action missing: " + source)
existing = bpy.data.actions.get(target)
if existing is not None:
bpy.data.actions.remove(existing)
clone = original.copy()
clone.name = target
clone.use_fake_user = True
log("source action %s <- %s %.0f..%.0f" %
(target, source, clone.frame_range[0], clone.frame_range[1]))
def build_parts(parts, prefix, armature, coll):
for suffix, bone, mat, (kind, params) in parts:
obj = mesh_object("%s_%s" % (prefix, suffix), build_shape(kind, params),
material(mat), coll)
bone_parent(obj, armature, bone)
def build_attachments(armature, coll):
for bone, location in ATTACHMENTS.items():
empty = bpy.data.objects.new("attach_" + bone, None)
empty.empty_display_type = "PLAIN_AXES"
empty.empty_display_size = 0.06
empty.location = location
coll.objects.link(empty)
bone_parent(empty, armature, bone)
def build_camera(scene, camera_cfg, root_coll):
elevation = math.radians(float(camera_cfg["elevation_deg"]))
distance = float(camera_cfg["distance"])
data = bpy.data.cameras.new("agent_cam")
data.type = "ORTHO"
data.clip_start = 0.01
data.clip_end = distance * 4.0
data.shift_x = 0.0
data.shift_y = 0.0
camera = bpy.data.objects.new("agent_cam", data)
# Смотрит строго в начало координат: ось взгляда проходит через (0,0,0),
# поэтому в ортографии оно всегда попадает точно в центр кадра.
camera.location = (0.0, -distance * math.cos(elevation), distance * math.sin(elevation))
camera.rotation_euler = (math.pi / 2.0 - elevation, 0.0, 0.0)
root_coll.objects.link(camera)
scene.camera = camera
return camera
def build_lights(root_coll):
# Свет фиксирован в МИРЕ, а не привязан к фигуре: поворачивается модель,
# значит каждое направление освещено по-своему — как и должно быть.
key = bpy.data.lights.new("key_sun", type="SUN")
key.energy = 3.2
key.color = (1.0, 0.96, 0.90)
key.angle = math.radians(6.0)
key_obj = bpy.data.objects.new("key_sun", key)
key_obj.rotation_euler = (math.radians(52.0), 0.0, math.radians(-38.0))
root_coll.objects.link(key_obj)
fill = bpy.data.lights.new("fill_sun", type="SUN")
fill.energy = 0.55
fill.color = (0.62, 0.72, 1.0)
fill_obj = bpy.data.objects.new("fill_sun", fill)
fill_obj.rotation_euler = (math.radians(108.0), 0.0, math.radians(150.0))
root_coll.objects.link(fill_obj)
def build_world(scene):
world = bpy.data.worlds.new("agent_world")
world.use_nodes = True
background = world.node_tree.nodes.get("Background")
if background is not None:
_set_socket(background, "Color", (0.035, 0.042, 0.055, 1.0))
_set_socket(background, "Strength", 1.0)
scene.world = world
def configure_render(scene):
for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "CYCLES"):
try:
scene.render.engine = engine
break
except (TypeError, ValueError):
continue
log("engine: " + scene.render.engine)
scene.render.film_transparent = True
scene.render.use_motion_blur = False
scene.render.resolution_percentage = 100
scene.render.pixel_aspect_x = 1.0
scene.render.pixel_aspect_y = 1.0
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "8"
scene.render.image_settings.compression = 0
# Standard, а не AgX: спрайт должен получить ровно тот цвет, что в материале.
try:
scene.view_settings.view_transform = "Standard"
scene.view_settings.look = "None"
except (TypeError, ValueError):
pass
eevee = getattr(scene, "eevee", None)
if eevee is not None and hasattr(eevee, "taa_render_samples"):
eevee.taa_render_samples = 48
if scene.render.engine == "CYCLES":
scene.cycles.samples = 64
scene.cycles.use_denoising = True
# ----------------------------------------------------------------------------
# Анимации
# ----------------------------------------------------------------------------
def world_axis_quat(armature, bone_name, axis, angle):
"""Кватернион позы, вращающий кость вокруг МИРОВОЙ оси на angle радиан."""
rest = armature.data.bones[bone_name].matrix_local.to_3x3()
# Imported rigs may be normalized by a rotated/scaled parent. Convert the
# requested scene axis through the full armature transform first; otherwise
# a forward elbow bend turns into a sideways T-pose rotation.
armature_axis = armature.matrix_world.to_3x3().inverted() @ Vector(axis)
local_axis = rest.inverted() @ armature_axis
if local_axis.length < 1e-9:
return Quaternion((1.0, 0.0, 0.0, 0.0))
return Quaternion(local_axis.normalized(), angle)
def pose(armature, frame, swings, offset=None):
"""swings: {bone: (axis, angle)}; offset — сдвиг корня в мировых координатах."""
for bone in armature.pose.bones:
bone.rotation_quaternion = Quaternion((1.0, 0.0, 0.0, 0.0))
armature.pose.bones["root"].location = Vector((0.0, 0.0, 0.0))
for name, (axis, angle) in swings.items():
armature.pose.bones[name].rotation_quaternion = world_axis_quat(armature, name, axis, angle)
if offset is not None:
root = armature.pose.bones["root"]
rest = armature.data.bones["root"].matrix_local.to_3x3()
root.location = rest.inverted() @ Vector(offset)
for bone in armature.pose.bones:
bone.keyframe_insert(data_path="rotation_quaternion", frame=frame)
armature.pose.bones["root"].keyframe_insert(data_path="location", frame=frame)
X = (1.0, 0.0, 0.0) # ось «шаг вперёд/назад» для фигуры, смотрящей в -Y
Y = (0.0, 1.0, 0.0) # ось «наклон вбок»
Z = (0.0, 0.0, 1.0) # ось «поворот корпуса»
# Базовая стойка: low ready. Руки не по швам — иначе оружие в кисти висит в воздухе.
READY = {
"upper_arm_r": (X, math.radians(-22.0)),
"forearm_r": (X, math.radians(-58.0)),
"upper_arm_l": (X, math.radians(-18.0)),
"forearm_l": (X, math.radians(-62.0)),
"shoulder_r": (Y, math.radians(10.0)),
"shoulder_l": (Y, math.radians(-10.0)),
"thigh_r": (X, math.radians(8.0)),
"thigh_l": (X, math.radians(8.0)),
"shin_r": (X, math.radians(-14.0)),
"shin_l": (X, math.radians(-14.0)),
"spine": (X, math.radians(-9.0)),
"chest": (Z, math.radians(-5.0)),
}
def merged(base, extra):
out = dict(base)
out.update(extra)
return out
def make_action(armature, name):
if armature.animation_data is None:
armature.animation_data_create()
action = bpy.data.actions.new(name)
# Blender 5 may not count a layered action as used until a slot/channel is
# committed. Keep authored clips across save even when their first pose is
# identical to rest and therefore creates no curve.
action.use_fake_user = True
armature.animation_data.action = action
return action
def build_actions(armature, animations):
for name, spec in animations.items():
frames = int(spec["frames"])
make_action(armature, name)
if name == "idle":
for frame in range(frames):
pose(armature, frame + 1, READY)
elif name == "walk":
for frame in range(frames):
phase = 2.0 * math.pi * frame / frames
swing = math.radians(26.0)
pose(armature, frame + 1, merged(READY, {
"thigh_r": (X, swing * math.sin(phase)),
"thigh_l": (X, swing * math.sin(phase + math.pi)),
"shin_r": (X, -math.radians(30.0) * max(0.0, -math.sin(phase - 0.6))),
"shin_l": (X, -math.radians(30.0) * max(0.0, -math.sin(phase + math.pi - 0.6))),
"upper_arm_r": READY["upper_arm_r"],
"upper_arm_l": READY["upper_arm_l"],
"spine": (X, math.radians(-11.0)),
}), offset=(0.0, 0.0, -0.022 * abs(math.sin(phase))))
elif name == "attack":
# Замах на 0..1, удар на 2..3, возврат на 4..5.
recoil = [0.0, -0.06, -0.14, -0.08, -0.03, 0.0]
twist = [0.0, -0.025, -0.055, -0.030, -0.010, 0.0]
for frame in range(frames):
k = recoil[frame % len(recoil)]
t = twist[frame % len(twist)]
pose(armature, frame + 1, merged(READY, {
"upper_arm_r": (X, math.radians(-22.0) + k),
"forearm_r": (X, math.radians(-58.0) - 0.25 * k),
"upper_arm_l": (X, math.radians(-18.0) + k * 0.65),
"forearm_l": (X, math.radians(-62.0) - 0.20 * k),
"chest": (Z, t),
"hips": (Z, t * 0.25),
}))
else:
for frame in range(frames):
pose(armature, frame + 1, READY)
log("action %s: %d frame(s)" % (name, frames))
SOURCE_READY = {
# The source is an A-pose. Clavicles lower the arms around world Y;
# upper arms and elbows then close around the weapon platform in world Z.
"upper_arm.R": (Z, math.radians(-43.0)),
"forearm.R": (Z, math.radians(-67.0)),
"upper_arm.L": (Z, math.radians(43.0)),
"forearm.L": (Z, math.radians(67.0)),
"shoulder.R": (Y, math.radians(36.0)),
"shoulder.L": (Y, math.radians(-36.0)),
"thigh.R": (X, math.radians(7.0)),
"thigh.L": (X, math.radians(7.0)),
"shin.R": (X, math.radians(-12.0)),
"shin.L": (X, math.radians(-12.0)),
"spine.002": (X, math.radians(-8.0)),
"spine.003": (Z, math.radians(-4.0)),
}
def pose_source(armature, frame, swings, offset=None):
for bone in armature.pose.bones:
bone.rotation_quaternion = Quaternion((1.0, 0.0, 0.0, 0.0))
for name, (axis, angle) in swings.items():
if name in armature.pose.bones:
armature.pose.bones[name].rotation_quaternion = world_axis_quat(
armature, name, axis, angle)
armature.location = Vector(offset or (0.0, 0.0, 0.0))
for bone in armature.pose.bones:
bone.keyframe_insert(data_path="rotation_quaternion", frame=frame)
armature.keyframe_insert(data_path="location", frame=frame)
def build_source_actions(armature, animations):
"""Compact LoD-like cycles on the imported deforming humanoid."""
for name, spec in animations.items():
frames = int(spec["frames"])
make_action(armature, name)
for frame in range(frames):
phase = 2.0 * math.pi * frame / max(1, frames)
swings = dict(SOURCE_READY)
offset = (0.0, 0.0, 0.0)
if name == "idle":
swings["spine.002"] = (X, math.radians(-8.0) + math.radians(1.2) * math.sin(phase))
elif name == "walk":
stride = math.radians(27.0)
swings.update({
"thigh.R": (X, stride * math.sin(phase)),
"thigh.L": (X, stride * math.sin(phase + math.pi)),
"shin.R": (X, -math.radians(34.0) * max(0.0, -math.sin(phase - 0.55))),
"shin.L": (X, -math.radians(34.0) * max(0.0, -math.sin(phase + math.pi - 0.55))),
"spine.003": (Z, math.radians(3.0) * math.sin(phase)),
})
offset = (0.0, 0.0, -0.025 * abs(math.sin(phase)))
elif name == "attack":
recoil = (0.0, -0.04, -0.13, -0.075, -0.025, 0.0)[frame % 6]
swings.update({
"upper_arm.R": (Z, math.radians(-43.0) + recoil),
"forearm.R": (Z, math.radians(-67.0) - recoil * 0.30),
"upper_arm.L": (Z, math.radians(43.0) - recoil * 0.65),
"forearm.L": (Z, math.radians(67.0) + recoil * 0.22),
"spine.003": (Z, recoil * 0.28),
})
pose_source(armature, frame + 1, swings, offset)
log("source action %s: %d frame(s)" % (name, frames))
# ----------------------------------------------------------------------------
# build
# ----------------------------------------------------------------------------
def cmd_build(cfg, out_path):
wipe_scene()
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 1.0
root = collection("AGENT", scene.collection)
rig_coll = collection("RIG", root)
body_coll = collection("BODY", root)
armor_coll = collection("ARMOR", root)
weapon_coll = collection("WEAPON", root)
attach_coll = collection("ATTACH", root)
# Пустышка над ригом: направление задаётся её поворотом, камера не двигается.
pivot = bpy.data.objects.new("agent_root", None)
pivot.empty_display_type = "ARROWS"
pivot.empty_display_size = 0.3
rig_coll.objects.link(pivot)
source_model = cfg.get("camera", {}).get("source_model")
if source_model:
armature = import_source_body(source_model, cfg.get("camera", {}),
rig_coll, body_coll, pivot)
build_parts(SOURCE_BODY_PARTS, "body_sleek", armature, body_coll)
for variant, parts in SOURCE_ARMOR_PARTS.items():
build_parts(parts, "armor_" + variant, armature, armor_coll)
for variant, parts in SOURCE_WEAPON_PARTS.items():
build_parts(parts, "weapon_" + variant, armature, weapon_coll)
else:
armature = build_armature(rig_coll)
armature.parent = pivot
build_parts(BODY_PARTS, "body_sleek", armature, body_coll)
# Armor authored for the legacy skeleton is intentionally withheld from
# the imported body until its fitted replacement is built. Never mix
# coordinate systems and silently ship floating equipment.
if not source_model:
for variant, parts in ARMOR_PARTS.items():
build_parts(parts, "armor_" + variant, armature, armor_coll)
for variant, parts in WEAPON_PARTS.items():
if variant in {"rifle", "shotgun"}:
parts = [(suffix, "chest", mat, shape)
for suffix, _bone, mat, shape in parts]
build_parts(parts, "weapon_" + variant, armature, weapon_coll)
build_attachments(armature, attach_coll)
build_camera(scene, cfg["camera"], scene.collection)
build_lights(scene.collection)
build_world(scene)
configure_render(scene)
if source_model:
install_source_actions()
else:
build_actions(armature, cfg["animations"])
scene.frame_start = 1
scene.frame_end = max(int(a["frames"]) for a in cfg["animations"].values())
os.makedirs(os.path.dirname(out_path), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=out_path)
meshes = [o.name for o in bpy.data.objects if o.type == "MESH"]
log("saved %s (%d mesh objects, %d bones)" % (out_path, len(meshes), len(BONES)))
return 0
# ----------------------------------------------------------------------------
# render
# ----------------------------------------------------------------------------
def assign_action(armature, name):
action = bpy.data.actions.get(name)
if action is None:
raise SystemExit("[agent_rig] no action '%s' in the .blend" % name)
if armature.animation_data is None:
armature.animation_data_create()
anim = armature.animation_data
anim.action = action
# Blender 4.4+: у Action появились слоты, и без выбранного слота она молчит.
slots = getattr(action, "slots", None)
if slots is not None and hasattr(anim, "action_slot") and anim.action_slot is None and len(slots):
anim.action_slot = slots[0]
return action
def cmd_render(cfg, component, animation, outdir, supersample):
scene = bpy.context.scene
armature = bpy.data.objects.get("agent_rig")
pivot = bpy.data.objects.get("agent_root")
if armature is None or pivot is None:
raise SystemExit("[agent_rig] the .blend has no agent_rig/agent_root; run `blender-init` first")
prefix = component + "_"
shown = 0
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
obj.hide_render = not obj.name.startswith(prefix)
shown += 0 if obj.hide_render else 1
if shown == 0:
raise SystemExit("[agent_rig] no objects named '%s*' in the .blend" % prefix)
configure_render(scene)
scene.render.resolution_x = int(cfg["render_width"]) * supersample
scene.render.resolution_y = int(cfg["render_height"]) * supersample
camera = scene.camera or build_camera(scene, cfg["camera"], scene.collection)
camera.data.type = "ORTHO"
camera.data.shift_x = 0.0
camera.data.shift_y = 0.0
# ortho_scale ложится на БОЛЬШУЮ сторону кадра.
longest = max(int(cfg["render_width"]), int(cfg["render_height"]))
camera.data.ortho_scale = longest / float(cfg["camera"]["pixels_per_unit"])
action = assign_action(armature, animation)
frames = int(cfg["frames"])
directions = cfg["directions"]
os.makedirs(outdir, exist_ok=True)
for index, name in enumerate(directions):
# Порядок направлений в каталоге идёт по -45° от south: юг, юго-запад, …
pivot.rotation_euler = (0.0, 0.0, math.radians(-45.0 * index))
bpy.context.view_layer.update()
start, end = action.frame_range
for frame in range(frames):
# Source mocap clips are 15-60 frames long; LoD-style sprites keep
# only 4-8 decisive poses. Sample the full clip and omit the
# duplicated loop endpoint.
source_frame = start + (end - start) * frame / max(1, frames)
scene.frame_set(int(round(source_frame)))
cell = index * frames + frame
scene.render.filepath = os.path.join(outdir, "frame_%03d.png" % cell)
bpy.ops.render.render(write_still=True)
log("direction %d/%d (%s) done" % (index + 1, len(directions), name))
log("rendered %d cell(s) at %dx%d into %s"
% (len(directions) * frames, scene.render.resolution_x, scene.render.resolution_y, outdir))
return 0
def main(argv):
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
if not argv:
raise SystemExit("[agent_rig] usage: build|render --config <json>")
command = argv[0]
options = {}
index = 1
while index < len(argv) - 1:
if argv[index].startswith("--"):
options[argv[index][2:]] = argv[index + 1]
index += 2
else:
index += 1
with open(options["config"], "r", encoding="utf-8") as handle:
cfg = json.load(handle)
if command == "build":
return cmd_build(cfg, options["out"])
if command == "render":
return cmd_render(cfg, cfg["component"], cfg["animation"], cfg["outdir"],
int(cfg.get("supersample", 1)))
raise SystemExit("[agent_rig] unknown command: " + command)
if __name__ == "__main__":
sys.exit(main(list(sys.argv)))