Уже на начальном этапе создания мобильных игр следует учитывать, что детализированные модели сильно нагружают портативное устройство, а это ведет к падению частоты кадров, особенно на слабых девайсах. Как экономно использовать ресурсы трехмерных моделей без потери визуального качества? Под катом — решение, найденное специалистами краснодарской студии 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%. Для оптимизации каждой сцены, состоящей из нескольких объектов, потребовалось от трех до пяти минут.

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

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


  1. Leopotam
    08.02.2018 10:13

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


    1. Plarium Автор
      08.02.2018 12:02

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


      1. Leopotam
        08.02.2018 12:38

        именно это и позволяет сделать приведенный в статье способ.

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


        1. Plarium Автор
          08.02.2018 14:38

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


  1. Wolf4D
    08.02.2018 10:29

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


    1. Tutanhomon
      08.02.2018 10:57

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


      1. Wolf4D
        08.02.2018 13:03

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


        1. GCU
          08.02.2018 18:03

          Из статьи:

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

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


    1. andrewdrone
      08.02.2018 11:07

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


  1. Myzrael
    08.02.2018 10:55

    Что мешало просто отрендерить меш заливая полигоны порядковым номером вместо цвета, а потом пробежаться по результату рендеринга и собрать номера видимых полигонов?


    1. Plarium Автор
      08.02.2018 10:59

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


      1. Myzrael
        08.02.2018 11:12

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


      1. GCU
        08.02.2018 11:13

        В моём понимании рендерить видимые части всё равно приходится, чтобы проверить — перекрывают ли они красный треугольник, разве не так?


      1. GCU
        08.02.2018 11:24

        По времени непосредственно на отрисовку — один рендер на ракурс всего. В пиксельный шейдер прокидывается индекс треугольника и по нему выставляется цвет фрагмента, меш рисуется целиком.


    1. Tutanhomon
      08.02.2018 11:03

      Может то что нет такого понятия как «полигон», есть вершины объединенные индексами.


      1. Myzrael
        08.02.2018 11:04

        вы плохо знаете GAPI :)


        1. Tutanhomon
          08.02.2018 11:05

          А может быть вы? (С) :)



  1. GCU
    08.02.2018 10:55

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


  1. AnriCZEU
    08.02.2018 10:55

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

    Что за чушь?
    никакого прироста FPS не приносит. Движок и так не тратит ресурсы на отрисовку невидимой геометрии, даже если она находится в пределах видимости камеры.


    1. Plarium Автор
      08.02.2018 10:56

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


      1. GCU
        08.02.2018 12:03

        А как вы учитываете влияние невидимых треугольников на карту освещения?
        По логике они являются источником отражений света, но в самой карте не нужны.


        1. Plarium Автор
          08.02.2018 13:02

          Суть как раз в том, чтобы избавиться от этих невидимых треугольников, и не запекать их в карте освещения. Соответственно, экономится текстурное пространство. А за счет уменьшения площади поверхности объектов экономится время просчета. Единственный нюанс – это корректность запекания освещения, а именно теней от объектов. Если сразу удалить невидимые треугольники и запечь сцену, оптимизированные объекты будут отбрасывать некорректные тени. Поэтому невидимые треугольники объединяются в отдельный меш, этому мешу выставляется нулевой масштаб в карте освещения (свет НА него не запекается), но при этом он отбрасывает тень. Проводится запекание, и только потом меш удаляется.


  1. barrettdesign
    09.02.2018 14:30

    А чем не понравился вариант скалярного произведения векторов?


    1. Plarium Автор
      09.02.2018 14:31

      Вы имеете в виду произведение нормали треугольника и направления взгляда? Тем, что этот вариант подходит для удаления частей меша, которые находятся сзади модели (не «смотрят» в камеру). А вот перекрытые другими частями и объектами области так не найти.


      1. barrettdesign
        09.02.2018 21:07

        Понял когда уже нажал отправить. Эту проблему я решил с помощью MeshCollider и рэйкастов. Сначала отсекаю невидимый меш с помощью произведения нормали треугольника и направления взгляда, после выбрасываю мусорный меш при помощи рэйкастов. Если длина вектора разницы позиции вертекса и точки в которую пришелся рейкаст больше определенной длины — я считаю точку невидимой. Если не один из вертексов не виден, и рейкаст в центр треугольника тоже не увенчался успехом — треугольник так же считается невидимым.

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