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

Формат ETC1 обязателен к поддержке на всех Android-устройствах с OpenGL ES 2.0 и является хорошей отправной точкой оптимизации потребляемой оперативной памяти. По сравнению с форматами PNG, JPEG, WebP загрузка текстур ETC1 осуществляется без интенсивных расчетов обычным копированием памяти. Также улучшается производительность игры по причине меньших размеров данных текстур пересылаемых из медленной памяти в быструю.
На любом устройстве с OpenGL ES 3.0 возможно использование текстур в формате ETC1, являющимся подмножеством улучшенного формата ETC2.
Формат ETC1 содержит только компоненты цвета RGB, поэтому он подходит для непрозрачных фонов, которые рекомендуется рисовать с отключенным Alpha-blending.
Что делать с прозрачной графикой? Для нее задействуем две текстуры ETC1 (далее — 2xETC1):
— в первой текстуре храним исходный RGB;
— во второй текстуре храним исходную альфу (далее — A), скопировав ее в компоненты RGB.
Тогда в пиксельном шейдере 2xETC1 восстановим цвета таким образом:
Формат ETC1 использует независимые блоки 4x4 пикселя, поэтому положение помещаемых в атлас элементов желательно выравнивать на 4 пикселя, чтобы исключить попадание разных элементов в общий блок.
Все элементы при помещении в атлас слегка увеличиваются по площади, потому что нуждаются в дополнительной защитной рамочке толщиной 1-2 пикселя. Это связано с дробными координатами отрисовки (при плавном движении спрайтов) и с билинейной фильтрацией текстур. Математическое обоснование причин происходящего заслуживает отдельной статьи.
В случае полигональных атласов элементы разводятся на приемлемое расстояние. Все блоки ETC1 при размере 4x4 состоят из пары полосок 2x4 или 4x2, поэтому даже расстояние в 2 пикселя может иметь хороший изолирующий эффект.
Имеется выбор среди бесплатных утилит:
— ETC2Comp;
— Mali GPU Texture Compression Tool;
— PVRTexTool;
— rg-etc1.
Для качественного сжатия графики приходится задавать perceptual метрику, учитывающую особенности восприятия, а также выбирать медленные режимы best и slow. Один раз попробовав качественно сжать текстуру 2048x2048 понимаешь, что это долгий процесс… Возможно поэтому многие разработчики ограничиваются быстрыми альтернативами medium и fast. Можно ли сделать лучше?
История создания с нуля собственной утилиты EtcCompress одним из программистов Playrix берет начало в январе 2014 года, когда финальное сжатие графики в формат ETC1 превысило по длительности трехчасовой поход в гости.
Формат ETC1 является форматом с независимыми блоками. Поэтому мы используем классический подход сжатия отдельных блоков, который хорошо распараллеливается. Конечно, можно пытаться улучшить стыковку блоков, рассматривая наборы блоков, но в таком случае потребуется информация о принадлежности элементам атласа и резко возрастает вычислительная сложность задачи.
Для сравнения результатов сжатия подходит утилита dssim.
Для каждого блока придется перебрать все 4 возможные режима кодирования, чтобы найти наилучший, в коде функция CompressBlockColor:
— две полоски 2x4, каждая имеющая свой базовый 4-битный цвет, в коде вызовы CompressBlockColor44(…, 0);
— две полоски 4x2, каждая имеющая свой базовый 4-битный цвет, в коде вызовы CompressBlockColor44(…, 1);
— две полоски 2x4, первая имеющая базовый 5-битный цвет, вторая отличающаяся базовым цветом от первой в диапазоне 3-бит, в коде вызовы CompressBlockColor53 (…, 2);
— две полоски 4x2, первая имеющая базовый 5-битный цвет, вторая отличающаяся базовым цветом от первой в диапазоне 3-бит, в коде вызовы CompressBlockColor53 (…, 3).
Кстати об ошибке, во многих утилитах используется классический PSNR. Мы тоже используем эту метрику. Выберем весовые коэффициенты из таблицы.
Перейдем к целочисленным значениям, умножив коэффициенты на 1000 и округлив. Тогда начальная ошибка блока 4x4 составит
У PSNR есть слабые места. Например, кодирование заливки цветом
Для улучшения результата финальный расчет в блоке выполним по SSIM. В коде это делается в функции ComputeTableColor с использованием макросов SSIM_INIT, SSIM_UPDATE, SSIM_CLOSE, SSIM_OTHER, SSIM_FINAL. Идея в том, что для всех решений с наилучшим PSNR (в найденном режиме кодирования) выбирается решение с наибольшим SSIM.
Для каждого режима кодирования блока придется перебрать все возможные комбинации базовых цветов. В случае независимых базовых цветов функция CompressBlockColor44 выполняет независимое сжатие полосок двумя вызовами функции GuessColor4.
Функция GuessColor4 выполняет перебор отклонений и компонент базового цвета:
В случае зависимых базовых цветов возрастает алгоритмическая сложность из-за двойной вложенности циклов полосок. Функция CompressBlockColor53 выполняет перебор отклонений.
Функция AdjustColors53 выполняет перебор компонент двух базовых цветов:
Представленный полный перебор ничем не быстрее наилучших режимов сжатия аналогичных утилит, зато это наш полный перебор, который будет сильно ускорен далее.
В случае графики 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 с учетом трафарета.
Чтобы утилита нашла свое применение, помимо качества результата важно и время работы. Рассматриваемый переборный алгоритм сжатия блока достоин быстрой начальной эвристической оценки и полезных отсечений в ходе работы, в том числе на основе жадных алгоритмов.
Для формата с независимыми блоками легко реализуется инкрементальное сжатие. Например, когда сохранились результаты предыдущего сжатия.
В данном случае упаковщик пытается прочитать выходной файл, распаковать его и рассчитать имеющуюся ошибку, это будет начальным решением. Если же файла нет, то берется начальное решение из нулей. В коде это 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:
Надеемся, что утилита поможет качественно и быстро сжимать мобильную графику.

Формат 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.
— две полоски 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 |
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.
Функция 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);
Представленный полный перебор ничем не быстрее наилучших режимов сжатия аналогичных утилит, зато это наш полный перебор, который будет сильно ускорен далее.

Незначащие пиксели мы можем не учитывать, поэтому отфильтруем их в самом начале, в коде это вызовы 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.
Решения в разрезе цветовых компонент представлены структурами 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, что существенно ускоряет сортировку и дальнейший перебор.
Этим занимаются функции Walk и Bottom.
В структуре AdjustStateColor поля ErrorsG, ErrorsGR и поле ErrorsGRB очищаемое LazyGR дают существенный прирост производительности.
Команды _mm_adds_epu8 и _mm_subs_epu8 удобны для расчета четырехцветной палитры из базового цвета и отклонений.
В функциях ComputeErrorGRB и ComputeErrorGR сначала применяются частично развернутые циклы, оптимизированные командой _mm_madd_epi16, так как в большинстве случаев достаточно ее разрядности. В случае же больших погрешностей работает второй цикл на «медленных» командах _mm_mullo_epi32.
Функция ComputeLevel рассчитывает ошибку сразу для четырех значений базового цвета.
Достигнутые результаты
Изложенные подходы позволяют уменьшить требования к оперативной памяти в 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: различия не найдены
Надеемся, что утилита поможет качественно и быстро сжимать мобильную графику.