3D-ассеты через агента Массовые вариации: генератор вместо ста просьб
0%

Массовые вариации: генератор вместо ста просьб

Массовые вариации: генератор вместо ста просьб

Во вводной главе трека массовые вариации — «сто ящиков с разными пропорциями и потёртостями» — записаны в то, что у агента получается хорошо. Показать это мы до сих пор не показали. Пора, потому что именно здесь становится видно, чем процедурный путь отличается от всех остальных не на словах.

Наивный способ и его цена

Первое, что приходит в голову: попросить агента сделать ящик. Потом ещё один. Потом ещё девяносто восемь раз.

Так делать не надо, и причины стоит перечислить, потому что каждая из них повторяется в любой работе с агентами, а не только в 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.

Что дальше

Генератор написан, сто ящиков собраны, манифест лежит рядом. Осталось решить, что из этого хранить в репозитории, как пересобирать через полгода и что должно падать в сборке, если кто-то нечаянно поднял подразделение на единицу.

Дальше — «Ассет как код»: исходники против артефактов, воспроизводимость сборки и проверки, которые ловят регрессию ассетов до того, как её увидит игрок.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше