что такое meshes в играх
Особенности работы с Mesh в Unity
Компьютерная графика, как известно, является основой игровой индустрии. В процессе создания графического контента мы неизбежно сталкиваемся с трудностями, связанными с разницей его представления в среде создания и в приложении. К этим трудностям прибавляются риски простой человеческой невнимательности. Учитывая масштабы разработки игр, такие проблемы возникают либо часто, либо в больших количествах.
Борьба с подобными трудностями навела нас на мысли об автоматизации и написании статей на эту тему. Большая часть материала коснется работы с Unity 3D, поскольку это основное средство разработки в Plarium Krasnodar. Здесь и далее в качестве графического контента будут рассматриваться 3D-модели и текстуры.
В этой статье мы поговорим об особенностях доступа к данным представления 3D-объектов в Unity. Материал будет полезен в первую очередь новичкам, а также тем разработчикам, которые нечасто взаимодействуют с внутренним представлением таких моделей.
О 3D-моделях в Unity — для самых маленьких
При стандартном подходе в Unity для рендеринга модели используются компоненты MeshFilter и MeshRenderer. MeshFilter ссылается на Mesh — ассет, который представляет модель. Для большинства шейдеров информация о геометрии является обязательной минимальной составляющей для отрисовки модели на экране. Данные же о текстурной развертке и костях анимации могут отсутствовать, если они не задействованы. Каким образом этот класс реализован внутри и как все там хранится, является тайной за энную сумму денег семью печатями.
Снаружи меш как объект предоставляет доступ к следующим наборам данных:
Ядро движка (UnityEngine (native)) изолировано от скриптов разработчика, и обращение к его функционалу реализовано через библиотеку UnityEngine (C#). Фактически она является адаптером, поскольку большинство методов служат прослойкой для получения данных от ядра. При этом ядро и вся остальная часть, в том числе ваши скрипты, крутятся под разными процессами и скриптовая часть знает только список команд. Таким образом, прямой доступ к используемой ядром памяти из скрипта отсутствует.
О доступе к внутренним данным, или Насколько все может быть плохо
Для демонстрации того, насколько все может быть плохо, проанализируем объем очищаемой памяти Garbage Collector’ом на примере из документации. Для простоты профилирования завернем аналогичный код в Update метод.
Мы прогнали данный скрипт со стандартным примитивом — сферой (515 вершин). При помощи инструмента Profiler, во вкладке Memory можно посмотреть, сколько памяти было помечено для очистки сборщиком мусора в каждом из кадров. На нашей рабочей машине это значение составило
Это довольно много даже для нагруженного приложения, а мы здесь запустили сцену с одним объектом, на который навешен простейший скрипт.
Важно упомянуть об особенности компилятора .Net и об оптимизации кода. Пройдясь по цепочке вызовов, можно обнаружить, что обращение к Mesh.vertices влечет за собой вызов extern метода движка. Это не позволяет компилятору оптимизировать код внутри нашего Update() метода, несмотря на то, что DoSomething() пустой и переменные x, y, z по этой причине являются неиспользуемыми.
Теперь закешируем массив позиций на старте.
В среднем 6 Кб. Другое дело!
Такая особенность стала одной из причин, по которой нам пришлось реализовать собственную структуру для хранения и обработки данных меша.
Как это делаем мы
За время работы над крупными проектами возникла идея сделать инструмент для анализа и редактирования импортируемого графического контента. О самих методах анализа и трансформации поговорим в следующих статьях. Сейчас же рассмотрим структуру данных, которую мы решили написать для удобства реализации алгоритмов с учетом особенностей доступа к информации о меше.
Изначально эта структура выглядела так:
Здесь класс CustomMesh представляет, собственно, меш. Отдельно в виде Utility мы реализовали конвертацию из UntiyEngine.Mesh и обратно. Меш определяется своим массивом треугольников. Каждый треугольник содержит ровно три ребра, которые в свою очередь определены двумя вершинами. Мы решили добавить в вершины только ту информацию, которая нам необходима для анализа, а именно: позицию, нормаль, два канала текстурной развертки (uv0 для основной текстуры, uv2 для освещения) и цвет.
Спустя некоторое время возникла необходимость обращения вверх по иерархии. Например, чтобы узнать у треугольника, какому мешу он принадлежит. Помимо этого, обращение вниз из CustomMesh в Vertex выглядело вычурно, а необоснованный и значительный объем дублированных значений действовал на нервы. По этим причинам структуру пришлось переработать.
В CustomMeshPool реализованы методы для удобного управления и доступа ко всем обрабатываемым CustomMesh. За счет поля MeshId в каждой из сущностей имеется доступ к информации всего меша. Такая структура данных удовлетворяет требованиям к первоначальным задачам. Ее несложно расширить, добавив соответствующий набор данных в CustomMesh и необходимые методы — в Vertex.
Стоит отметить, что такой подход не оптимален по производительности. В то же время большинство реализованных нами алгоритмов ориентированы на анализ контента в редакторе Unity, из-за чего не приходится часто задумываться об объемах используемой памяти. По этой причине мы кешируем буквально все что можно. Реализованный алгоритм мы сначала тестируем, а затем рефакторим его методы и в некоторых случаях упрощаем структуры данных для оптимизации времени выполнения.
На этом пока все. В следующей статье мы расскажем о том, как редактировать уже внесенные в проект 3D-модели, и воспользуемся рассмотренной структурой данных.
Оптимизация 3D-моделей для игровой сцены
Почти 2 года назад мы написали статью, в которой рассказали о варианте оптимизации 3D-геометрии в сцене с ограничениями на ракурс камеры и поворот соответствующих объектов. Не то чтобы много воды утекло с тех пор, однако возможность усовершенствовать решение, рассмотреть разные подходы и подглядеть за другими не дает покоя умам разработчиков. В этой статье мы опишем улучшенный вариант алгоритма, основанного на покраске полигонов, а также расскажем о попытках перенести часть такой работы в 3D-пакет.
Обрезка в сцене
Основной принцип этого алгоритма мы уже рассматривали в указанной выше статье: гасим все эффекты и прозрачные объекты, красим необрабатываемые полигоны одним цветом, а обрабатываемые — другими, рендерим, извлекаем результат. В старом варианте красили так, что все черное было лишним, а красным цветом метили только один треугольник.
В комментариях к той статье один из читателей указал на возможность оптимизировать алгоритм, установив взаимооднозначное соответствие между множеством полигонов и некоторым набором уникальных чисел. Тогда можно будет обрабатывать тем же способом больше одного треугольника. Рассмотрим такой вариант.
В этом случае, как и в прошлый раз, предполагается некоторая предподготовка, связанная с отключением всех свистящих объектов на сцене и объектов, гарантированно не влияющих на видимость целевой модели. Ракурсы камеры обрабатываются почти независимо, они связаны лишь общим буфером индексов видимых полигонов. Помимо этого, для каждого ракурса проводится предобработка геометрии, в процессе которой удаляются полигоны, повернутые к камере обратной стороной (backface). Так делается, потому что на определенном этапе алгоритма создается временный меш со значительно большим количеством вершин, чем у исходного. Это число может запросто превысить порог в 65 535, что потребует дополнительных телодвижений при вычислениях и приведет к снижению производительности. Эти полигоны в любом случае удалятся, так как их цвет не попадет в кадр. Однако из-за того, что каждый треугольник потенциально родит до трех мусорных вершин, устранение лишних полигонов заранее облегчает основной этап алгоритма и снижает затраты памяти.
Пусть имеется некоторая 3D-модель, геометрия которой представлена мешем. Чтобы покрасить конкретный полигон в уникальный цвет, нужно покрасить все его вершины в этот цвет. Поскольку в общем случае одна вершина может принадлежать разным полигонам, решить проблему в лоб не получится. Как бы мы ни покрасили какую-либо вершину, при рендеринге ее цвет переползет на все треугольники, которые ей владеют, в соответствии с алгоритмом интерполяции на стороне видеокарты.
Пример интерполяции цвета при отображении полигонов с общими вершинами
Поэтому необходимо как-то получить разбиение меша на отдельные независимые полигоны, сохранив при этом топологию и геометрию объекта. Dictum factum. Преобразуем массивы треугольников и вершин таким образом, что для каждого треугольника будет создано 3 уникальных вершины, позиция которых определяется по соответствующим вершинам исходного меша. Стоит отметить, что в общем случае такой меш будет иметь ощутимо большее количество вершин в сравнении с оригиналом. И если это число превысило 65 535, то при создании меша необходимо указать соответствующий формат индексации.
Теперь нужно обозначить полигоны этого меша так, чтобы после операции рендеринга можно было определить, какой из них попал на экран. Как уже было сказано, генерируем уникальные цвета для полигонов и красим каждую тройку вершин в соответствующий цвет. В результате получается новый меш, который мы назвали Byte-Colored Mesh.
Byte-Colored Mesh
Запоминаем раскраску. Настало время рендерить. Осуществляем 3D-рендеринг для всех ракурсов камеры и при обработке каждого из них пополняем буфер уникальных индексов полигонов, чьи цвета были обнаружены в кадре. На время вычислений для камеры нужно отключить сглаживание, чтобы избежать появления новых цветов из-за интерполяции соседних пикселей.
Стоит упомянуть, что по причине дискретизации некоторые треугольники могут не отобразиться из-за особо малого размера их проекции на экран, а не потому, что их что-то перекрывает или они повернуты не той стороной. Мы реализовали консервативный вариант алгоритма. В этом случае вычисляется AABB проекции треугольника на экране, и если хотя бы одна из его сторон меньше стороны текселя в снимке, то такой полигон помечается как видимый. Этот подход защищает от артефактов при запуске алгоритма с разрешением, которое меньше, чем разрешение экрана целевого устройства. Если же игнорировать мелкие полигоны, то результат будет также приемлем при условии, что разрешение используемой рендер текстуры больше разрешения экранов предполагаемых устройств.
Этот алгоритм обрезки мы реализовали в Unity и используем для оптимизации статических объектов, модели которых встречаются в сцене более одного раза в самых разных положениях. Это в основном декорации: камни, деревья, статуи, вазы и прочее, что ссылается на часто используемый префаб. Мы бы хотели оптимизировать такие объекты раньше, на этапе создания в 3D-пакете, но кто знает, в какую фантасмагоричную позу дизайнер уровней захочет поставить любимый канделябр.
Обрезка множества однотипных объектов таким инструментом уменьшает размер сцены, поскольку при static batching данные общего меша префабов в любом случае копируются на этапе билда столько раз, сколько активных отрисованных объектов с этим мешем представлено в сцене. Также наш метод позволяет освободить место в текстурных атласах, таких как lightmap. Сэкономленное пространство мы используем для повышения детализации тех частей моделей, которые пережили чистку.
Обрезка в 3D-пакете
И все же лучше, если художник может обрезать все лишнее у себя в редакторе, сокращая таким образом число этапов подготовки контента. Это оправдано, когда модель используется в сцене только с одним заранее определенным поворотом относительно камеры. Раньше объекты, которые точно будут повернуты к пользователю одной стороной, часто упрощались вручную перед интеграцией в проект. Важно отметить, что осуществлять такое упрощение программно в Unity значительно труднее из-за сложности упаковки UV-развертки, поэтому автоматизация на этапе 3D-пакета подчас облегчает жизнь художника.
Один из инструментов для работы с 3D-моделями в нашей компании — Blender. В него мы и залезли. Вроде бы такой «взрослый» софт, как Blender, должен иметь подобный функционал. Однако оказалось, что не должен. Пришлось пилить свой собственный велосипед.
Первой идеей было использовать всем знакомый инструмент выделения — по сути повторить часть ручной работы художника для одного ракурса камеры: выделить видимые полигоны, инвертировать выделение, удалить. План был такой: перемещать камеру, в каждой позиции определять AABB проекции модели, затем запросить результат выделения полигонов области, соответствующей AABB, получить объединение множества полигонов текущего ракурса с предыдущими и в конце удалить невыделенные полигоны.
Однако во время реализации скрипта был обнаружен существенный недостаток с точки зрения поставленной задачи. Инструменты выделения в Blender (rectangle select, circle select) теряют точность с возрастанием количества выделяемых элементов на единицу площади экрана (некоторые полигоны остаются невыделенными), что делает их использование в наших средствах автоматизации невозможным. Интересный факт: в том же 3ds Max такой проблемы не наблюдается.
Выделение издалека в Blender
Результат выделения
Следующая попытка была направлена на решение задачи в лоб: мы пускали лучи из камеры через каждый пиксель вьюпорта и смотрели, какие полигоны первыми пересекутся с хотя бы одним лучом. Мы не надеялись на точные результаты при таком подходе, но попробовать стоило. Итог очевиден: очень низкая производительность при обработке на CPU или те же дырки при небольшом количестве лучей.
Тем не менее мы сделали плацдарм для воплощения более продвинутого подхода. Идея состояла в том, чтобы выбрать энное количество случайных точек на каждом полигоне и затем пустить в их направлении лучи из камеры. Этот подход хорошо себя зарекомендовал, но у нас возникали граничные случаи: обрезались также полигоны, у которых угол между лучом и их нормалью был приблизительно равен π/2. Таким образом, при зуме камеры из-за перспективных искажений могли открыться вырезанные участки.
Этот способ оказался, по мнению художников, слишком агрессивным, поэтому мы решили остановиться на обрезке только backfaces.
Заключение
Не секрет, что бережное отношение к ресурсам устройства при создании игр — это важнейший фактор, влияющий на качество конечного продукта. Особенно это касается мобильных платформ, капризных к активному использованию ОЗУ. Сокращение количества полигонов позволяет более эффективно заполнять пространство текстурных атласов и немного снижает вычислительную нагрузку.
Также не стоит забывать о затратах человеко-часов и о цене ошибки при применении инструментов, описанных выше, и им подобных. Предложенный подход предполагает хорошо отлаженный пайплайн работы арт-отдела, в особенности сотрудников, занимающихся интеграцией моделей в проект.
Таким образом, имея условия и инструменты, рассмотренные в этой статье, мы придерживаемся следующих правил. Если предполагается, что создаваемая модель будет всегда повернута одной стороной к пользователю, а также если с этих ракурсов величина перекрытия одних частей модели другими довольно мала, то художник пользуется нашим инструментом обрезки backfaces в 3D-редакторе, проверяет корректность и приступает к упаковке UV-развертки. Если же модель часто используется в разных положениях или имеет более сложную геометрию, то уже после импорта в проект мы запускаем описанный в первой части статьи алгоритм, обрабатывая им все статичные объекты в сцене.
Unity: процедурное редактирование Mesh
Преобразование моделей «на лету» — нередкая практика в симуляции физики деформаций, а также в играх с динамически генерируемым и изменяемым контентом. В таких случаях удобно применять методы процедурного редактирования и создания геометрии. Последние часто позволяют сэкономить заветные байты при передаче подгружаемых из сети данных. Кроме того — это весело!
Статья направлена на прокачку навыков процедурной обработки мешей в Unity. Мы расскажем об операциях преобразования и генерации частей меша.
Наш джентльменский набор для процедурного редактирования 3D-моделей включает три базовые операции: триангуляцию, движение точек, выдавливание. Подробно поговорим о последних двух. Сначала рассмотрим простейшие операции движения — перемещение вершин, поворот и масштабирование ребер и треугольников. Затем разберемся с одним из способов генерации новой геометрии — операцией выдавливания (Extrude).
В предыдущей публикации мы описывали свою структуру для удобной работы с данными 3D-моделей.
Как можно заметить, здесь используется PLINQ. Это обусловлено тем, что алгоритмы вычислительной геометрии часто можно оптимизировать за счет многопоточности.
Конечно, во время выполнения LINQ-конструкций мусора создается больше, чем при выполнении «ручного» кода. Однако этот недостаток в значительной степени компенсируется лаконичностью таких конструкций, а также наличием в PLINQ встроенных средств управления ресурсами. Кроме того, переход между однопоточной и многопоточной реализацией осуществляется с помощью всего лишь одной команды, что сильно облегчает процесс отладки.
Кручу, верчу, запутать хочу
Приступим к операциям движения. В перемещении вершин ничего сложного нет. Только нужно не забывать о совпадающих вершинах: если требуется, их положение тоже должно меняться.
Алгоритм реализован через добавление вектора движения к позиции вершины. Смещение при этом происходит относительно начала координат модели (pivot). Стоит отметить, что положение полигонов при таких трансформациях может меняться, а нормали их вершин — нет. Однако для упрощения изложения мы не будем рассматривать этот нюанс.
В CAD-средствах есть функция перерасчета нормалей, которую обычно вызывают уже после применения требуемых трансформаций. Существуют разные способы выполнения такого перерасчета. Наиболее распространенный вычисляет нормаль к плоскости каждого треугольника, а затем каждой вершине присваивает нормаль как среднее от нормалей треугольников, которым эта вершина принадлежит.
В целом здесь нет веских причин усложнять код и применять матрицу трансформации. Результат добавления вектора движения к позиции вершины соответствует интуитивному представлению о ее перемещении.
Перемещение ребер и треугольников реализовано так же — добавлением вектора смещения.
А вот вращать и масштабировать удобнее при помощи матрицы преобразования. Результат выполнения этих операций относительно начала координат модели скорее всего окажется не таким, каким вы ожидали или хотели его увидеть. За опорную точку вращения и масштабирования обычно берется середина объекта — как наиболее понятная для человеков.
Роем себе аккуратную ямку
В 3D-моделировании часто применяется операция выдавливания (Extrude). Для ее выполнения должен быть известен вектор движения (смещения) и набор полигонов. Процесс выдавливания можно декомпозировать на два действия:
1. Смещение полигонов на заданный вектор движения (offset). При этом необходимо дублировать разделяемые граничными полигонами вершины, чтобы не нарушать положение тех элементов, которые не относятся к смещаемой части. Иначе говоря, нужно оторвать и передвинуть выбранный кусок. Если этот шаг выполнить первым, то модель, вероятно, развалится на части, которые придется соединять в будущем.
2. Добавление новой геометрии между границей смещенной части и границей, которая образовалась при выдавливании. Просвет между основной и сдвинутой частями модели заполняется полигонами, образующими стенку.
В реализации удобнее сначала выполнять построение стенки, поскольку до сдвига мы имеем исходное положение ребер на границе и можем использовать эти данные сразу. В противном случае пришлось бы либо инвертировать направление вектора сдвига, либо сохранять часть информации о начальном состоянии меша.
Модель и ее части, с которыми мы работаем, складываются из множеств попарно соседних полигонов (треугольников). Назовем каждое такое множество кластером.
Два выделенных на модели кластера в Blender
Сперва нам понадобится получить все ребра контуров, ограничивающих выбранные кластеры. Для этого достаточно последовательно добавлять ребра в список. Если встречается совпадающее ребро, то его необходимо удалять, не добавляя при этом текущее. Для правильности работы такого алгоритма нужно ввести ограничение: на выбранном множестве треугольников не существует больше двух совпадающих ребер. В кейсах, где используется Extrude, модели зачастую удовлетворяют этому условию, а более сложный алгоритм требует больших вычислительных ресурсов.
После получения всех ребер контура нужно построить соответствующие стенки. Вариантов реализации можно нафантазировать много, но мы решили пойти по пути наименьшего сопротивления — генерировать параллелограммы в направлении вектора движения на основе ребер по отдельности. Поскольку смещение у нас для всех одно, в результате этого действия параллелограммы будут образовывать сплошную и замкнутую стенку для каждого кластера. Остается определиться с ориентацией элементов стенки.
Стенка, как и весь меш, состоит из треугольников. По конвенции OpenGL обособленный треугольник рендерится на экране, если при проецировании его точек на плоскость экрана обход их по порядку соответствует обходу по часовой стрелке:
Так, треугольнику соответствует некоторый вектор нормали, определяющий лицевую сторону. Каждый треугольник ограничен выпуклым контуром, состоящим из трех ребер. У каждого ребра есть две вершины, в нашей структуре представленные как v0 и v1. Определим направление ребра так, что v0 — начало, v1 — конец. Теперь, если направление ребер треугольника задано в соответствии с обходом его вершин, то любой внешний контур кластера должен иметь обход либо по часовой стрелке, либо против, а любой внутренний — наоборот. Конструкторы CustomMesh и Triangle мы реализовали так, чтобы обход вершин всех треугольников соответствовал направлению хода часовой стрелки.
Имея направление обхода контура, можно точно сказать, с какой стороны от ребра находится внутренняя часть контура, а с какой — внешняя. Опираясь на эту информацию, мы будем выбирать ориентацию стенки. Пусть (v0, v1) — ребро, на основе которого нужно сгенерировать желаемый параллелограмм. Возьмем две точки v2 и v3 как позиции смещения v0 и v1. Затем построим два треугольника по следующей схеме:
И так для каждого ребра контура.
При таком подходе лицевая сторона генерируемых стенок будет корректной и для горок, и для ямок. Есть лишь одно существенное ограничение: множество треугольников, над которым выполняется операция Extrude, не должно заворачиваться под себя относительно вектора движения.
Подмножество полигонов, невалидное относительно смещения. Даже в Blender при таком Extrude не удастся избежать кривой геометрии
Валидные подмножества полигонов
Стенка готова, осталось сместить треугольники. Этот шаг алгоритма прост в понимании, хоть реализация и получилась громоздкой.
В нашем случае нужно убедиться, что каждая вершина кластера принадлежит только его треугольникам. Если не выполнить условие, то за кластером могут потянуться некоторые соседние полигоны. Решение этой ситуации — продублировать каждую вершину, принадлежащую как кластеру, так и остальной части модели. Затем для всех полигонов кластера заменить индекс данной вершины на индекс дубликата. Когда условие выполнено, перемещаем все вершины кластера на вектор движения.
Готово. Теперь, сложив результаты всех шагов, получаем ямку или горку.
Пошаманив с координатами текстурной развертки и смещением точек контура, можно получить вот такое углубление:
И это еще не все
Помимо рассмотренных выше операций редактирования мы пользуемся и другими удобными методами работы с моделями.
Например, дополнительно мы написали метод Combine() для объединения двух CustomMesh. Ключевое отличие нашей реализации от UnityEngine.Mesh.CombineMeshes() в том, что если при объединении мешей какие-то вершины оказываются полностью эквивалентными, мы оставляем только одну из них, таким образом избегая лишней геометрии.
В том же модуле мы реализовали алгоритм плоской триангуляции Делоне. Используя его, можно, например, закрыть большую яму, созданную с помощью Extrude, плоской крышкой с текстурой воды и получить озеро:
Что же, с этим разобрались! В следующей статье рассмотрим особенности импорта .fbx в Unity и методы валидации моделей в проекте.