Как оптимизировать сгенерированную 3D-сцену для игры

Сгенерированные локации ломаются иначе, чем сделанные руками. Сначала замеры, потом материалы, инстансинг, LOD, текстуры и отсечение — именно в этом порядке.

Стеклянные башни вокруг высокого небоскрёба с широкими проспектами, построенные агентом в Cuberta

Район, который агент построил за одиннадцать минут, нередко выдаёт 20 fps на машине, где вышедшая опенворлд-игра идёт в 90. Первый порыв — удалять треугольники. Почти всегда это неверный первый шаг: сгенерированные сцены редко упираются в треугольники. Они упираются в количество раз, когда рендереру приходится остановиться и переключить состояние.

Сгенерированные сцены ломаются по-своему

Чем бы локация ни была сделана — генератором городов на основе библиотеки, процедурным инструментом или агентом, который управляет настоящим редактором вроде Cuberta и экспортирует GLB или FBX с текстурами, — импорт обычно выглядит одинаково:

  • Сотни уникальных материалов там, где художник уровня обошёлся бы дюжиной.
  • Каждый объект — свой меш. Двести сорок одинаковых фонарей приходят как 240 ассетов, а не как 240 инстансов одного.
  • Ни одной цепочки LOD. Один и тот же меш рисуется и с 5 м, и с 300 м.
  • Текстурная память потрачена равномерно. Альбедо 2048 на коньке крыши, к которому вы никогда не подходите ближе 40 м: разрешение назначалось на объект, а не на экранный размер.
  • Пересекающаяся и компланарная геометрия. Разметка ровно в плоскости дороги, бордюры, утопленные в тротуар, стены, делящие грани с соседями.
  • Никакой структуры для окклюзии. Ничего не помечено статикой, ничего не назначено окклюдером, у интерьеров нет объёмов комнат.

Это не баги. Так выглядит результат, когда софт оптимизирует правильность планировки, а не то, как это будет отправляться на GPU. У каждого пункта есть конкретное лечение, и порядок важен: первое исправление меняет тот замер, которым вы бы обосновали второе.

Сначала замер, потом правки

Работайте в миллисекундах, а не в кадрах в секунду: 60 fps — это 16,7 мс, 120 fps — 8,3 мс, и миллисекунды вычитаются, а частота кадров нет. Дальше смотрите на счётчики.

СчётчикЧто меряетСимптом сгенерированной сцены
Batches / draw callsСколько отдельных вызовов собирает CPU за кадрПочти равны числу рендереров: батчинга нет вовсе
SetPass callsКак часто перепривязываются состояние, текстуры и шейдерОтличаются от числа батчей на проценты и повторяют число материалов
ТреугольникиПропускную способность по геометрииНа десктопе обычно норма, на мобилках обычно и есть проблема
Текстурная памятьРезидентную VRAM под картыГигабайты: фризы и подгрузки вместо низкого среднего фреймтайма
Время потоков и GPUКто кого ждётГлавный поток загружен, GPU простаивает

Кто кого ждёт

Самая быстрая диагностика стоит одной настройки: уменьшите разрешение рендера вдвое. Если фреймтайм резко упал — вы упираетесь в GPU, потому что только что убрали пиксели. Если почти не изменился — упираетесь в CPU, а для сгенерированного контента это почти всегда отправка draw call'ов. Профайлеры говорят то же точнее: в Unity главный поток в Gfx.WaitForPresentOnGfxThread ждёт GPU, а рендер-поток в Gfx.WaitForCommands ждёт главный поток; в Unreal stat unit показывает Game, Draw и GPU рядом, и самое большое из трёх — ваш бюджет.

Почему важен порядок

Сведите материалы — и число батчей может упасть на порядок. Район, который выглядел упирающимся в CPU, теперь упирается в GPU на построении карт теней, а работа по LOD, за которую вы собирались взяться, либо внезапно стала правильной, либо внезапно не нужна. Сделали одно — замерили — выбрали следующее по новым числам.

Материалы, атласы и инстансинг

Почему батчинг держится на общих материалах

Draw call дорог не тем, что он рисует, а тем, что должно смениться перед ним: другие текстуры, другие константные буферы, иногда другой шейдер в состоянии конвейера. Два объекта с одним материалом сливаются в одну отправку; два объекта с разными материалами — нет, какими бы одинаковыми ни были меши. Поэтому во всех движках в предусловиях любого пути батчинга где-то стоит «один и тот же материал».

Есть нюанс, который стоит знать. SRP Batcher в Unity группирует вызовы по варианту шейдера, а не по материалу: свойства каждого материала заранее заливаются в константные буферы, поэтому сотня материалов на одном lit-шейдере всё равно батчится. Это избавляет от процессорной цены пересборки данных материала, но не от привязки текстур и не от текстурной памяти — в однозначных числах теперь надо держать количество вариантов шейдера. Unreal вместо батчера опирается на instanced static meshes и кластеры Nanite. Движковая специфика — тема отдельных статей, см. разбор по Unity.

