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

Раньше на проекте  War Robots у нас был устоявшийся и вполне рабочий пайплайн по импорту текстурных массивов, на выходе которого мы получали массивы в конечном формате (ASTC, ETC2), отлично удовлетворяющие нашим требованиям для мобильных платформ. С этим все у нас было хорошо — до поры. Проблемы начались тогда, когда возникла необходимость релиза на ПК. 

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


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

Текстурные массивы (Texture2DArray в Unity) — это ассет-файлы, в которых записано несколько бинарных представлений текстур, уже пожатых в необходимом для конечного девайса формате. Такие массивы позволяют объединить несколько текстур в один ресурс, который можно единожды привязать к графичеcкому пайплайну (binding — в терминах графических API), и затем множество вызовов отрисовки смогут использовать этот ресурс без необходимости перепривязки между вызовами. Это снижает нагрузку на ЦПУ, поскольку уменьшает количество обращений к графическому API, что в итоге положительно сказывается на производительности. В Unity текстурные массивы позволяют использовать один материал для множества объектов, а значит — при рендере будет работать batching, и их отрисовка будет более эффективной. 

Текстурные массивы можно рассматривать как альтернативу текстурным атласам, лишенной их недостатков. Так, при тайлинге использование текстурного пространства становится гораздо эффективнее. Поскольку не нужно добавлять «защитные» области, с помощью закраски (dilation) или дополнительными проверками в коде шейдера в случае с текстурными массивами выборкой данных полноценно занимается специализированный блок — sampler. В результате более эффективно используются вычислительные ресурсы и текстурный кэш GPU. Также для текстурных массивов можно использовать привычный механизм генерации  mip-уровней, не боясь появления артефактов в виде швов. Однако, в отличие от атласов, все текстуры в массиве обязательно должны быть одинакового разрешения и формата. 

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

Пример текстурного атласа и текстурного массива

В документации написано, что в Unity2019.4 (на которой сейчас разрабатывается War Robots) не существует пайплайна для импорта текстурных массивов, и предлагается создавать массивы либо прямо в рантайме, либо в эдиторных скриптах с последующим сохранением их, используя AssetDatabase.CreateAsset. 

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

Исходный флоу для текстурных массивов на War Robots

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

Мы используем несколько пресетов качества (HD и LD), и для каждого из них можно выбирать основные настройки будущего текстурного массива: разрешение и компрессию. 

Самый первый алгоритм создания текстурных массивов выглядел примерно так. Первым шагом выполнялся реимпорт исходной текстуры, содержащей несжатые данные. 

Зачем вообще там нужен реимпорт текстур?

Использование любого сжатого формата должно производиться именно из оригинальной исходной текстуры. Исходная текстура — это не одно и то же, что разжатая. Проблема в том, что все без исключения форматы с компрессией являются сжатием с потерями (losy compression), и на самом деле так или иначе «уродуют» текстуру в зависимости от выбранного алгоритма сжатия. Таким образом, из любого сжатого формата восстановить исходную текстуру уже не получится: разжатая текстура будет иметь ровно те же артефакты, что и сжатая. Именно поэтому категорически нельзя делать что-то вроде «ETC2 → ASTC → DXT», поскольку на выходе будет нечто, вобравшее в себя артефакты компрессий всех трех форматов. Всегда нужно работать только с исходной текстурой и никак иначе.

Важно понимать почему обязательно следует использовать исходную текстуру без сжатия: слева картинка сжата в ASTC, а справа — каскад ETC → DXT ​​→ ASTC. Как можно заметить, справа видны артефакты после применения последовательности компрессий.

Далее выполнялась поканальная перепаковка текстур для каждого слоя. В результате из таких текстур в конфигурации для слоя 0…

...мы получаем две поканально перепакованные текстуры. Первая — AS, содержит Albedo + Smoothness, вторая — MNAO, содержит Metalness + Normals + Ambient Occlusion:

Эти текстуры не сохраняются в проекте и попадают в разные текстурные массивы (AS/MNAO). По каждому слою (из рисунка с конфигом) получаем две поканально перепакованные текстуры, каждая из которых является будущим слоем для своего текстурного массива.

Далее применяем компрессию, которая была указана в конфиге, создаем из этих  текстур объект Texture2DArray (в данном примере мы создаем сразу два Texture2DArray — для AS и для MNAO) и сохраняем каждый как ассет, используя AssetDatabase.CreateAsset, как нам и советовала документация Unity. В итоге получается так называемый «запеченный» текстурный массив, над которым можно произвести только две операции: назначить его в материал или удалить из проекта. Изменить размер и/или тип компрессии у него уже невозможно.

