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

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

Комментарии (8)


  1. WanSpi
    18.02.2019 17:54

    Сам недавно столкнулся с похожей проблемой, и реализовал свой MeshEditor, но у меня треугольник хранит в себе по три вершины, вместо Ваших трех сторон, почему именно хранить стороны, а не вершины?


    1. Plarium Автор
      19.02.2019 11:05

      Для реализации некоторых алгоритмов проще работать именно с ребрами как отдельными сущностями, а не с парами вершин. Примеры сможете посмотреть в следующих статьях данного цикла. Из тех же соображений удобства в коде Triangle у нас также есть properties для прямого доступа к вершинам.


      1. WanSpi
        19.02.2019 13:27

        > Из тех же соображений удобства в коде Triangle у нас также есть properties для прямого доступа к вершинам.

        На последней схеме не заметил что бы был прямой доступ к вершинам, из под триангла.

        > Примеры сможете посмотреть в следующих статьях данного цикла.

        Тогда буду с нетерпением ждать следующей статьи :)


  1. FDsagizi
    18.02.2019 21:33
    +1

    vertices = Mesh.vertices;

    Данные из меша можно получать через Mesh.GetVertexes( list ) тогда аллокаций не будет, если лист достаточной длины.


    1. Plarium Автор
      19.02.2019 11:07

      Да, верно подмечено. Однако этот метод больше полезен, судя по всему, для обработки множества мешей подряд, используя один и тот же список. С точки зрения времени выполнения все равно лучше кешировать, если хватает памяти. Кроме того, мы используем PLINQ для параллельного выполнения некоторых операций по обработке геометрии, а LINQ более лоялен по производительности к Array, нежели к List (https://jacksondunstan.com/articles/3165). Еще Mesh.GetVertexes также обращается к ядру движка, чего мы по возможности избегали в собственной реализации.


  1. Thibo
    19.02.2019 11:07

    А подскажите, уважаемые, не допилен-ли Unity за последний год до пригодности создания на нём простейших игрушек 3D паззлов? Уровня примерно такого:
    image
    image
    Ранее точность детектора коллизий для таких сложных фигур была вопиюще неприемлемой.
    Уж не говоря об отсутствии возможности сделать в меше несколько необходимых вырезов.


    1. Plarium Автор
      19.02.2019 11:09

      Вычисления коллизий невыпуклых фигур — довольно трудоемкая задача.
      Для расчетов физики 3D-объектов Unity использует PhysX от NVIDIA. В 2018.3 как раз было обновление с 3.3 до 3.4. По Вашему вопросу советуем смотреть, какие улучшения произошли при смене версии PhysX, а не самого Unity.


      1. Thibo
        19.02.2019 13:56

        Благодарю, ясн. Плюсик извините поставить не могу, пишу оч редко и «кармы» не нажил.
        Да, нетривиальная.
        Список changes для 3.4 как-то не воодушевил, да Unity действительно «не для такого».
        Но мне задача актуальна, кто знает подходящие движки и плагины — буду благодарен за наводку, можно в ЛС.