Техники рендеринга воксельного мира на мобильном устройстве в Unity

от автора

TL;DR. Эта статья для Unity/C#‑разработчиков, которые знакомы с процедурной генерацией геометрии, но не обязательно писали собственный воксельный рендерер. В статье будут описаны техники и оптимизации, направленные на ускорение рендеринга стилизованного воксельного мира на мобильном устройстве.

Если вас интересует только техническая часть (и то, как заставить видеокарту плакать от счастья), вводную обо мне можно смело пропускать.

Введение

Это моя первая статья, я не считаю себя экспертом ни в Unity, ни в компьютерной графике, но у меня есть определенный опыт, которым я хочу поделиться, а также получить обратную связь.

Всем привет, меня зовут Данил, и я Unity‑разработчик с опытом чуть больше 5 лет в коммерческой разработке. Я всегда любил игры и с детства мечтал заниматься их созданием, рисовал карты в разных играх (сделал свою школу для Half‑Life 2, знаю, многие делали:)).

У меня профильное образование «Прикладная математика и информатика», но жизнь — штука ироничная, поэтому ещё во время обучения и долгое время после я работал ревизором. В какой‑то момент работа начала надоедать, и я в свободное время начал изучать C# и Unity, просто экспериментируя с разными системами в этом движке. Спустя 15 лет ревизий я окончательно понял, что сверять дебет с кредитом — это, конечно, весело, но создавать собственные миры — куда интереснее. В итоге: несколько попыток, тестовые задания, и вот я — junior‑программист в геймдеве. Дальше — просто опыт и развитие.

В детстве мне больше всего нравилось что‑то создавать в играх: карты, текстуры или сами игры, где можно что‑то строить.

Честное признание: Я обожал The Sims, но не ради симуляции жизни. Я вводил чит‑код на деньги, строил роскошный особняк и… выходил из игры. Одна из любимых игр детства — RollerCoaster Tycoon 2. Поэтому для пет‑проекта я выбрал именно воксельную технологию мира, потому что она предоставляет возможность создавать внутриигровой контент в качестве одной из основных механик геймплея.

В этой статье я хочу рассказать о своих экспериментах с отображением стилизованного воксельного мира. Его воксели не обязаны быть скучными кубами: форма собирается из восьми вершин кубической ячейки и может образовывать склоны, углы и срезанные грани. Это не marching cubes в классическом смысле, но идея похожая: данные ячейки определяют готовую локальную геометрию.

Основная часть. Воксельный мир в Unity

Я думаю, что многие игроки, не знающие, как устроена компьютерная графика, когда видят Minecraft, задаются вопросом: «Как эта угловатая, пиксельная штука может быть кому‑то интересна?».

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

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

Мы пройдем путь от сырых данных до финального пикселя на экране, затронув такие технологии, как GPU‑driven rendering, Greedy Meshing, Surface Atlas, Soft Lighting, запекание данных света и ambient occlusion в статические структуры видеокарты, сжатие света и uv для компактного хранения вертексов, маскированные транзитные бленд маски текстур для плавных переходов между вокселями и менее машинного ощущения структуры воксельного мира. Я объясню, почему некоторые оптимизации на GPU в моем эксперименте оказались медленнее, чем «хорошо» написанный Burst‑код на CPU.

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

Форма Вокселя и Магия LUT: как описать (не кубическую) форму вокселей одним байтом

  1. Чтобы мир не выглядел «кубическим», можно сделать чтобы форма некоторых блоков зависела от их окружения. Это не идентификатор из словаря, а битовая маска: восемь битов напрямую соответствуют восьми углам кубической ячейки. 

    formByte — битовая карта внутренней формы вокселя, где каждый бит отвечает за одну из 8 вершин куба:

    • Биты 0–3 соответствуют 4-м нижним вершинам куба.

    • Биты 4–7 соответствуют 4-м верхним вершинам куба.

      Значение бита определяет статус вершины:

    • Бит сброшен (0): Вершина «срезана» (вдавлена внутрь).

    • Бит установлен (1): Вершина находится на своем законном месте.

      Так получаются все комбинаций: от пустой ячейки (formByte = 0) до полного куба (formByte = 255). Не каждая комбинация одинаково полезна художнику, зато мешеру не нужны специальные классы «склон», «угол» и «карман»: вход всегда один и тот же.

  2. Магия LUT (Lookup Table) — Предвычисленная «Библия» геометрии:

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

    Шаблон плоскости содержит локальные углы, нормали, направление выборки света и сведения, нужные для UV и контактов. Отдельные LUT отвечают на более узкие вопросы: есть ли полная внешняя грань, какой полигон соответствует нормали и можно ли объединять внутренние наклонные плоскости. 

    Горячий код не перебирает варианты формы и почти не ветвится: получает данные прямым индексом. Это важно не только для скорости отдельного блока — одинаковый предсказуемый доступ хорошо масштабируется на тысячи секций в IJobParallelFor и лучше дружит с кэшем CPU.

    Те же топологические данные используются при culling граней, выборе соседних контактов для AO и разрешении текстурных переходов. LUT не хранит готовый свет всего мира; он хранит форму и правила выборки, по которым мешер быстро получает корректный результат для конкретного окружения.

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

    Мешер не восстанавливает форму заново, а получает её описание прямым индексом

    formByte │ ▼┌──────────────────────┐│ Geometry LUT │├──────────────────────┤│ готовые плоскости ││ нормали ││ углы ││ маски граней ││ направления света │└──────────────────────┘ │ ▼Готовые данные для мешера

