Ассет как код: воспроизводимая сборка и проверки в конвейере
Генератор из предыдущей главы существует ровно до тех пор, пока лежит у кого-то на диске. Чтобы он стал частью проекта, надо ответить на три вопроса: что класть в репозиторий, как пересобирать и что должно упасть, если ассет незаметно испортился.
Что здесь исходник
Вопрос звучит наивно ровно до первого спора в команде.
| Что у вас есть | Исходник | Артефакты | Где хранить |
|---|---|---|---|
| Модель собрана скриптом | 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означает «дошёл до конца», а не «сделал полезное».
Что дальше
Ассеты собираются, проверяются и воспроизводятся. Остался вопрос, который не решается ни скриптом, ни сборкой: что из этого вы имеете право положить в продукт. В пайплайне участвуют чужие банки материалов, чужие сервисы генерации и чужие модели, и у каждого свои условия.
Дальше — «Лицензии, происхождение и приёмка чужого»: что проверять до того, как ассет уехал в билд, как записывать происхождение — и чем заканчивается этот трек.