Как свести материалы городка

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

Пропсы — в атлас, архитектуру — в тайл. После примерно четырёх метров поверхности уникальные тексели перестают быть по карману: фасаду 12 м при 512 пикселях на метр нужна карта шириной 6144 пикселя, и платить за это никто не будет.

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

Инстансинг и что под него подходит

Инстансинг рисует много копий одного меша одной отправкой, с трансформом на каждый экземпляр. Условия жёсткие: тот же меш, тот же материал, шейдер, собранный под это. Другой оттенок подходит, только если вариация живёт в per-instance данных, а не во втором материале. Потолок одного батча в Unity — 1023 экземпляра, или 511, когда шейдеру нужна обратная матрица на экземпляр: прагма равномерного масштаба её убирает, а indirect-отрисовка снимает ограничение совсем.

Сгенерированные районы — хорошие кандидаты, ведь генератор и так переиспользовал один исходный объект на все столбики, скамейки и деревья. Убивает всё экспорт, который пишет каждую расстановку отдельным мешем с трансформом, запечённым в вершины. Пришли ли ваши 240 фонарей как 240 ассетов или как 240 инстансов — часто самый выгодный вопрос, который можно задать про экспорт.

LOD, импосторы и политика для района

Писать 400 цепочек вручную вы не хотите. Отранжируйте меши по «треугольники × число экземпляров × типичное экранное покрытие» и пусть ранжирование определяет усилия: первой двадцатке — проверенные цепочки, остальным — автоматическая двухступенчатая редукция или ничего. Рабочая стартовая политика: LOD0 в полной детализации, LOD1 примерно на половине треугольников, LOD2 примерно на пятой части, дальше билборд или ничего. Переходы задавайте в экранном размере, а не в метрах — так работают и LOD Group в Unity, и screen size в Unreal, — потому что дистанция, подобранная для угла обзора 60 градусов при 1080p, ломается при смене любого из двух.

Дальнее кольцо — там, где импосторы окупаются: один запечённый билборд вместо целого квартала. World Partition в Unreal строит это слоями HLOD, где самые дальние слои сведены к объединённым прокси и импосторным квадам. Nanite в UE5 снимает вопрос о LOD на уровне меша для непрозрачной жёсткой геометрии, но это не универсальное прикрытие: masked-материалы стоят почти как их полная непрозрачная площадь, очень мелкие меши уходят под пиксельный порог, а тысячи крошечных Nanite-мешей несут реальные накладные расходы на экземпляр. Число материалов за вас это не сократит. Подробнее — в статье про Unreal.

Бюджет текстур — это арифметика, а не вкус

Текстуре никогда не нужно больше текселей, чем пикселей она занимает на экране в самой близкой точке, до которой доходит игрок. Всё сверх этого — память, за которую вы заплатили, а мипмаппинг выбросил.

В BC7, восемь бит на тексель, карта 2048 на 2048 — это 4 МБ, около 5,3 МБ после того, как мипы добавят свои 33 %; BC1 делит пополам. Допустим, импорт отдал вам 180 уникальных материалов, у каждого альбедо, нормаль и упакованная карта roughness-metal-occlusion в 2048: это 540 текстур, примерно 2,8 ГБ. Сведите к четырнадцати наборам — 42 текстуры, около 220 МБ, причём половина из них крыши и верхние этажи, которые незаметно опускаются до 1024.

Задайте одну плотность текселей на район и выводите разрешение из площади поверхности. Около 512 пикселей на метр — распространённый стандарт для окружения, 1024 оставляют для поверхностей, к которым камера подходит вплотную. Затем сверьтесь с экраном: объект в 40 м, занимающий 60 пикселей по высоте, выбирает где-то пятый мип карты 2048, то есть четыре верхних уровня существуют только чтобы их пропустили. Стриминг смягчает вину; размер билда, время запекания и фриз при первом визите остаются настоящими.

Отсечение и почему сгенерированным интерьерам нужны ячейки

Фрустум и дистанция

Отсечение по пирамиде видимости работает само и стоит дёшево — и не делает ровно ничего, когда вы стоите в конце прямого проспекта и весь район перед вами. Именно этот ракурс и используют для скриншотов сгенерированного города. Отсечение по дистанции — самый дешёвый реальный выигрыш: в Unreal есть Cull Distance Volumes, где размер объекта сопоставлен с дистанцией отсечения, в Godot — visibility range у каждого geometry instance, в Unity — дистанции отсечения по слоям у камеры. Урны, вывески и столбики могут исчезать на 40–80 м незаметно, а это тысячи отправок.

Окклюзия

