Эта статья завершает цикл публикаций от краснодарской студии Plarium о разных аспектах работы с 3D-моделями в Unity. Предшествующие статьи: «Особенности работы с Mesh в Unity», «Unity: процедурное редактирование Mesh», «Импорт 3D-моделей в Unity и подводные камни», «Пиксельные отступы в текстурной развертке».

Почти 2 года назад мы написали статью, в которой рассказали о варианте оптимизации 3D-геометрии в сцене с ограничениями на ракурс камеры и поворот соответствующих объектов. Не то чтобы много воды утекло с тех пор, однако возможность усовершенствовать решение, рассмотреть разные подходы и подглядеть за другими не дает покоя умам разработчиков. В этой статье мы опишем улучшенный вариант алгоритма, основанного на покраске полигонов, а также расскажем о попытках перенести часть такой работы в 3D-пакет.



Обрезка в сцене


Основной принцип этого алгоритма мы уже рассматривали в указанной выше статье: гасим все эффекты и прозрачные объекты, красим необрабатываемые полигоны одним цветом, а обрабатываемые — другими, рендерим, извлекаем результат. В старом варианте красили так, что все черное было лишним, а красным цветом метили только один треугольник.

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

В этом случае, как и в прошлый раз, предполагается некоторая предподготовка, связанная с отключением всех свистящих объектов на сцене и объектов, гарантированно не влияющих на видимость целевой модели. Ракурсы камеры обрабатываются почти независимо, они связаны лишь общим буфером индексов видимых полигонов. Помимо этого, для каждого ракурса проводится предобработка геометрии, в процессе которой удаляются полигоны, повернутые к камере обратной стороной (backface). Так делается, потому что на определенном этапе алгоритма создается временный меш со значительно большим количеством вершин, чем у исходного. Это число может запросто превысить порог в 65 535, что потребует дополнительных телодвижений при вычислениях и приведет к снижению производительности. Эти полигоны в любом случае удалятся, так как их цвет не попадет в кадр. Однако из-за того, что каждый треугольник потенциально родит до трех мусорных вершин, устранение лишних полигонов заранее облегчает основной этап алгоритма и снижает затраты памяти.

Пусть имеется некоторая 3D-модель, геометрия которой представлена мешем. Чтобы покрасить конкретный полигон в уникальный цвет, нужно покрасить все его вершины в этот цвет. Поскольку в общем случае одна вершина может принадлежать разным полигонам, решить проблему в лоб не получится. Как бы мы ни покрасили какую-либо вершину, при рендеринге ее цвет переползет на все треугольники, которые ей владеют, в соответствии с алгоритмом интерполяции на стороне видеокарты.


Пример интерполяции цвета при отображении полигонов с общими вершинами

Поэтому необходимо как-то получить разбиение меша на отдельные независимые полигоны, сохранив при этом топологию и геометрию объекта. Dictum factum. Преобразуем массивы треугольников и вершин таким образом, что для каждого треугольника будет создано 3 уникальных вершины, позиция которых определяется по соответствующим вершинам исходного меша. Стоит отметить, что в общем случае такой меш будет иметь ощутимо большее количество вершин в сравнении с оригиналом. И если это число превысило 65 535, то при создании меша необходимо указать соответствующий формат индексации.

Преобразование исходного меша в меш с уникальными для каждого полигона вершинами
private static Mesh GetNotSmoothMesh(Mesh origin)
        {
            var oVertices = origin.vertices;
            var oTriangles = origin.triangles;

            var vertices = new Vector3[oTriangles.Length];
            var triangles = new int[oTriangles.Length];

            for (int i = 0; i < triangles.Length; i++)
            {
                vertices[i] = oVertices[oTriangles[i]];
                triangles[i] = i;
            }
            
            return new Mesh()
            {
    indexFormat = vertices.Length > 65535 ? IndexFormat.UInt32 : IndexFormat.UInt16,
                vertices = vertices,
                triangles = triangles
            };
        }


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