Всё это отлично работало для мобильных платформ — ровно до момента релиза на ПК-платформы. Проблема заключается в том, что компрессия ASTC не поддерживается на standalone-платформах, в частности — под Windows. В результате почти для каждого релиза требовалось создание второго набора ассетов, но уже в формате DXT5/BC7, поддерживаемого standalone-платформами. Такое ветвление довольно сильно усложняло процесс подготовки контента, сборки билдов и, что хуже всего, требовало постоянной рутинной ручной работы в довольно большом объеме. По факту требовалась возможность создания всего одно ассета вместо нескольких (под мобильные и ПК платформы), в котором под каждую платформу можно указать настройки разрешения и компрессии по аналогии с обычными текстурами. При этом сама методика создания массивов и наполнения слоев должна была остаться прежней.

Тогда на помощь пришел ScriptedImporter из Unity. Он позволяет для любого типа файлов написать свой алгоритм импорта, чтобы Unity понимал, как работать с этим типом ассетов.

При реализации кастомного импортера было несколько требований: 

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

  • результирующий ассет (текстурный массив) не должен зависеть от исходных текстур;

  • результирующий ассет (текстурный массив) должен попадать в кэш акселератора.

Первый вариант импортера: так делать нельзя

В одном из первых вариантов реализации предлагалось внутри каждого ассета массива хранить ссылку на нужный конфиг, а также название целевого качества и типа массива (AS/MNAO). Выглядело это примерно вот так:

То есть, это был обычный json, из которого можно было сделать полноценный Unity-ассет.

При первых попытках написать импортер сразу вылезла проблема из-за реимпорта текстур: мы не можем внутри одного импорта вызвать другой импорт. Точнее, можем, но вложенный вызов импорта по факту начнет работать только тогда, когда мы выйдем из вызывающего импорта.

На основе этой идеи оперативно родилось это чудовище — алгоритм, который мы, к счастью, не применили:

Это была «трехходовка», описание прилагается:

  1. Мы попадаем в импорт нашего созданного ассета. В метод импорта передается контекст, который хранит всю необходимую нам информацию, а именно: путь до ассета, целевую платформу и другие полезные данные.

  2. Читаем данные внутри ассета — в нашем случае это был просто json, в котором указывался Guid конфига, качество и тип целевых текстур. Загружаем по Guid конфиг, в котором присутствуют ссылки на исходные текстуры.

  3. Для каждой текстуры проверяем, есть ли ее дубликат во временной директории.

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

  5. Выходим из импорта — только после этого по факту начнут копироваться текстуры.

    Так как в шаге 4 мы добавили зависимости от текстур и инициировали их копирование, то после копирования  Unity снова вызовет импорт нашего ассета (шаг 1). Но на этот раз все текстуры уже лежат во временной папке, так что мы доходим до шага 6.

  6. Проверяем настройки временных текстур — у них должен быть одинаковый целевой размер и формат — кроме того, текстуры должны быть isReadable и т. д.

  7. Если по каким-то причинам какая-либо временная текстура не удовлетворяет этим требованиям, мы правим настройки ее импортера и инициируем ее реимпорт.

  8. Выходим из импорта и, как было отмечено ранее, только после этого по факту начнется реимпорт текстур.

    Так как в шаге 7 мы сменили настройки и инициировали реимпорт текстур (помним, что в шаге 4 мы добавили зависимость от этих текстур), то после их реимпорта Unity снова вызовет импорт нашего текстурного массива, и мы снова попадем в шаг 1. Но в этот раз текстуры уже лежат во временной папке, и все их параметры соответствуют требованиям нашего алгоритма для создания текстурного массива. Поэтому на этот раз мы подходим к шагу 9.

  9. Применяем целевую компрессию, так как наши копии текстур в данный момент без сжатия (RGBA).

  10. По каждому слою текстур создаем поканально перепакованные текстуры.

  11. Из поканально перепакованных текстур создаем объект типа Texture2DArray.

  12. Назначаем этот объект в контексте импорта как основной — именно он затем подгрузится, если мы вызовем AssetDatabase.LoadAssetAtPath<Texture2DArray>.

  13. Удаляем копии текстур из временной папки.

  14. Выходим из импорта.

Мы реализовали этот алгоритм, но у него было два огромных минуса:

  • Он совсем неочевиден: для каждого ассета массива мы попадаем в импорт три раза, и только после третьего захода мы получаем необходимый артефакт в Library.

  • Из-за удаления копий текстур (шаг 13) результирующий артефакт не попадал в кэш акселератора при сборке на TeamCity. Конечно, можно было написать очередной BuildStep для нашего кастомного билд-пайплайна, но к тому моменту уже была уверенность, что это будет совсем перебор, и нужно менять подход к созданию файла ассета и его импорту.

Второй вариант импортера: не идеальный, но рабочий

В ходе последующих дискуссий с нашими тех. артистами решили распилить алгоритм до момента, когда мы перепаковываем каналы. То есть, в процессе создания файла для текстурного массива весь замес с реимпортом текстур и перепаковкой каналов  происходит до непосредственного импорта. Поканально перепакованные текстуры мы решили сохранить прямо внутри ассета с максимальным разрешением, которое нам пригодится в дальнейшем импорте. Все текстуры внутри файла ассета, естественно,  хранятся без сжатия. Затем мы сохраняем все это в виде байтового массива в своем формате. Сам формат нового ассета выглядит примерно так:

