Сжатие мобильной графики в формат ETC1 и открытая утилита

    При развитии free-to-play мобильной игры вместе с новыми фичами регулярно добавляется и новая графика. Часть ее включается в дистрибутив, часть скачивается в ходе игры. Для возможности запуска приложения на устройствах с небольшим размером оперативной памяти разработчики применяют аппаратно сжатые текстуры.



    Формат ETC1 обязателен к поддержке на всех Android-устройствах с OpenGL ES 2.0 и является хорошей отправной точкой оптимизации потребляемой оперативной памяти. По сравнению с форматами PNG, JPEG, WebP загрузка текстур ETC1 осуществляется без интенсивных расчетов обычным копированием памяти. Также улучшается производительность игры по причине меньших размеров данных текстур пересылаемых из медленной памяти в быструю.

    На любом устройстве с OpenGL ES 3.0 возможно использование текстур в формате ETC1, являющимся подмножеством улучшенного формата ETC2.

    Использование сжатых текстур в формате ETC1


    Формат ETC1 содержит только компоненты цвета RGB, поэтому он подходит для непрозрачных фонов, которые рекомендуется рисовать с отключенным Alpha-blending.

    Что делать с прозрачной графикой? Для нее задействуем две текстуры ETC1 (далее — 2xETC1):

    — в первой текстуре храним исходный RGB;
    — во второй текстуре храним исходную альфу (далее — A), скопировав ее в компоненты RGB.

    Тогда в пиксельном шейдере 2xETC1 восстановим цвета таким образом:

    uniform sampler2D u_Sampler;
    uniform sampler2D u_SamplerAlpha;
    
    varying vec2 v_TexCoords;
    varying vec4 v_Color;
    
    void main() {
        vec4 sample = texture2D(u_Sampler, v_TexCoords);
        sample.a = texture2D(u_SamplerAlpha, v_TexCoords).r;
        gl_FragColor = sample * v_Color;
    }

    Особенности подготовки атласов перед сжатием в формат ETC1


    Формат ETC1 использует независимые блоки 4x4 пикселя, поэтому положение помещаемых в атлас элементов желательно выравнивать на 4 пикселя, чтобы исключить попадание разных элементов в общий блок.

    Все элементы при помещении в атлас слегка увеличиваются по площади, потому что нуждаются в дополнительной защитной рамочке толщиной 1-2 пикселя. Это связано с дробными координатами отрисовки (при плавном движении спрайтов) и с билинейной фильтрацией текстур. Математическое обоснование причин происходящего заслуживает отдельной статьи.

    В случае полигональных атласов элементы разводятся на приемлемое расстояние. Все блоки ETC1 при размере 4x4 состоят из пары полосок 2x4 или 4x2, поэтому даже расстояние в 2 пикселя может иметь хороший изолирующий эффект.

    Чем можно качественно сжать в формат ETC1?


    Имеется выбор среди бесплатных утилит:

    ETC2Comp;
    Mali GPU Texture Compression Tool;
    PVRTexTool;
    rg-etc1.

    Для качественного сжатия графики приходится задавать perceptual метрику, учитывающую особенности восприятия, а также выбирать медленные режимы best и slow. Один раз попробовав качественно сжать текстуру 2048x2048 понимаешь, что это долгий процесс… Возможно поэтому многие разработчики ограничиваются быстрыми альтернативами medium и fast. Можно ли сделать лучше?

    История создания с нуля собственной утилиты EtcCompress одним из программистов Playrix берет начало в январе 2014 года, когда финальное сжатие графики в формат ETC1 превысило по длительности трехчасовой поход в гости.

    Идеи качественного сжатия в формат ETC1


    Формат ETC1 является форматом с независимыми блоками. Поэтому мы используем классический подход сжатия отдельных блоков, который хорошо распараллеливается. Конечно, можно пытаться улучшить стыковку блоков, рассматривая наборы блоков, но в таком случае потребуется информация о принадлежности элементам атласа и резко возрастает вычислительная сложность задачи.

    Для сравнения результатов сжатия подходит утилита dssim.

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

    — две полоски 2x4, каждая имеющая свой базовый 4-битный цвет, в коде вызовы CompressBlockColor44(…, 0);
    — две полоски 4x2, каждая имеющая свой базовый 4-битный цвет, в коде вызовы CompressBlockColor44(…, 1);
    — две полоски 2x4, первая имеющая базовый 5-битный цвет, вторая отличающаяся базовым цветом от первой в диапазоне 3-бит, в коде вызовы CompressBlockColor53 (…, 2);
    — две полоски 4x2, первая имеющая базовый 5-битный цвет, вторая отличающаяся базовым цветом от первой в диапазоне 3-бит, в коде вызовы CompressBlockColor53 (…, 3).
    2x4, 444+444 4x2, 444+444 2x4, 555+333 4x2, 555+333

    Кстати об ошибке, во многих утилитах используется классический PSNR. Мы тоже используем эту метрику. Выберем весовые коэффициенты из таблицы.

    PixelError = 0.715158 * (dstG - srcG)^2 + 0.212656 * (dstR - srcR)^2 + 0.072186 * (dstB - srcB)^2

    Перейдем к целочисленным значениям, умножив коэффициенты на 1000 и округлив. Тогда начальная ошибка блока 4x4 составит kUnknownError  = (255^2) * 1000 * 16 + 1, где 255 — максимальная ошибка цветовой компоненты, 1000 – фиксированная сумма весов, 16 — количество пикселей. Такая ошибка укладывается в int32_t. Можно заметить, что целочисленное квадрирование близко по смыслу учету гаммы 2.2.

    У PSNR есть слабые места. Например, кодирование заливки цветом c0 выбором из палитры c1 = c0 - d и c2 = c0 + d вносит одинаковую ошибку d^2. Это означает случайный выбор между c1 и c2 влекущий всевозможные шашки.

    Для улучшения результата финальный расчет в блоке выполним по SSIM. В коде это делается в функции ComputeTableColor с использованием макросов SSIM_INIT, SSIM_UPDATE, SSIM_CLOSE, SSIM_OTHER, SSIM_FINAL. Идея в том, что для всех решений с наилучшим PSNR (в найденном режиме кодирования) выбирается решение с наибольшим SSIM.

    Для каждого режима кодирования блока придется перебрать все возможные комбинации базовых цветов. В случае независимых базовых цветов функция CompressBlockColor44 выполняет независимое сжатие полосок двумя вызовами функции GuessColor4.

    Функция GuessColor4 выполняет перебор отклонений и компонент базового цвета:

    for (int q = 0; q < 8; q++)
        for (int c0 = 0; c0 < c0_count; c0++) // G, c0_count <= 16
            for (int c1 = 0; c1 < c1_count; c1++) // R, c1_count <= 16
                for (int c2 = 0; c2 < c2_count; c2++) // B, c2_count <= 16
                    ComputeErrorGRB(c, q);

    В случае зависимых базовых цветов возрастает алгоритмическая сложность из-за двойной вложенности циклов полосок. Функция CompressBlockColor53 выполняет перебор отклонений.

    for (int qa = 0; qa < 8; qa++)
        for (int qb = 0; qb < 8; qb++)
            AdjustColors53(qa, qb);

    Функция AdjustColors53 выполняет перебор компонент двух базовых цветов:

    for (int a0 = 0; a0 < a0_count; a0++) // G, a0_count <= 32
        for (int a1 = 0; a1 < a1_count; a1++) // R, a1_count <= 32
            for (int a2 = 0; a2 < a2_count; a2++) // B, a2_count <= 32
                ComputeErrorGRB(a, qa);
    
                for (int d0 = Ld0; d0 <= Hd0; d0++) // G, d0_count <= 8
                    for (int d1 = Ld1; d1 <= Hd1; d1++) // R, d1_count <= 8
                        for (int d2 = Ld2; d2 <= Hd2; d2++) // B, d2_count <= 8
                            b = a + d;
                            ComputeErrorGRB(b, qb);

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

    В случае графики 2xETC1 полностью прозрачные пиксели в общем случае могут иметь произвольный цвет RGB, который будет умножен на нулевую альфу.


    Незначащие пиксели мы можем не учитывать, поэтому отфильтруем их в самом начале, в коде это вызовы FilterPixelsColor. С другой стороны, не всякий прозрачный пиксель является незначащим, вспомним хотя бы защитную рамочку в 1-2 пикселя и эффект отбеливания границ.

    Поэтому сделаем трафарет, в котором ноль будет означать незначащий пиксель, а положительная величина покажет значимый пиксель. Трафарет создается на основе канала A применением обводки, чаще размера 1 или 2 пикселя, в коде это функция OutlineAlpha.

    Как показала практика, при использовании трафарета улучшаются сжатые границы объектов, а невидимые блоки быстро принимают хорошо пакуемый zip черный цвет. Именно идея трафарета дает заметный выигрыш по качеству в сравнении с раздельным сжатием RGB и A, в том числе перечисленными утилитами.
                             

    Таким образом, сжатие 2xETC1 можно представить следующими шагами, реализованными в функции EtcMainWithArgs:

    1) сжимаем канал A в формат ETC1;
    2) распаковываем сжатый канал A обратно;
    3) делаем обводку видимого, где A > 0, получая трафарет;
    4) сжимаем каналы RGB в формат ETC1 с учетом трафарета.

    Идеи ускорения качественного сжатия в формат ETC1


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

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

    В данном случае упаковщик пытается прочитать выходной файл, распаковать его и рассчитать имеющуюся ошибку, это будет начальным решением. Если же файла нет, то берется начальное решение из нулей. В коде это LoadEtc1, CompressBlockColor, MeasureHalfColor.

    Последующие шаги должны пытаться улучшить имеющееся решение алгоритмами по возрастанию сложности. Поэтому сначала вызываются быстрые CompressBlockColor44, лишь затем медленные CompressBlockColor53. Такая цепочечная конструкция в перспективе позволит интегрировать сжатие в формат ETC2.

    Перед началом перебора вложенными циклами есть смысл найти решение в разрезе цветовых компонент. Дело в том, что наилучшее решение не может иметь ошибку меньше, чем суммарная ошибка наилучших решений для каждой из компонент G, R, B. Часто результирующая ошибка будет существенно больше, что характеризует нелинейность и сложность алгоритма ETC1.

    Решения в разрезе цветовых компонент представлены структурами GuessStateColor и AdjustStateColor. Для каждого значения из таблицы отклонений g_table рассчитываются ошибки полосок Half и сохраняются в поля node0, node1, node2. Причем в GuessStateColor в индексах [0x00..0x0F] хранятся рассчитанные ошибки для всех возможных базовых цветов g_colors4, а в индексе [0x10] наилучшее решение. Для AdjustStateColor наилучшее решение хранится в индексе [0x20], все возможные базовые цвета берутся из g_colors5.

    Расчет ошибки по компонентам цвета осуществляется функциями ComputeLevel, GuessLevels, AdjustLevels на основе таблиц g_errors4, g_errors5, предварительно рассчитанных функцией InitLevelErrors.

    Перебор цветовых компонент есть смысл сделать в порядке возрастания вносимой ими ошибки, для этого осуществляется сортировка полей node0, node1, node2 функциями SortNodes10 и SortNodes20.

    Для ускорения самой сортировки применяются сортирующие сети, рассчитанные на тематическом сайте.

    Перед выполнением сортировки есть смысл отбросить большие ошибки, превышающие найденное решение. При этом заметно уменьшается количество элементов в полях node0, node1, node2, что существенно ускоряет сортировку и дальнейший перебор.

    Третий вложенный цикл по цветовым компонентам G, R, B можно попытаться отсечь, найдя наилучшее решение для текущих G, R функцией ComputeErrorGR, которая в 2 раза быстрее функции ComputeErrorGRB. Это, кстати, горячие места в профилировщике.

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

    Этим занимаются функции Walk и Bottom.

    64 вызова функции AdjustColors53 могут привести к повторным вызовам функций ComputeErrorGR и ComputeErrorGRB с одинаковыми параметрами базового цвета, поэтому будем кэшировать результаты вызовов. В свою очередь, для быстрой инициализации кэша можно использовать ленивые вычисления по третьему цветовому компоненту.

    В структуре AdjustStateColor поля ErrorsG, ErrorsGR и поле ErrorsGRB очищаемое LazyGR дают существенный прирост производительности.

    После различных алгоритмических улучшений пришло время использовать SIMD, в данном случае опубликовано решение на целочисленном SSE4.1. Данные одного пикселя храним как int32x4_t.

    Команды _mm_adds_epu8 и _mm_subs_epu8 удобны для расчета четырехцветной палитры из базового цвета и отклонений.

    В функциях ComputeErrorGRB и ComputeErrorGR сначала применяются частично развернутые циклы, оптимизированные командой _mm_madd_epi16, так как в большинстве случаев достаточно ее разрядности. В случае же больших погрешностей работает второй цикл на «медленных» командах _mm_mullo_epi32.

    Функция ComputeLevel рассчитывает ошибку сразу для четырех значений базового цвета.

    Для сжатия одного канала A можно упростить полученный код сжатия RGB. Будет заметно меньше вложенных циклов и повысится производительность.

    Достигнутые результаты


    Изложенные подходы позволяют уменьшить требования к оперативной памяти в Android-версиях игр за счет использования сжатых текстур в аппаратном формате ETC1.

    В скриптах формирования атласов и самой утилите сжатия уделяется внимание вопросам предотвращения артефактов и повышения качества сжатой графики.

    На удивление, вместе с повышением качества сжатой графики удалось ускорить само сжатие! В нашем проекте Gardenscapes сжатие атласов в формат ETC1 на процессоре Intel Core i7 6700 занимает 24 секунды. Это быстрее генерации самих атласов и в несколько раз быстрее предыдущей утилиты сжатия в режиме fast. Предложенное инкрементальное сжатие происходит за 19 секунд.

    В заключение приведу пример сжатия текстуры 8192x8192 RGB представленной утилитой EtcCompress под Win64 на процессоре Intel Core i7 6700:

    x:\>EtcCompress
    Usage: EtcCompress [/retina] src [dst_color] [dst_alpha] [/debug result.png]
    
    x:\>EtcCompress 8192.png 1.etc /debug 1.png
    Loaded 8192.png
    Image 8192x8192, Texture 8192x8192
    
    Compressed 4194304 blocks, elapsed 10988 ms, 381716 bps
    Saved 1.etc
    Texture RGB wPSNR = 42.796053, wSSIM_4x2 = 0.97524678
    
    Saved 1.png
    
    x:\>EtcCompress 8192.png 1.etc /debug 2.png
    Loaded 8192.png
    Image 8192x8192, Texture 8192x8192
    
    Loaded 1.etc
    Compressed 4194304 blocks, elapsed 6487 ms, 646570 bps
    Saved 1.etc
    Texture RGB wPSNR = 42.796053, wSSIM_4x2 = 0.97524678
    
    Saved 2.png
    
    x:\>fc /b 1.png 2.png
    Сравнение файлов 1.png и 2.png
    FC: различия не найдены

    Надеемся, что утилита поможет качественно и быстро сжимать мобильную графику.
    Метки:
    Playrix 26,96
    Разработчик мобильных free-to-play игр
    Поделиться публикацией
    Похожие публикации
    Комментарии 15
    • +2
      А на картинке реально существующая игра или нет?
    • 0
      DXT не в почёте? Или на мобильных устройствах плохо с его поддержкой?
      • 0
        По сути только на SoC от nVidia.
        • 0
          Сравнивали утилитой dssim атласы сжатые в DXT5 и 2xETC1. Результаты у 2xETC1 были лучше. Не было заметно характерного для DXT5 шума.
        • +2
          Для своих игр использую утилиту TexurePacker уже пару лет, она поддерживает как сжатие для android так и для iOS (разные форматы ETC1_ALPHA, PVRTC4). Игра в изометрии, сжатие реально помогает сэкономить размер атласов.
          • 0
            Ряд идей может быт успешно применен и на других форматах. Например, сжатие по трафарету, полировка по SSIM, современная весовая функция.
          • 0
            Текстуры лесной зоны с заглавной картинки, очень похожи на приглаженные текстуры из Warcraft 3 (вдохновлялись, ага)
            • 0
              Это можно как то с Unity подружить?
              • +1
                Код открытый, возможно всё. Утилита понимает командную строку. Стоит доработать функции-заглушки LoadEtc1 и SaveEtc1 под нужный формат, например KTX/PVR.
                Если есть конкретный пример командной строки и известен формат могу попробовать доработать.
                • 0
                  Я больше менеджер чем разработчик, так что технических деталей не подскажу. Если бы вы оформили это как плагин для Unity, даже платный, был бы спрос, я бы купил точно
                  • 0
                    Решение для ETC1 получилось хорошее, поэкспериментирую с Unity…
                    А так витают мысли скрестить ETC1 от EtcCompress и ETC2 от etc2comp, был бы мощный инструмент.
                    • 0
                      Еще вам задачка на миллион, решите — с руками оторвут. Там на iOS только pvrtc и он просто ужасен для спрайтов с прозрачностью. Адекватного решения я нигде не могу найти (( Советуют так же прозрачность хранить в отдельной текстуре, но как это подружить с Unity Sprite Packer не понятно. Знакомый, который собственный движок пилит, хранит в webp, но Unity такое тоже не умеет
                    • 0
                      Обсуждение сжатия в Unity натолкнуло на мысль сделать прокладку совместимую с PVRTexTool, но умеющую вызывать нужный упаковщик в зависимости от командной строки.
                • 0
                  За последний год с PVRTC4BPP провел много экспериментов и тоже наблюдал проблемы со сжатием альфы. На некоторых проектах применяем сжатие аналогично 2xETC1 — когда RGB(PVRTC4) + A(PVRTC4), но это по метрикам слабее, чем 2xETC1. Пока не определились будет ли статья про PVRTC4. Тем не менее, успешно используем свою утилиту сжатия в PVRTC4, и качество заметно выше.

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

                  Самое читаемое