Окклюзия отвечает на более сложный вопрос — что за чем спрятано. Unreal по умолчанию использует GPU-окклюзию: аппаратные occlusion queries плюс путь через иерархический Z-буфер, который сэмплит мип-цепочку глубины сцены и намеренно консервативен, отсекая меньше в обмен на дешёвые проверки; для слабого железа есть precomputed visibility. Unity запекает через Umbra: сцена вокселизуется офлайн, раскладывается на ячейки и порталы, а в рантайме опрашивается низкоразрешающая иерархия глубины. В Godot 4 окклюзия выключена по умолчанию — её включают в настройках рендеринга и затем запекают или расставляют окклюдеры руками. Всем трём нужно знать, какая геометрия статична и какая годится в окклюдеры, а у свежего импорта не проставлено ни то ни другое: пометить здания, рельеф и дороги статикой и не пускать мелкие пропсы в набор окклюдеров — несколько минут работы, которые включают систему целиком.

Интерьерам нужны ячейки или порталы

Сгенерированные интерьеры ломаются вполне определённо: стены — отдельные коробки, и соседние стены часто не сходятся вплотную. Зазор в 2 см, который игрок никогда не увидит, для запечённого окклюдера — дыра: комната перестаёт закрывать что-либо, и вся мебель здания отправляется на отрисовку, пока вы в этом районе. У комнат, сделанных под вид сверху, часто вообще нет потолка — та же проблема по вертикали. Либо загерметизируйте оболочку, либо не трогайте видимые стены и поставьте простые коробчатые окклюдеры чуть больше комнаты, а в каждый дверной проём — портал, чтобы закрытая дверь отсекала то, что за ней. Интерьер, видимый только изнутри, часто честнее сделать отдельным подгружаемым подуровнем.

Гигиена геометрии: три дефекта, за которыми стоит охотиться

Грани, которых никто не увидит

Генерация из коробок даёт закрытые коробки. Ряд из восьми блокированных домов прячет четырнадцать стеновых граней внутри соседей, плоскость земли продолжается под каждым зданием, у мебели есть днища. Эти треугольники всё равно проходят вершинную обработку, даже когда тест глубины выбрасывает все их пиксели, они раздувают упаковку UV под лайтмапы и портят запекание окклюдеров, подсовывая пекарю геометрию внутри другой геометрии.

Компланарные поверхности

Разметка ровно в плоскости дороги, ковёр ровно на уровне пола, плакат ровно в плоскости стены. Две поверхности на одной глубине мерцают при движении камеры, и артефакт вылезает сначала вдалеке, потому что точность глубины там самая тонкая. По предпочтительности: сместить верхнюю поверхность на 1–5 мм; для того, что действительно является декалью, использовать систему декалей движка; depth bias — в последнюю очередь, поскольку под скользящим углом поверхность может уехать сквозь стену. Заодно проверьте ближнюю плоскость отсечения: 0,01 м на двухкилометровом городе обесточивает точность вдали.

Детализация, которую никто не просил

Цилиндр из 64 сегментов на столбик шириной 12 пикселей. Плоская стена, нарезанная сеткой, потому что генератор работал по сетке. Сфера в 1000 треугольников на плафон. Треугольник должен покупать либо силуэт, либо градиент затенения; если ни того ни другого — это накладные расходы. Столбику хватит 8–12 граней, стене — двух треугольников, если её не деформируют и не освещают по вершинам.

Порядок работ

  1. Снимите профиль и запишите цифры. Уменьшите разрешение вдвое, чтобы понять, кто кого ждёт.
  2. Удалите то, чего не видно — замурованные грани, землю под зданиями, геометрию за игровой зоной.
  3. Сведите материалы к именованной палитре и держите число вариантов шейдера однозначным.
  4. Пропсы в атлас, архитектуру в тайл — и переэкспорт.
  5. Замерьте снова. Узкое место, скорее всего, переехало, и остаток списка нужно пересобрать вокруг него.
  6. Инстансируйте всё повторяющееся и убедитесь, что экспорт сохранил инстансинг.
  7. Добавьте LOD первой двадцатке и импосторы или HLOD для дальнего кольца.
  8. Пройдитесь по разрешениям текстур, исходя из экранного покрытия, а не из «важности» объекта.
  9. Проставьте статику, окклюдеры, дистанции отсечения и ячейки или порталы для интерьеров.
  10. Запеките свет и замерьте последний раз — на самом слабом железе, которое собираетесь поддерживать.

Более жёсткие числа для мобилок и VR

Автономный VR сжимает все бюджеты. Рекомендации Meta для устройств Quest дают порядка 50–150 draw call'ов и нижнюю границу 72 Гц, то есть 13,9 мс на то, чтобы отрисовать сцену дважды, по разу на глаз. Бюджеты по треугольникам там измеряются сотнями тысяч, а не миллионами, так что гигиена геометрии перестаёт быть опциональной. Мобильные GPU тайловые, и это меняет форму задачи: прозрачность и овердро стоят непропорционально дорого, поэтому сцена, полная альфа-блендинговых карточек листвы и стеклянных фасадов, ляжет задолго до того, как начнёт мешать число треугольников. Переведите что можно в alpha-test или в непрозрачное, дайте стеклу непрозрачный шейдер с кубмапой отражения, используйте ASTC и уполовиньте все разрешения текстур ещё до споров о том, каким из них это нужно.

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