Сжатые атласы в Unity Runtime

cefcb10873d63b329c2e547a3968c1c1.jpeg

Привет, меня зовут Юрий Грачев, я программист из студии Whalekit — автора зомби-шутера Left to Survive и мобильного PvP-шутера Warface: Global Operations. Кстати, именно о его технологиях мы и поговорим подробнее далее.

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

В паре слов о проекте

Как я уже говорил, речь пойдет о Warface: GO. Это командный экшен-шутер, кор-геймплей которого — PvP-сражения 4-на-4 игрока. 

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

В итоге мы получаем, что каждый персонаж в игре рисуется с использованием минимум 18 drawcall«ов, из которых 9 уходит на основной кадр и 9 — на отрисовку shadow maps. В сумме мы получаем аж 144 drawcall«ов — и это только на персонажей!

А вот так в игре выглядят персонаж и его экипировка до и после ее смены:

image-loader.svgimage-loader.svg

Атласы: что это такое и зачем они нужны

Так как мы поддерживаем iPhone 6, а на старте разработки замахивались даже на 5s, нам было важно избавляться от такого количества drawcall«ов. Обычно слабые девайсы на наших проектах упираются именно в CPU, который ставит эти самые drawcall«ы в очередь команд, а не в GPU, который затем их выполняет. 

Чтобы снизить количество drawcall«ов, мы вручную объединяем в один меш геометрию элементов экипировки, из которых состоит наш персонаж. И чтобы это имело смысл, нужно объединить не только геометрию, но и текстуры, чтобы впоследствии можно было использовать один материал с одним комплектом текстур.

Тут на помощь и приходят атласы: без них, даже объединив геометрию, мы будем все еще вынуждены рисовать элементы экипировки отдельными drawcall«ами, между которыми будет происходить переключение текстур. Чаще всего атласы можно встретить созданными художниками вручную при подготовке статического контента —, но мы-то хотим делать это в рантайме, ведь персонаж у нас собирается динамически самим игроком из предопределенных элементов.

Есть, конечно, и альтернативные подходы. Например, можно здесь почитать про использование текстурных массивов, которые позволяют сразу несколько однотипных текстур добавить в один материал.

Начиная работу с атласами, нужно иметь в виду требования и ограничения, предъявляемые к исходным текстурам:

  • Мы вынуждены доставлять исходные текстуры на девайс пользователя обязательно в сжатом виде, иначе они будут занимать слишком много места в памяти конечного устройства;

  • Если на модели есть два текстурных слота, которые адресуются одним и тем же набором UV-координат, нужно позаботиться о том, чтобы пропорции этих текстур были одинаковыми, иначе один из атласов может неправильно собраться и/или не соответствовать второму, а отдельная текстура при апскейлинге или даунскейлинге будет отличаться по качеству.

74f0379ddded68e845b60a939d236279.jpeg

Немаловажно вспомнить и про color bleeding. Каждый раз, когда мы собираем атлас, мы вынуждены с ним бороться. Ниже показан пример с включенной и выключенной билинейной фильтрацией:

1612058c8acb739e6020dcc3939ba531.jpeg

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

image-loader.svg

Наивная реализация текстурного атласа

Теперь, когда мы разобрались с исходными текстурами, давайте попробуем их объединить и рассмотрим самый простой метод, как это сделать:

  • Берем пачку текстур;

  • Готовим лэйаут этих текстур внутри атласа — как вариант, можем воспользоваться методом Texture2D.GenerateAtlas;

  • Создаем RenderTexture в формате ARGB32;

  • Blit’им наши текстуры в атлас в соответствии с подготовленным лэйаутом;

  • Исправляем UV-координаты нашей комбинированной геометрии;

  • Получаем на выходе профит (ака собранный воедино персонаж).

image-loader.svg

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

Чтобы показать наглядно, как это влияет на показатели, я запустил тестовую сцену с комбинированием и без комбинирования и получил следующие результаты:

802f64a380283879b11c6f657e3e3274.jpeg

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

Получившаяся рендер-текстура формата ARGB32 занимает много памяти (ОЧЕНЬ много памяти). Можно, конечно, снизить разрешение, тогда она будет занимать меньше памяти, но и детали изображения мы потеряем. Зато такая текстура может быть любых пропорций и размера, имеет широкую поддержку и работает везде.

Стоит учесть, что не все текстуры можно собрать таким методом в атлас. Могут возникнуть проблемы при попытке объединения исходных текстур с закодированными в цвет данными. Реинтерпретация цвета наверняка приведет к невозможности декодировать данные обратно. Зато та же реинтерпретация цвета позволяет blit«ить в атлас исходные текстуры любого формата. То есть, можно добавлять атлас разнородные текстуры.

И все-таки проблема занимаемого объема памяти таким атласом и связь этого объема с разрешением перевешивает абсолютно все, что может быть сказано после этого. Так что, поняв, что такой результат нас не очень-то устраивает, мы стали думать, какие еще варианты у нас есть. И первая очевидная мысль, которая нас посетила — попробовать runtime compression.

Runtime compression

