Особенности работы с Mesh в Unity
Компьютерная графика, как известно, является основой игровой индустрии. В процессе создания графического контента мы неизбежно сталкиваемся с трудностями, связанными с разницей его представления в среде создания и в приложении. К этим трудностям прибавляются риски простой человеческой невнимательности. Учитывая масштабы разработки игр, такие проблемы возникают либо часто, либо в больших количествах.
Борьба с подобными трудностями навела нас на мысли об автоматизации и написании статей на эту тему. Большая часть материала коснется работы с Unity 3D, поскольку это основное средство разработки в Plarium Krasnodar. Здесь и далее в качестве графического контента будут рассматриваться 3D-модели и текстуры.
В этой статье мы поговорим об особенностях доступа к данным представления 3D-объектов в Unity. Материал будет полезен в первую очередь новичкам, а также тем разработчикам, которые нечасто взаимодействуют с внутренним представлением таких моделей.
О 3D-моделях в Unity — для самых маленьких
При стандартном подходе в Unity для рендеринга модели используются компоненты MeshFilter и MeshRenderer. MeshFilter ссылается на Mesh — ассет, который представляет модель. Для большинства шейдеров информация о геометрии является обязательной минимальной составляющей для отрисовки модели на экране. Данные же о текстурной развертке и костях анимации могут отсутствовать, если они не задействованы. Каким образом этот класс реализован внутри и как все там хранится, является тайной за энную сумму денег семью печатями.
Снаружи меш как объект предоставляет доступ к следующим наборам данных:
- vertices — набор позиций вершин геометрии в трехмерном пространстве с собственным началом координат;
- normals, tangents — наборы векторов-нормалей и касательных к вершинам, которые обычно используются для расчета освещения;
- uv, uv2, uv3, uv4, uv5, uv6, uv7, uv8 — наборы координат для текстурной развертки;
- colors, colors32 — наборы значений цвета вершин, хрестоматийным примером использования которых является смешивание текстур по маске;
- bindposes — наборы матриц для позиционирования вершин относительно костей;
- boneWeights — коэффициенты влияния костей на вершины;
- triangles — набор индексов вершин, обрабатываемых по 3 за раз; каждая такая тройка представляет полигон (в данном случае треугольник) модели.
Доступ к информации о вершинах и полигонах реализован через соответствующие свойства (properties), каждое из которых возвращает массив структур. Человеку, который не читает документацию редко работает с мешами в Unity, может быть неочевидно, что всякий раз при обращении к данным вершины в памяти создается копия соответствующего набора в виде массива с длиной, равной количеству вершин. Этот нюанс рассмотрен в небольшом блоке документации. Также об этом предупреждают комментарии к свойствам класса Mesh, о которых говорилось выше. Причиной такого поведения является архитектурная особенность Unity в контексте среды исполнения Mono. Схематично это можно изобразить так:
Ядро движка (UnityEngine (native)) изолировано от скриптов разработчика, и обращение к его функционалу реализовано через библиотеку UnityEngine (C#). Фактически она является адаптером, поскольку большинство методов служат прослойкой для получения данных от ядра. При этом ядро и вся остальная часть, в том числе ваши скрипты, крутятся под разными процессами и скриптовая часть знает только список команд. Таким образом, прямой доступ к используемой ядром памяти из скрипта отсутствует.
О доступе к внутренним данным, или Насколько все может быть плохо
Для демонстрации того, насколько все может быть плохо, проанализируем объем очищаемой памяти Garbage Collector«ом на примере из документации. Для простоты профилирования завернем аналогичный код в Update метод.
public class MemoryTest : MonoBehaviour
{
public Mesh Mesh;
private void Update()
{
for (int i = 0; i < Mesh.vertexCount; i++)
{
float x = Mesh.vertices[i].x;
float y = Mesh.vertices[i].y;
float z = Mesh.vertices[i].z;
DoSomething(x, y, z);
}
}
private void DoSomething(float x, float y, float z)
{
//nothing to do
}
}
Мы прогнали данный скрипт со стандартным примитивом — сферой (515 вершин). При помощи инструмента Profiler, во вкладке Memory можно посмотреть, сколько памяти было помечено для очистки сборщиком мусора в каждом из кадров. На нашей рабочей машине это значение составило ~9.2 Мб.
Это довольно много даже для нагруженного приложения, а мы здесь запустили сцену с одним объектом, на который навешен простейший скрипт.
Важно упомянуть об особенности компилятора .Net и об оптимизации кода. Пройдясь по цепочке вызовов, можно обнаружить, что обращение к Mesh.vertices влечет за собой вызов extern метода движка. Это не позволяет компилятору оптимизировать код внутри нашего Update () метода, несмотря на то, что DoSomething () пустой и переменные x, y, z по этой причине являются неиспользуемыми.
Теперь закешируем массив позиций на старте.
public class MemoryTest : MonoBehaviour
{
public Mesh Mesh;
private Vector3[] _vertices;
private void Start()
{
_vertices = Mesh.vertices;
}
private void Update()
{
for (int i = 0; i < _vertices.Length; i++)
{
float x = _vertices[i].x;
float y = _vertices[i].y;
float z = _vertices[i].z;
DoSomething(x, y, z);
}
}
private void DoSomething(float x, float y, float z)
{
//nothing to do
}
}
В среднем 6 Кб. Другое дело!
Такая особенность стала одной из причин, по которой нам пришлось реализовать собственную структуру для хранения и обработки данных меша.
Как это делаем мы
За время работы над крупными проектами возникла идея сделать инструмент для анализа и редактирования импортируемого графического контента. О самих методах анализа и трансформации поговорим в следующих статьях. Сейчас же рассмотрим структуру данных, которую мы решили написать для удобства реализации алгоритмов с учетом особенностей доступа к информации о меше.
Изначально эта структура выглядела так:
Здесь класс CustomMesh представляет, собственно, меш. Отдельно в виде Utility мы реализовали конвертацию из UntiyEngine.Mesh и обратно. Меш определяется своим массивом треугольников. Каждый треугольник содержит ровно три ребра, которые в свою очередь определены двумя вершинами. Мы решили добавить в вершины только ту информацию, которая нам необходима для анализа, а именно: позицию, нормаль, два канала текстурной развертки (uv0 для основной текстуры, uv2 для освещения) и цвет.
Спустя некоторое время возникла необходимость обращения вверх по иерархии. Например, чтобы узнать у треугольника, какому мешу он принадлежит. Помимо этого, обращение вниз из CustomMesh в Vertex выглядело вычурно, а необоснованный и значительный объем дублированных значений действовал на нервы. По этим причинам структуру пришлось переработать.
В CustomMeshPool реализованы методы для удобного управления и доступа ко всем обрабатываемым CustomMesh. За счет поля MeshId в каждой из сущностей имеется доступ к информации всего меша. Такая структура данных удовлетворяет требованиям к первоначальным задачам. Ее несложно расширить, добавив соответствующий набор данных в CustomMesh и необходимые методы — в Vertex.
Стоит отметить, что такой подход не оптимален по производительности. В то же время большинство реализованных нами алгоритмов ориентированы на анализ контента в редакторе Unity, из-за чего не приходится часто задумываться об объемах используемой памяти. По этой причине мы кешируем буквально все что можно. Реализованный алгоритм мы сначала тестируем, а затем рефакторим его методы и в некоторых случаях упрощаем структуры данных для оптимизации времени выполнения.
На этом пока все. В следующей статье мы расскажем о том, как редактировать уже внесенные в проект 3D-модели, и воспользуемся рассмотренной структурой данных.