Массовые вариации: генератор вместо ста просьб
Во вводной главе трека массовые вариации — «сто ящиков с разными пропорциями и потёртостями» — записаны в то, что у агента получается хорошо. Показать это мы до сих пор не показали. Пора, потому что именно здесь становится видно, чем процедурный путь отличается от всех остальных не на словах.
Наивный способ и его цена
Первое, что приходит в голову: попросить агента сделать ящик. Потом ещё один. Потом ещё девяносто восемь раз.
Так делать не надо, и причины стоит перечислить, потому что каждая из них повторяется в любой работе с агентами, а не только в 3D.
Каждая просьба — отдельный прогон. Агент заново читает сцену, заново пишет код, заново смотрит на результат. Сто ящиков — это сто циклов «сделал — посмотрел — поправил», и стоят они соответственно.
Результаты не связаны. Второй ящик написан другим кодом, чем первый. У третьего откуда-то взялась фаска, у седьмого — другая толщина доски. Набор выглядит не как семейство, а как свалка.
Ничего нельзя переделать разом. Заказчик просит сделать доски чуть у́же — и это сто правок вместо одной.
Ничего нельзя воспроизвести. Через месяц вы не соберёте тот же набор: исходников нет, есть сто готовых файлов.
Правильная постановка задачи звучит иначе: агент пишет не сто ассетов, а один генератор. Дальше генератор исполняется без всякого агента — столько раз, сколько нужно.
Параметризация: что варьировать
Генератор — это функция «параметры → объект». Первое настоящее решение здесь не техническое, а содержательное: какие параметры вообще выставлять наружу.
from dataclasses import dataclass
@dataclass(frozen=True)
class CrateParams:
width: float # 0.30 … 0.60
height: float # 0.25 … 0.55
plank: float # 0.02 … 0.04 толщина доски
planks: int # 3 … 6 досок на грань
wear: float # 0.0 … 1.0 потёртость фасок
tilt_deg: float # −4 … 4 завал крышки
def build_crate(p: CrateParams) -> bpy.types.Object:
...
Правило отбора: варьируйте то, что видно в силуэте. Сто ящиков, отличающихся только оттенком дерева, читаются как один ящик, поставленный сто раз, — глаз ловит форму раньше цвета. Пропорции, число досок, наклон крышки дают ощущение разнообразия; шероховатость 0,35 против 0,38 не даёт ничего.
Второе правило: диапазон — часть определения параметра. Ящик высотой 4 метра технически возможен, содержательно — мусор. Диапазоны стоит записать рядом с полями, как в примере выше, и проверять на входе: генератор, молча принимающий бессмыслицу, произведёт бессмыслицу сто раз подряд.
Детерминизм: зерно и ловушка с hash()
Вариации случайны, а результат обязан быть воспроизводим. Значит, случайность
должна быть управляемой: у генератора случайных чисел есть зерно (в коде —
seed), и от него однозначно зависит вся последовательность.
import random
def params_for(name: str) -> CrateParams:
rnd = random.Random(seed_of(name)) # свой генератор, а не глобальный
return CrateParams(
width=rnd.uniform(0.30, 0.60),
height=rnd.uniform(0.25, 0.55),
plank=rnd.uniform(0.02, 0.04),
planks=rnd.randint(3, 6),
wear=rnd.random(),
tilt_deg=rnd.uniform(-4, 4),
)
Два решения в трёх строках.
random.Random(seed), а не random.seed(). Глобальный генератор общий на
весь процесс: любой чужой код, дёрнувший random.random() между вашими
вызовами, сдвинет всю последовательность. Свой экземпляр никому не принадлежит и
ни от кого не зависит.
Зерно — из имени, а не из счётчика. Тогда crate_042 — это всегда один и тот
же ящик, даже если вы поменяли порядок генерации или выкинули из набора три
предыдущих. Со счётчиком удаление одного элемента сдвигает все последующие, и
набор «немного меняется» целиком.
А теперь ловушка, из-за которой этот раздел вообще существует:
def seed_of(name: str) -> int:
return hash(name) # ← так нельзя
hash() от строки в Python рандомизируется при каждом запуске процесса: в
интерпретаторе есть случайная соль, защита от подбора коллизий. Код выглядит
детерминированным, ведёт себя детерминированно внутри одного прогона — и даёт
другой набор завтра. Отладить это тяжело именно потому, что в пределах сессии всё
воспроизводится идеально.
Правильно — устойчивая функция:
import zlib
def seed_of(name: str) -> int:
return zlib.crc32(name.encode("utf-8"))
Приём общий: любая «случайность», влияющая на артефакт, обязана иметь записанный источник. Зерно, версия инструмента, порядок обхода. Всё, что не записано, однажды окажется другим — и обнаружится это в самый неподходящий момент.
Манифест: чем ассет объясняется
Сто файлов на диске — это сто вопросов «а откуда взялся вот этот». Ответ должен лежать рядом:
import hashlib, json
manifest = []
for name in names:
p = params_for(name)
path = build_and_export(name, p)
manifest.append({
"name": name,
"file": path.name,
"params": p.__dict__,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"generator": "crates/build.py@1.4.0",
"blender": bpy.app.version_string,
})
(out_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
Манифест отвечает на четыре вопроса, каждый из которых рано или поздно задают:
- из чего сделан этот ящик — параметры;
- тот ли это файл, что был в сборке — контрольная сумма;
- чем сделан — версия генератора и версия Blender;
- что изменилось между сборками — разница двух манифестов, читаемая глазами, вместо разницы бинарных файлов, не читаемой никем.
Последний пункт — тот, ради которого это стоит делать даже на десяти ассетах. Обычный отчёт о правке звучит как «пересобрал ассеты». Отчёт с манифестом — как «у трёх ящиков изменилась ширина, остальные девяносто семь побайтово те же».
Кто это исполняет: агент или Blender
Здесь важное разделение труда, к которому подводил весь трек.
Агент нужен, чтобы написать и отладить генератор. Это работа с обратной связью: сделал — посмотрел на вьюпорт — поправил. Ровно тот цикл, ради которого существует связка из главы «Как устроена связка», и ровно та, где нужен живой Blender с интерфейсом под виртуальным дисплеем.
Агент не нужен, чтобы генератор прогнать. Готовый скрипт исполняется обычным фоновым Blender:
blender -b --factory-startup --python build_crates.py -- --out dist/ --count 100
Тот самый blender -b, который в главе про устройство связки не работал. Он не
работал для аддона MCP — из-за очереди команд и таймеров, которым нужен цикл
событий. Обычному скрипту цикл событий не нужен: он выполняется от начала до
конца и выходит. Фоновый режим для этого и сделан.
Выгода не только в скорости. Фоновый прогон не требует ни виртуального дисплея, ни программного OpenGL, ни MCP, ни сети — значит, его можно поставить в сборку (глава 12), где ничего этого нет.
Мелкая, но обязательная деталь: аргументы после -- Blender не разбирает и
передаёт скрипту, а достать их надо руками:
import sys
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
Без этого argparse увидит аргументы самого Blender и честно откажется работать.
Сто ящиков, а не один ящик сто раз
Разнообразие набора стоит проверять так же, как всё остальное в этом треке, — числом, а не взглядом. Дешёвая проверка: посчитать, насколько различаются силуэты.
sizes = {n: (round(p.width, 2), round(p.height, 2), p.planks)
for n, p in ((n, params_for(n)) for n in names)}
assert len(set(sizes.values())) > len(names) * 0.8, "слишком много одинаковых"
Порог 0,8 — договорённость, а не истина; смысл в том, что генератор, выдавший
сто почти одинаковых наборов параметров, ловится автоматически, а не на
приёмке глазами. Такое бывает чаще, чем кажется: достаточно ошибиться в
диапазоне и получить uniform(0.30, 0.31).
Вторая полезная проверка — на выбросы: ни один вариант не должен вылезать за бюджет треугольников из главы «Полигональный бюджет». Ящик с шестью досками и включённым подразделением может оказаться вчетверо тяжелее среднего, и узнать об этом лучше в сборке, чем в игре.
Когда генератор окупается
Честная таблица. «Час» здесь — порядок величины из нашей работы над связкой, а не норматив.
| Подход | Первый ассет | Каждый следующий | Правка формы у всех | Воспроизводимость |
|---|---|---|---|---|
| Просить агента каждый раз | ~20 минут | ~20 минут | ×N | никакой |
| Написать генератор | 2–4 часа | секунды | одна правка | полная, если есть зерно и манифест |
| Купить набор на стоке | минуты | ноль | невозможна | полная, но чужая |
Из таблицы виден порог: генератор окупается примерно с десятка вариантов — или раньше, если форму придётся править. Правка почти всегда придётся: «а сделайте доски у́же» — это нормальная просьба, а не форс-мажор.
И обратное следствие, которое стоит проговорить: на трёх ассетах генератор не нужен. Написать параметризацию, диапазоны, зерно и манифест ради трёх ящиков — это инженерия ради инженерии. Тот же трезвый счёт, что и в главе про кейс, где LOD появился не из принципа, а из умножения 50 × 32 000.
Что дальше
Генератор написан, сто ящиков собраны, манифест лежит рядом. Осталось решить, что из этого хранить в репозитории, как пересобирать через полгода и что должно падать в сборке, если кто-то нечаянно поднял подразделение на единицу.
Дальше — «Ассет как код»: исходники против артефактов, воспроизводимость сборки и проверки, которые ловят регрессию ассетов до того, как её увидит игрок.