Первым делом мы нашли на просторах GitHub библиотеку под названием Unity.PVRTC и немного поэкспериментировали с ней. Библиотека заработала сразу из коробки, но очень медленно. По исходному коду сразу было видно, что она очень сырая. Нам пришлось достаточно сильно ее переписать, применяя даже Burst и Unity Jobs. Как результат, мы снизили время компрессии с 4 с до 220 мс для одной 2K-текстуры на iPhone 6. 

Как ни странно, этого было все еще недостаточно. Продюсеры были недовольны тем, что, применяя ARGB32-атласы и эту рантайм компрессию, мы увеличивали суммарное время старта миссии на несколько секунд, что плохо влияло на UX. Более того, мы планировали поддержку Player backfill — это когда новый игрок может присоединиться к уже начавшейся игровой сессии. Фича требовала выполнения такой же компрессии в середине игровой сессии на каждом пользовательском устройстве для смены «отвалившегося» персонажа на нового.

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

67244994106db263274a1defb81f7737.jpeg

Повертев эту библиотеку, мы продолжили искать способы получения нормального атласа и подумали:, а что, если попробовать рассмотреть алгоритмы сжатия с другой стороны? Родилась идея изучить подробнее подноготную разных алгоритмов сжатия: ASTC, PVRTC, ETC, BC (DXT). Мы надеялись найти какие-то подсказки, как нам реализовать сжатие в рантайме более эффективно. И мы нашли.

Эти разные алгоритмы сжатия

Все перечисленные выше форматы — ASTC, PVRTC, ETC, BC (DXT) — работают с блоками пикселей или с пакетами. Каждый такой блок кодируется в один или два 64-битных числа (long/int64), при этом все блоки в памяти лежат линейно и построчно для всех форматов, кроме PVRTC, в котором используется Z-order (кривая Мортона). MIP«ы во всех форматах (включая PVRTC) тоже лежат линейно от самой большой текстуры к самой маленькой.

На примере DXT1/BC1 рассмотрим, что представляет из себя блок пикселей:

28944dd2266c2b632982b2488cf4b564.jpeg

Изображение делится на одинаковые квадратики размером 4×4 пикселей, после чего из этих 16 пикселей выбираются два опорных цвета, и каждый кодируется в 16 бит. В дополнение к этим двум опорным цветам строится матрица индексов, которая позволяет получить из них все 16 пикселей с некоторым приближением.

Как я уже говорил, эти блоки лежат либо линейно, либо в Z-последовательности следующим образом:

74ecfa758ffa2332f81883e381b7305a.jpeg

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

Вооружившись этим багажом знаний, мы предприняли попытку собрать атлас из таких блоков, просто перекладывая их в памяти. Блочная природа этих данных и независимость блоков друг от друга сыграли нам на руку: такими блоками можно жонглировать, читая их как обычные long«и (или пары long«ов).

Наша реализация PVRTC-атласа

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

  • Во-первых, текстуры должны быть квадратными и в степени двойки в виду того, что алгоритм лэйаута у нас довольно хитрый, да и сама Unity не делает MIP уровней, если текстура не в степени 2.

  • Во-вторых, у всех исходных текстур должны быть одинаковые настройки импорта. Это продиктовано тем, что объединять блоки в атласе таким образом можно только с учетом однородности входящих данных.

  • В-третьих, мы поддержали только ASTC блоки размеров 4×4 и 8×8. Тут сыграл не последнюю роль наш алгоритм расположения текстур в атласе. Но на самом деле основной причиной было нежелание бороться со всякими бортиками. Ведь текстура степени двойки при использовании ASTC 10×10, например, нацело не делится на размер блока. В итоге по краю текстуры остаются ASTC блоки, заполненные релевантными данными лишь частично. С ними как раз и непонятно, что делать. В идеале надо было пережимать текстуры, от чего мы как раз пытались уйти.

  • И последнее — включение Read/Write Enabled галочки в импортере всех исходных текстур, чтобы мы могли получить доступ к пикселям на стороне CPU.

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

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

public static Texture2D GenerateAtlas(Texture2D[] sources, TextureFormat format, Layout layout)
    {
        var atlas = new Texture2D(4096, 4096, format, mipChain: true, linear: false);

Хочется отметить, что тут создается именно Texture2D, а не RenderTexture, как в случае наивной реализации.

Затем мы получаем доступ к области памяти с пикселями этой текстуры через обобщенный метод GetRawTexturedata, типизируя как long:

NativeArray atlasData = atlas.GetRawTextureData();

Теперь можно в этот массив писать блоки. Мы перебираем все наши исходные текстуры и получаем ссылки на соответствующие массивы блоков:

for (int srcIndex = 0; srcIndex < sources.Length; ++srcIndex)
        {
            var source = sources[srcIndex];
            NativeArray sourceData = source.GetRawTextureData();

Производим расчеты смещений и копируем блоки исходных текстур в массив блоков нашего атласа:

Rect sourceRect = layout.GetRect(srcIndex);

            for (int mip = 0; mip < source.mipmapCount; ++mip)
            {
                MemoryRect memRect = GetMemoryRect(format, 4096, 4096, sourceRect, source.width, source.height, mip);
                CopyMemoryData(sourceData, atlasData, format, memRect);
            }
        }

Для примера — при линейном расположении блоков функция может выглядеть так:

    public static void CopyMemoryDataLinear(NativeArray source, NativeArray destination, MemoryRect memRect)
    {
        for (int y = 0; y < memRect.blocksY; ++y)
        for (int x = 0; x < memRect.blocksX; ++x)
        {
            int srcOffset = memRect.GetSliceOffsetSrc(x, y);
            int dstOffset = memRect.GetSliceOffsetDst(x, y);
            destination[dstOffset] = source[srcOffset];
        }
    }

В конце обязательно вызываем метод Apply, который применит загруженные данные на стороне графического API:

        atlas.Apply();
        return atlas;
    }

