950 lines
47 KiB
Python
950 lines
47 KiB
Python
|
|
"""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)))
|