Фундамент: отказ от GameObject в пользу GPU‑driven rendering

Изначально я пробовал традиционный подход Unity, основанный на GameObject и MeshRenderer, но он не очень подходит для воксельных миров. Создание тысяч GameObject (даже если воксели объединены в секции) быстро исчерпывает доступную память (за счет хранения копий структур секций в доступной cpu памяти) и вызывает неприемлемые задержки из‑за накладных расходов на управление таким количеством объектов.

В итоге я принял решение отказаться от GameObject и MeshRenderer для ландшафта в пользу GPU‑driven rendering. Ландшафт не существует как набор GameObject: ECS хранит данные секций блоков, Burst строит готовые буферы, а собственный рендерер управляет долгоживущими GraphicsBuffer.

Burst‑джоба временно формирует вершины, 32-битные индексы и записи Surface Atlas (о нем позже) сразу в том layout, который читает HLSL. Рендерер передает через SetData только фактические диапазоны секции: повторной упаковки и сканирования индексов перед загрузкой на GPU нет. После загрузки временные ECS‑буферы очищаются вместе с capacity, поэтому постоянного CPU‑зеркала геометрии не остается.

Как это работает:

  1. Пейджинг (paging). Мир разделён на секции 16 × 16 × 16 вокселей. Каждая непустая секция занимает один page‑slot. Вершины и индексы страниц располагаются диапазонами переменной длины в общих GPU‑аренах. Арены объединяют страницы с одной категорией геометрии и близким размером draw‑bucket, но не владеют конкретной пространственной областью мира. При выделении или увеличении диапазона добавляется небольшой запас — 50 вершин и 50 индексов.

  2. Обновление страниц. Если новая геометрия помещается в прежние диапазоны и тот же draw‑bucket, SetData перезаписывает страницу на месте. После создания GPU‑буфера он не расширяется. Рендерер ищет свободный диапазон в подходящей арене, а если его нет — создаёт новую арену.

    Отдельно я пробовал прямую запись CPU с помощью флага LockBufferForWrite в постоянно доступную GPU‑память вместо SetData. На бумаге это выглядело как почти бесплатный обед: меньше копий и меньше работы при обновлении. На телефоне эксперимент оказался неудачным — устройство постепенно нагревалось, затем включался thermal throttling и производительность падала. С SetData телефон тоже греется, но до троттлинга в том же тесте не доходит. Мое непрофессиональное объяснение: обычный SetData позволяет драйверу использовать staging/ring‑буферы, сгруппировать копирование и оставить финальный GraphicsBuffer в памяти, удобной для длительного чтения GPU. Постоянно mapped‑ресурс может быть удобнее CPU, но хуже кэшироваться для GPU, требовать лишних барьеров и создавать больше устойчивого трафика памяти. На мобильном unified memory слово unified, как выяснилось, не означает «бесплатно». Это пока инженерная гипотеза по наблюдаемому поведению, но пока я остановился на SetData.

  3. Отсечение (culling). CPU мир проверяется крупными логическими чанками 4 × 4 секции по XZ и охватывает всю высоту. Для мира из 1024 секций это всего 16 быстрых проверок bounds. Если чанк не виден, связанные с ним GPU‑арены можно не отправлять на отрисовку. Точное отсечение остаётся на GPU: глобальный CullPages запускает один поток на page‑slot и проверяет AABB отдельной секции по frustum, near/far и высоте среза. Индексы прошедших проверку страниц записываются в список видимых страниц соответствующей draw‑арены.

  4. Indirect drawing. Страницы с близким количеством геометрии объединяются в общие draw‑арены. Для каждой видимой арены вызывается DrawProceduralIndirect, а число экземпляров в его команде соответствует числу секций, прошедших GPU‑culling. Фактическое количество индексов страницы хранится в PageData, поэтому лишняя часть draw‑bucket сразу завершается в vertex shader. Пространственные чанки отвечают за дешёвое предварительное отсечение, а арены сокращают количество отдельных вызовов отрисовки.

    Двухуровневый culling

    CPU16 логических областей 4×4 по XZ │ │ отбрасывает полностью │ невидимые области ▼GPU CullPagesодин поток на секцию │ ├─ frustum ├─ near/far └─ slice height ▼список видимых page-slot │ ▼DrawProceduralIndirect

