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

Как сжать, но не пережать текстурные атласы в рантайме.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Берем пачку текстур;
  • Готовим лэйаут этих текстур внутри атласа — как вариант, можем воспользоваться методом Texture2D.GenerateAtlas;
  • Создаем RenderTexture в формате ARGB32;
  • Blit’им наши текстуры в атлас в соответствии с подготовленным лэйаутом;
  • Исправляем UV-координаты нашей комбинированной геометрии;
  • Получаем на выходе профит (ака собранный воедино персонаж).
Сжатые атласы в Unity Runtime

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

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

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

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

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

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

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

Runtime compression

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

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

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

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

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

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

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

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

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

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

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

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

Отличие (и, наверное, преимущество) 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<long> atlasData = atlas.GetRawTextureData<long>();

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

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

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

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); }

Тут Rect задает расположение отдельной текстуры на атласе. А MemoryRect — это сущность, которая отвечает за расчет всех смещений, размеров, отступов и шагов.

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

public static void CopyMemoryDataLinear(NativeArray<long> source, NativeArray<long> 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();

Код целиком:

public static Texture2D GenerateAtlas(Texture2D[] sources, TextureFormat format, Layout layout) { var atlas = new Texture2D(4096, 4096, format, mipChain: true, linear: false); NativeArray<long> atlasData = atlas.GetRawTextureData<long>(); for (int srcIndex = 0; srcIndex < sources.Length; ++srcIndex) { var source = sources[srcIndex]; NativeArray<long> sourceData = source.GetRawTextureData<long>(); 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<long> source, NativeArray<long> 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);

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

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

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

  • Мы получаем атласы более высокого разрешения;
  • Они занимают гораздо меньше места в памяти per pixel;
  • Мы избавляемся от артефактов двойной компрессии;
  • Отсутствует bleeding внутри мипов (а мог бы быть, если бы мипы создавались на основе уже готового атласа)

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

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

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

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

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

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

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

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

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

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

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

Итоги

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

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

169169
25 комментариев

Сума сойти, я что попал в 2007-ой? На DTF полезные проф статьи про разработку игр?

23
Ответить

Упаковать 4к текстуру в 10 мегов - моё почтение.

10
Ответить

Ну, если честно, то объему в 10Мб для 4к текстуры мы должны быть благодарны разработчикам из Imagination Technologies, авторам алгоритма PVRTC сжатия. Статья про то, как получить такую 4к текстуру в рантайме из нескольких меньшего размера.

1
Ответить

Наивный = нативный?

5
Ответить

Нет, именно наивный в значении «простой»

3
Ответить

Расскажите пожалуйста поподробнее об этом видео оптимизации, выходит что объединив модель персонажа на котором висит много Game Object'ов и текстур мы получаем одну 3д модель и 1 текстуру, или все так же несколько 3д моделей 1 одну текстуру что умещается в 1 draw call?

Ответить

Правильнее говорить не про GameObject'ы, а про Renderer'ы. Несколько SkinnedMeshRenderer'ов (висящих на разных GameObject'ах) превращаются в один SkinnedMeshRenderer, у которого в качестве меша используется подготовленный в рантайме меш. Этот меш представляет собой комбинацию всех исходных мешей, которые использовались в изначальных рендерерах. Для такого объединения мешей можно найти общее решение на просторах ассет стора, а может быть и остального интернета.

На выходе мы получаем один SkinnedMeshRenderer с одним комбинированным мешом, одним материалом и одним набором текстур (диффузка + нормалка). Иерархия GameObject'ов при этом остается, потому что они - суть кости скелета, к которому привязан скин. Но за счет того, что рендерер один, материал один, набор текстур один, то и рисуется всё в один drawcall.

1
Ответить