В процессе сериализации текстуру в виде байтового массива мы получаем через Texture2D.GetRawTextureData(), а во время десериализации восстанавливаем текстуру через Texture2D.LoadRawTextureData().

То есть, еще раз: в новом варианте ассета мы храним не json, а bytearray своего формата, где содержатся все необходимые данные (и перепакованные текстуры в том числе) для успешного импорта нашего текстурного массива.

В результате сам импорт стал линейным и понятным:

И эта схема оказалась действительно рабочей:

  • мы только частично поменяли флоу создания текстурного массива;

  • мы не поменяли алгоритм перепаковки каналов исходных текстур;

  • мы не добавили зависимости от исходных текстур;

  • наш результирующий ассет успешно попадает в кэш акселератора.

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

В этом инспекторе можно изменить параметры под конкретную платформу, и эти параметры обязательно применятся при очередном импорте нашего Texture2DArray.

Сравнивая первый и второй варианты форматов файлов наших ассетов (обычный json vs бинарник собственного сочинения), мы получали (забудем пока про акселератор) совершенно одинаковые текстурные массивы. Всё это из-за того, что  Unity не работает с содержимым файлов ассетов напрямую — он работает с результатом импорта файлов этих ассетов, которые как раз лежат в Library.

Бонус. Ненормальная работа со Scripted Importer

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

Для начала найдем в интернете картинку и просто сохраним ее URL в текстовом файле с именем, например, “pixonic.wwwtexture”, а затем добавим этот файл в проект. Если открыть этот «ассет» в текстовом редакторе, мы просто увидим нашу ссылку:

На данный момент Unity понятия не имеет, как обращаться с файлами, у которых расширение “wwwtexture”, поэтому мы видим следующую картину:

Давайте объясним Unity, что мы от нее хотим — для этого напишем вот такой маленький импортер:

[ScriptedImporter(1, "wwwtexture")]
public class WwwTextureImporter : ScriptedImporter
{
   public override void OnImportAsset(AssetImportContext ctx)
   {
       var url = File.ReadAllText(ctx.assetPath);
       var texture = DownloadTexture(url);
       if (texture == null)
       {
           return;
       }

       ctx.AddObjectToAsset("main", texture);
       ctx.SetMainObject(texture);
   }

   private Texture2D DownloadTexture(string url)
   {
       using (var client = new WebClient())
       {
           var data = client.DownloadData(url);
           var texture = new Texture2D(1, 1);
           if (texture.LoadImage(data))
           {
               return texture;
           }
       }

       return null;
   }
}

Тут все должно быть максимально ясно: читаем файл ассета и получаем URL, затем скачиваем эту картинку, получаем объект Texture2D, после чего просто назначаем этот объект как основной артефакт в данном ассете. Et voila!

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

Сразу замечу: скачивать надо именно синхронно — иначе чуда не произойдет.

Теперь мы можем назначить этот ассет в материал, и все будет работать:

Естественно, так делать не надо, и у меня даже идей нет, где подобные ассеты с таким импортом могли бы пригодиться. Этот пример следует рассматривать как шутку и просто как  демонстрацию возможностей ScriptedImporter.

Заключение

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

Unity ScriptedImporter — очень мощный инструмент, который позволяет наладить работу с форматами файлов, которые не поддерживаются Unity «из коробки». С его помощью мы:

  • реализовали пайплайн для импорта файлов текстурных массивов нашего формата, слегка поправив существующий пайплайн создания контента;

  • избавились от нужды поддержки дополнительных релизных веток (mobile/standalone) и обязательных периодических ручных трудозатрат;

  • получили полный карт-бланш для дальнейшей поддержки и модификации процесса импорта таких ассетов.


Автор статьи — Александр Скотников, Automation Developer в Pixonic

Благодарности

Хочу также поблагодарить своих коллег из Pixonic: Романа Вишнякова, Павла Кирсанова, Александра Агапкина, Александра Богомольца и Дмитрия Четверикова — они очень помогли в процессе работы над импортером текстурных массивов, а также оказали мощнейший саппорт в описании технической части по графике и вообще в создании данной публикации.

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


  1. LordNeznay
    30.10.2021 19:28

    Когда я пытался использовать текстурные массивы при создании своего клона майнрафта, я быстро наткнулся на их ограничение по длине. На RTX-3070 в текстурном массиве могло быть не более 2048 элементов. Из-за этого пришлось вернуться к обычным атласам, так как в один атлас можно упаковать гораздо больше тайлов, чем в один массив. Это справедливо по крайней мере для ситуации, когда тайл меньше по размеру, чем 1024х1024 для атласов 8192х8192.
    В остальном же текстурные массивы - действительно удобная штука. Нет мороки со швами, можно без плясок с бубном использовать GL_REPEAT и т.д.


    1. Pixonic Автор
      01.11.2021 10:49

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