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


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

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




К сожалению, не доступен сервер mySQL