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

Борьба с подобными трудностями навела нас на мысли об автоматизации и написании статей на эту тему. Большая часть материала коснется работы с 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-модели, и воспользуемся рассмотренной структурой данных.