Byte-Colored Mesh

Раскраска меша, в котором каждая вершина принадлежит только одному полигону
private static void ColorizePolygons(Mesh mesh)
        {
            var pColors = ColorsOfPolygons(mesh);
            var colors = new Color[mesh.vertexCount];
            for (int i = 0; i < colors.Length; i++)
            {
                colors[i] = pColors[i / 3];
            }
            mesh.colors = colors;
        }

        private static Color[] GetColorsOfPolygons(Mesh mesh)
        {
            var colors = new Color[mesh.triangles.Length / 3];
            for (int i = 0; i < colors.Length; i++)
            {
                var color = Int2Color(i);// здесь может быть любой метод преобразования числа в цвет, спаренный с обратным ему Color2Int
               // для наглядного скриншота мы использовали хеш, но в боевой реализации оптимальнее разбивать int на байты и генерировать Color32
                colors[i] = color;
            }

            return colors;
        }


Запоминаем раскраску. Настало время рендерить. Осуществляем 3D-рендеринг для всех ракурсов камеры и при обработке каждого из них пополняем буфер уникальных индексов полигонов, чьи цвета были обнаружены в кадре. На время вычислений для камеры нужно отключить сглаживание, чтобы избежать появления новых цветов из-за интерполяции соседних пикселей.

Считывание и накопление цветов с разных ракурсов камеры
// CameraTransform — кастомная структура для хранения данных о ракурсе камеры
        // поля структуры соответствуют реализации метода SetCameraTransform
        private static HashSet<Color> GetVisibleColors(Camera camera, CameraTransform[] cameraTransforms)
        {
            var renderTexture = new RenderTexture(1920, 1080, 24);//for example
            var rtRect = new Rect(0, 0, renderTexture.width, renderTexture.height);
            var frame = new Texture2D(renderTexture.width, renderTexture.height, TextureFormat.RGB24, false);// в случае огромного кол-ва полигонов RGB24 может не хватить, тогда стоит использовать RGBA32
            
            var visibleColorsSet = new HashSet<Color>();

            foreach (var cameraTransform in cameraTransforms)
            {
                SetCameraTransform(camera, cameraTransform);
                CreateScreenShot(camera, renderTexture, frame, rtRect);
                visibleColorsSet.UnionWith(GetTextureColors(frame));
            }

            return visibleColorsSet;
        }

        public static void SetCameraTransform(Camera camera, CameraTransform camTransform)
        {
            camera.transform.position = camTransform.Position;
            camera.transform.rotation = camTransform.Rotation;
            camera.fieldOfView = camTransform.FieldOfView;
            camera.orthographic = camTransform.IsOrthographic;
            camera.nearClipPlane = camTransform.NearClippingPlane;
            camera.farClipPlane = camTransform.FarClippingPlane;
        }
        
        private static HashSet<Color> GetTextureColors(Texture2D texture)
        {
            return new HashSet<Color>(texture.GetPixels());
        }
        
        private static void CreateScreenShot(Camera cam, RenderTexture renderTexture, Texture2D screenShot, Rect renderTextureRect)
        {
            cam.targetTexture = renderTexture;
            cam.Render();
            RenderTexture.active = cam.targetTexture;
            
            screenShot.ReadPixels(renderTextureRect, 0, 0);

            RenderTexture.active = null;
            cam.targetTexture = null;
        }
    }


Стоит упомянуть, что по причине дискретизации некоторые треугольники могут не отобразиться из-за особо малого размера их проекции на экран, а не потому, что их что-то перекрывает или они повернуты не той стороной. Мы реализовали консервативный вариант алгоритма. В этом случае вычисляется 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-развертки. Если же модель часто используется в разных положениях или имеет более сложную геометрию, то уже после импорта в проект мы запускаем описанный в первой части статьи алгоритм, обрабатывая им все статичные объекты в сцене.