В обычном кадре геометрия не путешествует между CPU и GPU заново. Culling пересчитывается только при изменении матрицы камеры, высоты среза или набора страниц. SetData для вершин, индексов и записей Surface Atlas выполняется при первой генерации либо для измененных секций.

Генерация меша: от вокселей к полигонам с помощью Burst и Jobs

Я активно использую стек технологий DOTS (Data‑Oriented Technology Stack). Вся логика генерации мира, от создания ландшафта до расчета освещения, вынесена в параллельные задачи — C# Jobs, скомпилированные с помощью Burst.

Секция мешируется независимо внутри задачи, запущенной через ScheduleParallel. Перед основным проходом каждая worker‑итерация один раз собирает компактную область размером 18 × 18 × 18 ячеек: саму секцию 16³ и окружающий её слой соседних вокселей толщиной в одну ячейку. В этой области уже находятся форма, материалы, свет и флаги блоков, поэтому геометрия, AO и текстурные переходы читают непрерывную локальную память вместо разрозненных ECS‑буферов соседних секций.

Соседи копируются один раз, после чего геометрия, AO, свет и переходы читают одну непрерывную область памяти.

┌─────────────────────────┐│ соседние воксели ││ ┌───────────────────┐ ││ │ │ ││ │ секция 16×16 │ ││ │ │ ││ └───────────────────┘ ││ слой толщиной 1 воксель │└─────────────────────────┘Итоговый объём: 18×18×18

Не буду углубляться в алгоритмы генерации мира (у меня пока это просто несколько нойз функции для создания ландшафта и пещер, нормальной генерации пока что нет)

Проливка и распространение света сделана простым fluid‑fill,запускаемым многопоточно по шахматным кластерам секций, до тех пор, пока в буфере пришедшего из соседнего кластера света на прошлой итерации не станет пусто. Думаю, тут ещё много места для оптимизации, но меня пока устраивает такое решение.

Внутри одной секции горячий путь выглядит так:

  1. Culling граней и Greedy Meshing. Вместо создания отдельных поверхностей для каждого вокселя, алгоритм сначала определяет реально видимые после culling грани, а затем объединяет совместимые участки одной геометрической плоскости в большие квады. Ключ объединения не включает локальный свет, AO и переходы текстур, в расчёт берём только форму поверхности: различия сохраняются в последовательных записях атласа.

    Обычный Greedy алгоритм где ключом являются (форма, цвет, текстура)

    Обычный Greedy алгоритм где ключом являются (форма, цвет, текстура)
    Агрессивный жадный алгоритм по принципу Surface Atlas

    Агрессивный жадный алгоритм по принципу Surface Atlas
    Еще пример

    Исходные ячейки:┌───┬───┬───┬───┐│ A │ B │ C │ D │ разный свет/AO└───┴───┴───┴───┘Обычный Greedy:┌───┬───┬───┬───┐│ A │ B │ C │ D │ 4 отдельных квада└───┴───┴───┴───┘Surface Atlas:┌───────────────┐│ один большой │ 4 вершины│ greedy-квад │└───────────────┘ │ ▼[A][B][C][D] записи Surface Atlas

  2. Расчет Ambient Occlusion (AO) и мягкого света: джоба сэмплирует окружение каждой нужной вершины. Рассчитывает AO (Идея близка к предварительному расчёту доступности полусферы, описанному в Chapter 17. Ambient Occlusion | NVIDIA Developer, но алгоритм адаптирован под дискретную геометрию formByte, склоны и заранее построенные LUT). Далее рассчитывается мягкий свет, усреднением света солнца и ламп из вокселей вокруг вертекса и полученный результат объединяется с AO.

    Результат статического запекания Ambient occlusion

    Результат статического запекания Ambient occlusion
  3. Подготовка конечных данных освещения и переходов текстур: для обычной геометрии они записываются в вертексы, а для объединённых greedy‑поверхностей — в Surface Atlas.

