3D-ассеты через агента Ассет как код: воспроизводимая сборка и проверки в конвейере
0%

Ассет как код: воспроизводимая сборка и проверки в конвейере

Ассет как код: воспроизводимая сборка и проверки в конвейере

Генератор из предыдущей главы существует ровно до тех пор, пока лежит у кого-то на диске. Чтобы он стал частью проекта, надо ответить на три вопроса: что класть в репозиторий, как пересобирать и что должно упасть, если ассет незаметно испортился.

Что здесь исходник

Вопрос звучит наивно ровно до первого спора в команде.

Что у вас есть Исходник Артефакты Где хранить
Модель собрана скриптом build.py и параметры .blend, .glb, текстуры код — в Git, артефакты — в сборке
Модель слеплена руками .blend .glb, текстуры .blend — в Git LFS
Скрипт плюс ручная доводка расщеплён всё остальное самый дорогой случай, см. ниже

Первые две строки простые. Интересна третья, потому что в неё попадают почти все, кто начал с процедурного пути и однажды «просто подвинул руками вот эту вершину».

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

Лечится одним из двух решений, и оба надо принять сознательно:

  • Ручная доводка запрещена. Всё, что нужно поправить, правится в генераторе. Дороже в моменте, дешевле в сумме — и ассет остаётся пересобираемым.
  • Ручная доводка — отдельный шаг конвейера. Генератор выдаёт заготовку, доводка живёт в собственном .blend, который заготовку импортирует, а не содержит. Тогда пересборка заготовки не стирает работу.

Что нельзя — так это оставить вопрос без ответа. Расщеплённый исходник не доставляет неудобств ровно до дня, когда доставляет их все сразу.

Сборка

BLENDER ?= /opt/blender-4.5.11-linux-x64/blender
OUT     ?= dist

assets:
	$(BLENDER) -b --factory-startup --python build_crates.py -- --out $(OUT) --count 100
	python3 tools/check_assets.py $(OUT)

.PHONY: assets

Три детали, унаследованные из главы «Как устроена связка» и уже там объяснённые, но здесь особенно важные.

--factory-startup. Без него Blender подхватит настройки и аддоны того пользователя, от чьего имени идёт сборка. Локально соберётся, на машине сборщика — нет, и симптом будет выглядеть как «у меня всё работает». Заводской старт делает сборку одинаковой у всех.

Версия закреплена явно. Не blender из PATH, а конкретный путь к конкретной версии. Причина — в главе «Первая модель»: свойство use_auto_smooth исчезло в 4.1, и код, написанный по туториалу для 4.0, падает на строке, которая «всегда работала». Обновление Blender — это отдельное решение с прогоном сборки, а не то, что случается само при обновлении системы.

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

Полезная привычка — печатать в лог сборки то, чем собирали:

print(f"BUILD: blender={bpy.app.version_string} python={sys.version.split()[0]} "
      f"generator={GENERATOR_VERSION} seed_source=name")

Это тот же приём, что строка STARTUP: polyhaven=… telemetry=… running=… из главы «Установка». Через полгода при разборе «почему ассеты поехали» лог с версиями отвечает на вопрос за секунду, а его отсутствие превращает разбор в археологию.

Побайтовая воспроизводимость недостижима — и не нужна

Естественное желание: пересобрать дважды и сравнить файлы. Не сойдётся, и это не ваша ошибка.

В glTF записывается поле asset.generator с версией экспортёра. Порядок узлов и имена могут зависеть от порядка обхода коллекций. Сжатие картинок не обязано быть детерминированным между версиями библиотек. Любое обновление Blender меняет байты, не меняя модель.

Значит, сравнивать надо не байты, а извлечённые свойства — те же, что мы уже умеем доставать разбором GLB из главы «Экспорт в GLB»:

report = {
    "meshes":     sorted(m["name"] for m in gltf.get("meshes", [])),
    "materials":  sorted(m["name"] for m in gltf.get("materials", [])),
    "animations": sorted(a.get("name", "") for a in gltf.get("animations", [])),
    "joints":     sum(len(s["joints"]) for s in gltf.get("skins", [])),
    "triangles":  tris,
    "images":     len(gltf.get("images", [])),
    "bytes":      path.stat().st_size,
}

Такой отчёт кладётся рядом с ассетами и коммитится. Дальше сборка сравнивает свежий отчёт с записанным, и разница видна построчно:

$ python3 tools/check_assets.py dist/
crate_042.glb: triangles 8596 → 12894 (+50%)
crate_042.glb: materials ['Crate_Wood'] → ['Crate_Wood', 'Material.001']
2 расхождения с dist/report.json — сборка отклонена

