"""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 ") 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)))