Удалить то, что скрыто: оптимизация 3D-сцен в мобильной игре. Советы сотрудников Plarium Krasnodar

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

    image

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

    В игре Terminator Genisys: Future War есть трехмерные миниатюры юнитов (люди, роботы, машины), которые можно осматривать с разных сторон с помощью камеры. Однако ее обзор ограничен программно, и определенные части моделей всегда остаются скрытыми от глаз пользователей. Значит, надо найти и удалить такие участки.

    Невидимые части делятся на две категории:

    1. Находящиеся сзади модели.
    2. Перекрытые другими частями.

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

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

    Для определения невидимых треугольников мы разработали простой алгоритм:

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

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

    4.2. Проходимся по всем изначально зафиксированным позициям и ракурсам камеры.

    4.2.1. В текущей позиции камеры делаем снимок сцены. Хорошее разрешение снимка сделает результат точнее, но замедлит процесс оптимизации. Мы использовали разрешение 4К.

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

    4.2.3. Если мы нашли красный пиксель, то дальнейшую проверку для других ракурсов и позиций камеры можно не проводить. Проверяем следующий треугольник, возвращаясь к шагу 4.1.

    4.2.4. Переходим к следующей позиции камеры и к шагу 4.2.1.

    4.3. Если мы прошли все шаги и оказались здесь, значит мы не нашли красный цвет ни на одном из выполненных снимков. Треугольник можно удалить и идти к шагу 4.1.

         5. Profit! Мы оптимизировали один из объектов. Можно переходить к шагу 4 для других объектов.

         6. Сцена оптимизирована.

    public class MeshData
    {
        public Camera Camera;
        public List<int> Polygons;
        public MeshFilter Filter;
        public MeshFilter PolygonFilter;
        public float ScreenWidth;
        public float ScreenHeight;
        public RenderTexture RenderTexture;
        public Texture2D ScreenShot;
    }
    
    public class RenderTextureMeshCutter
    {
        // .....................
        
        // Точка входа
        // Убираем из списка видимые полигоны, таким образом они не будут удалены впоследствии
        public static void SaveVisiblePolygons(MeshData data)
        {
            var polygonsCount = data.Polygons.Count;
    
            for (int i = polygonsCount - 1; i >= 0; i--)
            {
                var polygonId = data.Polygons[i];
                var worldVertices = GetPolygonWorldPositions(polygonId, data.PolygonFilter);
                var screenVertices = GetScreenVertices(worldVertices, data.Camera);
                screenVertices = ClampScreenCordinatesInViewPort(screenVertices, data.ScreenWidth, data.ScreenHeight);
    
                var gui0 = ConvertScreenToGui(screenVertices[0], data.ScreenHeight);
                var gui1 = ConvertScreenToGui(screenVertices[1], data.ScreenHeight);
                var gui2 = ConvertScreenToGui(screenVertices[2], data.ScreenHeight);
                var guiVertices = new[] { gui0, gui1, gui2 };
    
                var renderTextureRect = GetPolygonRect(guiVertices);
                if (renderTextureRect.width == 0 || renderTextureRect.height == 0) continue;
    
                var oldTriangles = data.Filter.sharedMesh.triangles;
                RemoveTrianglesOfPolygon(polygonId, data.Filter);
    
                var tex = GetTexture2DFromRenderTexture(renderTextureRect, data);
    
                // Если полигон виден (найден красный пиксель), то удаляем его из списка полигонов, которые необходимо удалить
                if (ThereIsPixelOfAColor(tex, renderTextureRect))
                {
                    data.Polygons.RemoveAt(i);
                }
    
                // Возвращаем проверяемый меш к исходному состоянию
                data.Filter.sharedMesh.triangles = oldTriangles;
            }
        }
    
        // Обрезаем координаты, чтобы не залезть за пределы рендер текстуры
        private static Vector3[] ClampScreenCordinatesInViewPort(Vector3[] screenPositions, float screenWidth, float screenHeight)
        {
            var len = screenPositions.Length;
            for (int i = 0; i < len; i++)
            {
                if (screenPositions[i].x < 0)
                {
                    screenPositions[i].x = 0;
                }
                else if (screenPositions[i].x >= screenWidth)
                {
                    screenPositions[i].x = screenWidth - 1;
                }
    
                if (screenPositions[i].y < 0)
                {
                    screenPositions[i].y = 0;
                }
                else if (screenPositions[i].y >= screenHeight)
                {
                    screenPositions[i].y = screenHeight - 1;
                }
            }
    
            return screenPositions;
        }
    
        // Возвращаем мировые координаты
        private static Vector3[] GetPolygonWorldPositions(MeshFilter filter, int polygonId, MeshFilter polygonFilter)
        {
            var sharedMesh = filter.sharedMesh;
            var meshTransform = filter.transform;
            polygonFilter.transform.position = meshTransform.position;
    
            var triangles = sharedMesh.triangles;
            var vertices = sharedMesh.vertices;
    
            var index = polygonId * 3;
    
            var localV0Pos = vertices[triangles[index]];
            var localV1Pos = vertices[triangles[index + 1]];
            var localV2Pos = vertices[triangles[index + 2]];
    
            var vertex0 = meshTransform.TransformPoint(localV0Pos);
            var vertex1 = meshTransform.TransformPoint(localV1Pos);
            var vertex2 = meshTransform.TransformPoint(localV2Pos);
    
            return new[] { vertex0, vertex1, vertex2 };
        }
    
        // Находим красный полигон
        private static bool ThereIsPixelOfAColor(Texture2D tex, Rect rect)
        {
            var width = (int)rect.width;
            var height = (int)rect.height;
    
            // Пиксели берутся из левого нижнего угла
            var pixels = tex.GetPixels(0, 0, width, height, 0);
            var len = pixels.Length;
    
            for (int i = 0; i < len; i += 1)
            {
                var pixel = pixels[i];
                if (pixel.r > 0f && pixel.g == 0 && pixel.b == 0 && pixel.a == 1) return true;
            }
    
            return false;
        }
    
        // Получаем фрагмент рендер текстуры по ректу
        private static Texture2D GetTexture2DFromRenderTexture(Rect renderTextureRect, MeshData data)
        {
            data.Camera.targetTexture = data.RenderTexture;
            data.Camera.Render();
            RenderTexture.active = data.Camera.targetTexture;
    
            data.ScreenShot.ReadPixels(renderTextureRect, 0, 0);
    
            RenderTexture.active = null;
            data.Camera.targetTexture = null;
    
            return data.ScreenShot;
        }
    
        // Удаляем треугольник с индексом polygonId из списка triangles
        private static void RemoveTrianglesOfPolygon(int polygonId, MeshFilter filter)
        {
            var newTriangles = new int[triangles.Length - 3];
            var len = triangles.Length;
    
            var k = 0;
            for (int i = 0; i < len; i++)
            {
                var curPolygonId = i / 3;
                if (curPolygonId == polygonId) continue;
    
                newTriangles[k] = triangles[i];
                k++;
            }
    
            filter.sharedMesh.triangles = newTriangles;
        }
    
        // Переводим мировые в экранные координаты
        private static Vector3[] GetScreenVertices(Vector3[] worldVertices, Camera cam)
        {
            var scr0 = cam.WorldToScreenPoint(worldVertices[0]);
            var scr1 = cam.WorldToScreenPoint(worldVertices[1]);
            var scr2 = cam.WorldToScreenPoint(worldVertices[2]);
            return new[] { scr0, scr1, scr2 };
        }
    
        // Переводим экранные в Gui координаты
        private static Vector2 ConvertScreenToGui(Vector3 pos, float screenHeight)
        {
            return new Vector2(pos.x, screenHeight - pos.y);
        }
    
        // Вычисляем прямоугольник в Gui координатах
        private static Rect GetPolygonRect(Vector2[] guiVertices)
        {
            var minX = guiVertices.Min(v => v.x);
            var maxX = guiVertices.Max(v => v.x);
    
            var minY = guiVertices.Min(v => v.y);
            var maxY = guiVertices.Max(v => v.y);
    
            var width = Mathf.CeilToInt(maxX - minX);
            var height = Mathf.CeilToInt(maxY - minY);
    
            return new Rect(minX, minY, width, height);
        }
    }
    

    image

    Мы решили не останавливаться на обрезке геометрии и попробовали сэкономить свободное текстурное пространство. Для этого вернули оптимизированные модели юнитов моделлерам, и они пересоздали текстурные развертки в 3D-пакете. Затем модели с новыми текстурами мы добавили в проект. Осталось только заново просчитать освещение в сцене.

    image

    С помощью созданного алгоритма нам удалось:

    • Уменьшить число вершин и треугольников модели без потери качества → Снизилась нагрузка на видеоадаптер. Также шейдеры будут выполняться меньшее количество раз.
    • Сократить площадь объекта в карте освещения и сэкономить текстуру для некоторых моделей за счет образовавшейся пустой области → Уменьшился размер приложения и снизилось потребление видеопамяти.
    • Использовать большую плотность пикселей на модели (в отдельных случаях) → Улучшилась детализация.

    В результате в моделях нам удалось убрать до 50% полигонов и уменьшить текстуры на 10–20%. Для оптимизации каждой сцены, состоящей из нескольких объектов, потребовалось от трех до пяти минут.

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

    Комментарии 26

      0
      А если дизайнеру потом немного захочется повернуть камеру по-другому — контент опять придется переделывать? Очень странное решение. Удалять можно только то, что гарантированно не будет видно при любом ракурсе камеры, все остальное — должно быть на месте во избежание.
        0
        Вы правы. Если меняются настройки камеры, нужно заново перепроверить треугольники на видимость/невидимость. Поэтому оптимизацию нужно проводить после того, как сцена полностью готова и утверждена с визуальной точки зрения.
        «Удалять можно только то, что гарантированно не будет видно при любом ракурсе камеры» – именно это и позволяет сделать приведенный в статье способ. Сначала вы определяетесь с ракурсами камеры и расположением объектов, потом оптимизируете. Для наших сцен с юнитами есть ограничения на возможные положения камеры. Угол обзора, приближение и отдаление зафиксированы и меняются в определенных диапазонах. Объекты статичны, не меняют своих позиций и не вращаются. Если же камера полностью свободна, ее движение ничем не ограниченно, или же модель может поворачиваться под любым углом, то, конечно же, в кадр сможет попасть любая часть модели. И обрезать ничего нельзя.
          0
          именно это и позволяет сделать приведенный в статье способ.

          Так как раз нет. Дизайнеру захочется поменять циферку (а ничего финального не бывает, особенно у артистически-одаренных людей, они могут крутить циферки хоть для каждого билда) — придется пересобирать весь контент — плохое решение. Точнее, плохое как решение общего плана — в данном конкретном случае оно оказалось выгодным.
            0
            А это уже зависит от того, насколько правильно выстроены процессы согласования и утверждения контента. В любом процессе есть этап творчества, мозговых штурмов и генерации идей. Но когда основное направление выбрано, цена внесения изменений резко возрастает. Гибкость и производительность зачастую находятся на разных чашах весов.
        –1

        Но разве этим стандартные механизмы culling-а движка не занимаются? Отсечение невидимых вершин — то, что базово умеют все игровые движки уже лет 10.
        Интересно было бы увидеть сравнение производительности с и без Вашего механизма.
        P.S. Для Unity есть гораздо более эффективный способ оптимизации — отключать скрипты невидимым игроку объектам. Когда объект не в кадре — он не рисуется, но его физика работает, коллизии считаются, скрипты действуют, и всë это бодро кушает ресурсы. Большие движки раньше умели это отключать, а вот Unity не умеет. Я написал крохотный оптимизатор, который это всë делает (можно выборочно и при определëнных условиях). В нагруженных сценах даëт прирост до 20%. Могу поделиться наработками по этому методу, если интересно :)

          0
          Вообще-то, нет. Даже OcclusionCulling в Юнити не отсекает полигоны — только целые меши. Те полигоны которые отсекли на роботе и так не отрисуются по нормали (Back Face Culling). Но дело здесь не в отрисовке, а в памяти, занимаемой вершинами. А если узкая шина памяти будет заполнена — упадет fps.
            0

            Backface culling я и имел в виду. Если модели сделаны по уму, импортированы верно, и полигоны не double-sided, то дальние заслонëнные грани объекта будут отброшены и не нарисуются. Это копеечная по трудоëмкости операция, и Юнити это делает, да.
            Про память — разговор особый. По статье просто так читается, что именно отрисовка и оптимизируется. Про загрузку шины памяти — вот это надо обдумать. Велик ли импакт на рядовых мобильных моделях?

              0
              Из статьи:
              Невидимые части делятся на две категории:
              1 Находящиеся сзади модели.

              Полагаю что это и есть полигоны для Backface culling, они отбрасываются не потому что чем-то заслонены, а просто по ориентации, т.к. рисуются только с одной стороны.
            0

            конечно же интересно, зановоо изобретать велосипед интересно, но как всегда времени не хватает

            0
            Что мешало просто отрендерить меш заливая полигоны порядковым номером вместо цвета, а потом пробежаться по результату рендеринга и собрать номера видимых полигонов?
              0
              Myzrael GCU
              Обращаем ваше внимание, что не вся сцена рендерится целиком, а лишь определенный ее фрагмент, который содержит проверяемый треугольник. Ваш метод тоже интересный, но из-за более сложного алгоритма восстановления индекса треугольника из цвета и времени, затраченного на отрисовку нескольких ракурсов и так видимых частей, производительность будет сопоставима.
              Предположим, треугольник на третьем из 20-ти ракурсов оказался видимым, тогда его больше проверять не нужно. А в предлагаемой вами реализации на каждом ракурсе вы все равно будете рендерить даже те треугольники, которые уже не нужно проверять.

                0
                Нет разницы, рендерить 1000 треугольников или 1. Вы больше на DIP потеряете, чем карточка это рисовать будет. Я видимо несколько не понял вашу задачу. Я предполагал, что у вас есть несколько фиксированных ракурсов отображения моделей и вам нужно для КАЖДОГО ракурса вычислить список видимых полигонов, чтобы рисовать только их для данного ракурса. Я что-то не так понял? Я вообще думал, что это препроцессинг и тут скорость не так важна, зато простота решения должна иметь значение.
                  0
                  В моём понимании рендерить видимые части всё равно приходится, чтобы проверить — перекрывают ли они красный треугольник, разве не так?
                    0
                    По времени непосредственно на отрисовку — один рендер на ракурс всего. В пиксельный шейдер прокидывается индекс треугольника и по нему выставляется цвет фрагмента, меш рисуется целиком.
                    0
                    Может то что нет такого понятия как «полигон», есть вершины объединенные индексами.
                  0
                  Приведённый алгоритм выглядит не очень эффективным — для одного треугольника делать рендеринги со всех позиций?
                  Для каждого треугольника можно писать в буфер цвета его индекс, чтобы потом из картинки найти полный набор отображаемых треугольников. Дополнив этот набор со всех позиций — убрать те треугольники, индексы которых не встречались.

                    0
                    «Части первой категории обработать достаточно легко, используя стандартный способ удаления невидимых треугольников.»

                    Что за чушь?
                    никакого прироста FPS не приносит. Движок и так не тратит ресурсы на отрисовку невидимой геометрии, даже если она находится в пределах видимости камеры.
                      0
                      Производительность GPU зависит не только от количества треугольников в кадре. Важно количество и размер текстур в памяти и количество прочитанных пикселей с этих текстур, которые потребовалось сделать для отрисовки кадра. Обрезая невидимые ни при каких условиях треугольники мы, во-первых, уменьшаем размер карт освещения и время их просчета, во-вторых, можем более эффективно использовать пространство основной текстуры.
                        0
                        А как вы учитываете влияние невидимых треугольников на карту освещения?
                        По логике они являются источником отражений света, но в самой карте не нужны.
                          0
                          Суть как раз в том, чтобы избавиться от этих невидимых треугольников, и не запекать их в карте освещения. Соответственно, экономится текстурное пространство. А за счет уменьшения площади поверхности объектов экономится время просчета. Единственный нюанс – это корректность запекания освещения, а именно теней от объектов. Если сразу удалить невидимые треугольники и запечь сцену, оптимизированные объекты будут отбрасывать некорректные тени. Поэтому невидимые треугольники объединяются в отдельный меш, этому мешу выставляется нулевой масштаб в карте освещения (свет НА него не запекается), но при этом он отбрасывает тень. Проводится запекание, и только потом меш удаляется.
                      0
                      А чем не понравился вариант скалярного произведения векторов?
                        0
                        Вы имеете в виду произведение нормали треугольника и направления взгляда? Тем, что этот вариант подходит для удаления частей меша, которые находятся сзади модели (не «смотрят» в камеру). А вот перекрытые другими частями и объектами области так не найти.
                          0
                          Понял когда уже нажал отправить. Эту проблему я решил с помощью MeshCollider и рэйкастов. Сначала отсекаю невидимый меш с помощью произведения нормали треугольника и направления взгляда, после выбрасываю мусорный меш при помощи рэйкастов. Если длина вектора разницы позиции вертекса и точки в которую пришелся рейкаст больше определенной длины — я считаю точку невидимой. Если не один из вертексов не виден, и рейкаст в центр треугольника тоже не увенчался успехом — треугольник так же считается невидимым.

                          Точность конечно немного меньше чем точность вашего алгоритма — но у него есть свои плюсы. Процессинг выполняется очень быстро. И доступен предпросмотр в рантайме.
                          Вот так выглядит результат работы алгоритма:

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

                      Самое читаемое