Обратите внимание на вторую строку. Материал Material.001 — след того, что кто-то создал материал, не назвав его. В файле это выглядит безобидно; в рантайме, который ищет слот по имени, это отвалившаяся перекраска. Отчёт ловит такое до игры.

Смысл приёма — не в точности, а в направлении внимания: изменение ассета перестаёт быть невидимым. Оно теперь либо ожидаемое (тогда отчёт обновляется вместе с кодом, одной правкой, в одном коммите), либо неожиданное — и тогда сборка задаёт вопрос до того, как его задаст игрок.

Что проверять

Проверки складываются из того, что мы разбирали по главам, и каждая стоит секунды:

Проверка Откуда Что ловит
Бюджет треугольников глава 08 поднятое подразделение, забытый модификатор
Плотность текселя и сумма площадей UV глава 09 мыло на одном ассете рядом с бритвой на другом
Имена мешей, материалов, слотов глава 06 Material.001, сломанную перекраску
Наличие клипов и костей глава 10 анимацию, не доехавшую до файла
Все картинки встроены (uri отсутствует) глава 06 ассет, который у игрока грузится из ниоткуда
Вес файла глава 06 всё вышеперечисленное разом

Отдельно про предпоследнюю строку — она из чек-листа приёмки главы «Живой кейс» («сборки грузятся без обращения к сети»), и проверяется буквально одной строкой:

external = [i.get("uri") for i in gltf.get("images", []) if "uri" in i]
assert not external, f"текстуры не встроены: {external}"

На машине разработчика такой ассет работает — файлы лежат рядом. У игрока — нет. Разница между «работает» и «работает у меня» здесь ровно в одном ключе JSON.

Сборка ассетов на CI

Хорошая новость из главы про устройство связки: в конвейере не нужен ни виртуальный дисплей, ни программный OpenGL, ни MCP. Всё это требовалось агенту, чтобы видеть вьюпорт. Генератору не нужно ничего, кроме фонового Blender.

- name: Собрать ассеты
  run: |
        make assets OUT=dist
  env:
    LC_ALL: C.UTF-8          # иначе печать русских имён падает на некоторых образах

Три практических замечания.

Blender в образ надо доставить, и он большой. Распакованный тарбол — сотни мегабайт, качать его на каждый прогон дорого. Либо свой образ с уже установленным Blender, либо кеш по версии — и второе заодно фиксирует версию, что нам и нужно.

Собирать при каждом изменении не обязательно. Ассеты меняются реже кода; разумно запускать шаг только при правках в каталоге генератора и его отчёте.

Локаль. В минимальных образах локаль часто C, и печать кириллического имени материала валится с ошибкой кодировки — на середине сборки, из-за print. Ошибка выглядит серьёзной, а лечится переменной окружения.

И главное: код возврата — не доказательство

Мы уже проходили это в главе «Как агент смотрит на свою работу»: худший вид отказа — успешный ответ с неверными данными. В сборке ассетов он живёт в том же месте, где обычно ищут подтверждение, — в статусе процесса.

Blender исполняет ваш скрипт внутри себя. Что именно он сделает с необработанным исключением и какой код вернёт — зависит от версии и от того, как именно упало. Полагаться на это, не проверив на своей версии, нельзя:

$ printf 'raise RuntimeError("проба")\n' > /tmp/fail.py
$ blender -b --factory-startup --python /tmp/fail.py; echo "код возврата: $?"

Две команды, полминуты — и вы знаете, стережёт ли вас статус процесса. Если выяснилось, что не стережёт, лечится это в скрипте:

import sys, traceback

try:
    main(parse_args())
except Exception:
    traceback.print_exc()
    sys.exit(1)

Но правильный вывод шире: сборка обязана заканчиваться проверкой артефакта, а не проверкой статуса. Файл существует, отчёт сходится, бюджет выдержан — вот доказательство. Нулевой код возврата — только обещание.

Это третье появление одной и той же мысли в треке — сначала на чёрном скриншоте, потом на экспортёре, теперь на сборке. Так и надо к ней относиться: не как к частному случаю Blender, а как к свойству любых конвейеров. Чем длиннее цепочка автоматики, тем больше в ней мест, где success означает «дошёл до конца», а не «сделал полезное».

Что дальше

Ассеты собираются, проверяются и воспроизводятся. Остался вопрос, который не решается ни скриптом, ни сборкой: что из этого вы имеете право положить в продукт. В пайплайне участвуют чужие банки материалов, чужие сервисы генерации и чужие модели, и у каждого свои условия.

Дальше — «Лицензии, происхождение и приёмка чужого»: что проверять до того, как ассет уехал в билд, как записывать происхождение — и чем заканчивается этот трек.

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

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

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

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