Surface Atlas: как сохранить локальные детали внутри большого квада

После Greedy Meshing большой квад может покрывать десятки исходных вокселей, причём у каждой ячейки остаются свои четыре угловых значения света и свой набор текстурных переходов. Если тащить эту информацию через дополнительные вершины, объединение теряет смысл.

Обычная вершина ландшафта занимает 28 байт. Surface Atlas использует запись того же размера, но она описывает сразу четыре угла одной поверхностной ячейки и ее четыре текстурных слоя. Большой greedy‑квад по‑прежнему состоит всего из четырех вершин, а рядом лежит компактная последовательность записей для покрытых им ячеек. Это не магическое деление всей памяти ровно на четыре, но количество вершин сокращается очень заметно.

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

Принцип работы:

  1. Кэширование в Burst‑job: для каждой исходной ячейки, покрытой greedy‑поверхностью, создается одна 28-байтная запись SectionLandscapeSurfaceBuffer. Для одного большого квада записи лежат подряд, поэтому фрагментный шейдер получает их прямым индексом без hash‑поиска. 

    Каждая запись содержит:

    • light0–light3: 4 uint. В каждом упакованы R, G, B лампового света и Sun по одному байту на канал. Итого 16 байт готового света и AO (уже упакован) для четырех углов.

    • texture_data: 2 uint. В них лежат четыре 16-битных индекса: базовая текстура и до трех transition‑слоев.

    • metadata: 4 byte. C маской вершин и заранее выбранным преобразованием локальных UV и ориентация диагонали (про смысл правильной диагонали можно вот тут почитать https://0fps.net/2013/07/03/ambient‑occlusion‑for‑minecraft‑like‑worlds/).

    Элемент в атласе — 28 байт

    ┌──────────────────────────────┐│ Light0 4 Б ││ Light1 4 Б ││ Light2 4 Б ││ Light3 4 Б │├──────────────────────────────┤│ TextureData 8 Б │├──────────────────────────────┤│ Metadata 4 Б │└──────────────────────────────┘

  2. Реконструкция на GPU: По локальным UV большого квада фрагментный шейдер сначала выбирает исходную ячейку, делает одно прямое чтение ее записи из атласа, берет уже подготовленные textureData и интерполирует четыре значения света внутри двух треугольников ячейки. После этого он смешивает базовую текстуру и до трех transition‑масок.

  3. Цена подхода — одно дополнительное чтение StructuredBuffer и арифметика на фрагмент для greedy‑поверхностей. Выигрыш — меньше геометрии: в текущей тестовой сцене 1024 секции дали 485 856 вершин и 671 562 индекса вместо 762 504 и 1 086 534; Surface Atlas добавил 219 190 записей, то есть около 5,85 MiB полезных данных. На практике выигрыш в 30% на экономии вершин и индексов всего около 3Mib. Это нельзя назвать удачным решением, возможно Surface Atlas станет выгоднее на геометрии с большим количеством крупных однородных плоскостей.

Была попытка считать свет, AO и данные атласа в compute shader. Сложный ветвистый сбор соседей, характерный для воксельной геометрии, оказался заметно медленнее оптимизированной Burst‑джобы. Поэтому сейчас вся тяжелая подготовка выполняется на CPU, а шейдер получает готовые значения.

Surface Atlas применяется только к greedy‑поверхностям; одиночные квады, треугольники и baked‑модели (заборы и воксели особых форм) используют быстрый путь с готовым светом и текстурными данными непосредственно в вершинах.

Так же применена упаковка нормалей, они упакованы в uint по методу Octahedral Impostors, экономя 8 байт на вершину.

Текстурные переходы

Одна ячейка поддерживает базовую текстуру и до трех переходных слоев. Четыре 16-битных индекса помещаются в uint2. Индекс указывает на тайл атласа и вариант маски, а HLSL последовательно накладывает transition‑слои на базовый цвет. Соседей шейдер не ищет: решение, какие переходы нужны, уже принято Burst‑джобой.

Рендеринг растительности: отдельные инстансы и LOD

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

  • Деревья. Инстанс хранит позицию корня, typeId, высоту и две упакованные световые пробы — у корня и у вершины. Покачивание считается один раз на инстанс в compute shader и сохраняется в отдельном буфере, а vertex shader только применяет готовое смещение. Геометрия каждого уровня среза ствола заранее строится по неровному контуру при кэшировании шаблона.

  • Instanced baked‑модели (например, трава). Один экземпляр занимает 16 байт: позиция, индекс шаблона и упакованный свет. При Близкий вариант использует две перекрестные плоскости, а дальний — одну billboard‑плоскость из шести вершин, повернутую вокруг Y к камере.

Culling пересчитывается только при изменении матрицы камеры, высоты среза или набора инстансов. Растительность использует alpha cutout через clip, без прозрачного blending. Это сохраняет корректный depth и сортировку, но не отменяет overdraw: несколько слоев кроны все равно могут многократно запускать фрагментный шейдер для одного пикселя. Перед GPU‑culling выполняется дешёвая CPU‑проверка групп секций. Если ни одна группа потенциально не видна, compute‑dispatch, обновление ветра и сам render pass пропускаются полностью

Срез мира

Игрок может опустить горизонтальную плоскость среза и заглянуть внутрь мира. Ландшафт и baked‑модели передают высоту как SV_ClipDistance из vertex shader, поэтому обычный fragment shader не вызывает clip для отсечения мира по Y. Страницы, полностью лежащие выше среза, отбрасываются еще в CullPages.

Финальная схема

BlockState + formByte │ ▼ Geometry LUT │ ▼ Burst-мешер по секциям │ ├─ вершины ├─ индексы └─ Surface Atlas │ ▼ SetData изменённых секций │ ▼ Долгоживущие GPU-арены │ ▼ CPU culling │ ▼ GPU culling │ ▼ DrawProceduralIndirect │ ▼ Vertex + Fragment shader

Что с производительностью?

Тестовое окружение:

  • Платформа: Android 16

  • Устройство: Samsung galaxy s23 Plus

  • Графическое Api: Vulkan

  • Настройки:

    VsyncCount = 1

    TargetFrameRate = 60

    RenderScale сделан под максимальное разрешение 1080p

  • Мир: 256 на 256 вокселей по горизонтали и 64 вокселя по вертикали — это ровно 1024 секции.

Данные о нагрузке на Gpu получаю с помощью FrameTimingManager.CaptureFrameTimings(), потому что я не очень хорошо пока понимаю, как профилировать нормально Gpu на устройстве.

В тестах весь мир занимает около 18 SetPass‑вызовов, примерно 4 из них приходятся на UI.

При максимальном отдалении получалось примерно 60–80 FPS по PureGpu, при обычном игровом масштабе — стабильно выше 60 FPS со скачками до 90. После более чем 20 минут на максимальном масштабе телефон теплый, но не переходит в thermal throttling. Первичная загрузка готовых данных через SetData остается самой дорогой разовой операцией. Генерация мира занимает около 600–700 мс, из них холодное меширование всех секций — 80–130 мс. Локальное изменение перестраивает и загружает только измененные секции.

Эти цифры — профиль конкретной версии на конкретном телефоне, а не универсальный бенчмарк. Эксперимент с mapped GPU‑памятью хорошо показал почему: короткий замер может выглядеть бодро, а через несколько минут в разговор вступает температура.

Заключение

Подход к рендерингу из этой статьи основан на нескольких практических решениях: компактной форме вокселя, предвычисленных LUT, параллельном Burst‑мешере, page‑slot с диапазонами переменного размера, Surface Atlas и indirect drawing через URP RenderGraph. Главный результат для меня не в одной «волшебной» оптимизации, а в разделении работы: CPU один раз готовит сложные и ветвистые данные, GPU в кадре читает плотные буферы и выполняет короткий предсказуемый путь.

Surface Atlas здесь — осознанный компромисс. Он сокращает геометрию, но добавляет чтение и арифметику во fragment shader. Прямая запись в mapped GPU‑память и отдельный staging‑путь тоже не стали бесплатным ускорением: длительный мобильный тест показал больший нагрев. Поэтому выводы я проверяю не только по FPS первых секунд, но и по времени кадра, фактическим capacity буферов и температуре устройства.

P. S. Впереди еще много экспериментов. Если я где‑то ошибся или вы знаете более удачный вариант этого пайплайна, буду рад конструктивной критике и идеям в комментариях.

Данный проект создан исключительно в учебных и демонстрационных целях. В качестве визуального оформления (тайлов) использована графика из игры Timber and Stone. Автор статьи не претендует на авторство графических ассетов; фокус данной публикации сосредоточен исключительно на технической реализации механик и коде в Unity.

ссылка на оригинал статьи https://habr.com/ru/articles/1064472/