Код целиком

public static Texture2D GenerateAtlas(Texture2D[] sources, TextureFormat format, Layout layout)
    {
        var atlas = new Texture2D(4096, 4096, format, mipChain: true, linear: false);
        NativeArray atlasData = atlas.GetRawTextureData();

        for (int srcIndex = 0; srcIndex < sources.Length; ++srcIndex)
        {
            var source = sources[srcIndex];
            NativeArray sourceData = source.GetRawTextureData();

            Rect sourceRect = layout.GetRect(srcIndex);

            for (int mip = 0; mip < source.mipmapCount; ++mip)
            {
                MemoryRect memRect = GetMemoryRect(format, 4096, 4096, sourceRect, source.width, source.height, mip);
                CopyMemoryData(sourceData, atlasData, format, memRect);
            }
        }

        atlas.Apply();
        return atlas;
    }

    public static void CopyMemoryDataLinear(NativeArray source, NativeArray destination, MemoryRect memRect)
    {
        for (int y = 0; y < memRect.blocksY; ++y)
        for (int x = 0; x < memRect.blocksX; ++x)
        {
            int srcOffset = memRect.GetSliceOffsetSrc(x, y);
            int dstOffset = memRect.GetSliceOffsetDst(x, y);
            destination[dstOffset] = source[srcOffset];
        }
    }

Если вы точно знаете, что больше в атлас никакая текстура не уместится, или вы логически завершили добавление текстур в этот атлас, то лучше вызывать метод Apply с дополнительным параметром:

atlas.Apply(false, makeNoLongerReadable: true);
return atlas;
}

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

В результате выполнения данного кода на выходе мы получаем объединенную Texture2D того же формата, что и исходные текстуры.

Из плюсов данного решения можно отметить следующее:

  • Мы получаем атласы более высокого разрешения;

  • Они занимают гораздо меньше места в памяти per pixel;

  • Мы избавляемся от артефактов двойной компрессии;

  • Отсутствует bleeding внутри мипов (а мог бы быть, если бы мипы создавались на основе уже готового атласа)

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

А теперь рассмотрим наглядно разницу двух получающихся при разных подходах атласов:

861d623bbff49e3c16a788cf58089da6.jpeg

Наш атлас имеет разрешение в 4K в силу того, что исходные текстуры персонажа не влезали в 2К. Видно, что он весит чуть больше «наивного» ARGB32 атласа, но это большое разрешение по итогу играет нам на руку, о чем я еще расскажу подробнее позже. Тут можно оценить пропорцию разрешений, чтобы понять потенциальную разницу в качестве.

Мы можем убедиться в правильности подхода, сравнив наш новый вариант атласа с наивной реализацией:

4feb0f7dd13d03a833f3db67e8026722.jpegf7b2e04534a535ca974812740b7b5351.jpeg

И еще кое-что…

Поскольку размер атласа в 4K и большое количество пустого места в нем нам это позволяли, мы попробовали объединить в один атлас сразу несколько персонажей и сделали страничную имплементацию.

Для этого мы сначала собираем со всех персонажей все текстуры, которые нужно добавить в атлас. Затем разделяем эти текстуры на группы, каждая из которых должна уместиться в одну 4K текстуру. При этом необходимо соблюдать простое правило: каждый персонаж должен попадать целиком на одну страницу, иначе полностью переносим его на новую. При таком подходе повторяющиеся текстуры можно переиспользовать, если остальные текстуры персонажей находятся на той же странице.

По нашим подсчетам, таким образом мы должны были получать не больше 3–4 страниц атласа в худшем случае, но реальность даже превзошла наши ожидания: на деле мы никогда не видели больше двух страниц для всех персонажей на сцене.

613502484bb008f9817cf27f786bd8cc.jpeg

Итоги

Что нам дал такой механизм объединения текстур в атласы?

Рассмотрим на примере PVRTC/iOS. Суммарный объем памяти, который занимают наши атласы — 21 MB против прежних 46 MB для атласов в формате ARGB32. Время на генерацию двух PVRTC страниц сократилось до 70 мс вместо 8×220 мс времени, затраченного только на компрессию (без учета подготовки ARGB32 рендер текстуры). Текстуры стали большего разрешения, теперь они не пережимаются никакой двойной компрессией, и появилась возможность их переиспользовать — то есть, избавиться от части дубликатов в видео-памяти.

© Habrahabr.ru