Добавление ColorKey в libGDX

Привет Хабр! В данной заметке я расскажу о добавлении colorkey в библиотеку libgdx (или любую другую, где есть шейдеры). Как известно, нативной поддержки «прозрачного цвета» в libgdx нет, поэтому приходится хранить полноцветное изображение в формате RGBA8888 (4 байта на пиксель), либо в усечённом формате RGBA4444 (2 байта на пиксель), который позволяет вдвое уменьшить использование памяти, но сильно ухудшает картинку. При создании 2D игр, зачастую, было бы достаточно всего одного бита прозрачности… Особенно на мобильных платформах…, но его нет… Сделаем, чтобы был!


8e5310376c7046d5a36968c6f431e64f.png

RGBA8888


Для начала нам понадобится эталонное изображение в формате RGBA8888, с которым будут сравниваться все последующие попытки сэкономить байты. Экспериментировать будем с набором тайлов, из которых рисуется уровень, на небо и человечка внимания не обращаем. Размер тайлсета 512, а 512 пикселов. На диске текстуры сохранены в 8-bit png с прозрачностью и занимают 20 килобайт (можно сохранить в в 32 битный png, с тем же результатом, т.к. графика простая, а прозрачность всегда либо есть, либо её нет). В видеопамяти же они займут уже 512×512*4 = 1 мегабайт ровно. Хоцца поменьше, это же не единственная текстура…


2490b2e2527d439f9628837b02671395.png


RGBA4444


Перво-наперво возникает мысль использовать усечённую разрядность. Пиксельарт простой, цветов мало, а сэкономим сразу 512 килобайт. Пробуем:

4473664f1acd4c6499a6e778353f2836.png

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

Пишем шейдер!


Не мудрствуя лукаво я скопировал шейдеры по умолчанию и модифицировал фрагментный шейдер:


	private static String fragmentShader = "#ifdef GL_ES\n" //
			+ "#define LOWP lowp\n" //
			+ "precision mediump float;\n" //
			+ "#else\n" //
			+ "#define LOWP \n" //
			+ "#endif\n" //
			+ "varying LOWP vec4 v_color;\n" //
			+ "varying vec2 v_texCoords;\n" //
			+ "uniform sampler2D u_texture;\n" //
			+ "void main()\n"//
			+ "{\n" //
			+ "  LOWP vec4 pixel = texture2D(u_texture, v_texCoords);\n"//
			+ "  gl_FragColor = v_color * pixel;\n" //
			+ "  if( pixel.rgb == vec3(1.0, 0.0, 1.0) ){gl_FragColor.a = 0.0;}\n"//
			+ "}";

Интересны только последние три строчки. Сперва сохраняем цвет текселя (Важно! Интерполяция текстуры должна быть отключена, т.е. при загрузке используем фильтрацию NEAREST). Затем задаём цвет пикселя, умножая цвет текселя на цвет вершины. Если вы не примешиваете цвета вершин, то это умножение можно заменить на присваивание. И, наконец, сравниваем цвет текселя с «прозрачным цветом» и, если цвета совпадают, то делаем пиксель прозрачным. В качестве «прозрачного» я выбрал классический вырвиглазно-пурпурный rgb (255,0,255). Наверняка от условного оператора можно избавиться, но… «И так сойдёт!».)

RGB565


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


1351773455464285a8f230444f435891.png


Вот так мы легко и непринуждённо уменьшили потребление памяти вдвое, практически без потерь качества и скорости (всё-таки от условного оператора в шейдере хочется избавиться). Но, хочется большего. Хочется сжать текстуры в формат ETC1, но с прозрачностью. Всё-таки в шесть раз меньше занимает, чем RGB. и не на диске, а в памяти!


Пробуем… Эпик фэйл. Ожидаемый. Заглавная картинка как раз результат данной попытки. Результат ожидаемый, ведь ETC1 — формат сжатия с потерями. С сильными потерями. Вырвиглазный цвет помутнел и появились пикселы полувырвиглазного цвета. Обычно, альфа-канал хранят в отдельной текстуре. Часто — без сжатия. Но это не наш метод! Давайте посмотрим, чего можно добиться, если немного пошалить с шейдером.


Шейдер для затейников


 if( vec3( min(pixel.r,0.95), max(pixel.g,0.05), min(pixel.b,0.95)) == vec3(0.95, 0.05, 0.95) )
 {
    gl_FragColor.a = 0.0;
 }

Заменим только последнюю строчку в нашем шейдере. Теперь мы сравниваем не строго с конкретным цветом, а с небольшим отклонением: красной и синей компоненте разрешаем быть немного темнее, а зелёной — светлее.


ee7b3ceecb434f34870dce540f4abb13.png


Тут даже сравнивать с оригиналом не нужно, артефакты видны не вооружённым глазом. Но! Если поиграться с допустимым отклонением, или считать «расстояние» между цветами (тоже с достаточным отклонением), то вполне можно добиться сносных результатов для конкретного набора текстур. Когда борешься за каждый килобайт — этот способ может оказаться вполне приемлемым.


Прозрачный jpeg?


А почему нет? У нас уже есть шеёдер, который сделает прозрачным любую тестуру. Если повезёт, то результат даже будет пригодным к использованию. Если важно занимаемое на диске место, а png слишком плохо жмёт, то почему бы и нет. Попробуем сразу два варианта: с профилем сжатия «maximum» и «very high»


c85b581f00394056a6550150af030cd9.png


Видим, что с профилем «maximum» вполне возможно использование jpg с «прозрачным цветом». Теоретически. Если использование png оказывается менее выгодным.


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


Надеюсь, заметка будет полезна не только мне. Ещё больше надеюсь, что кто-нибудь предложит эквивалентный код без условного оператора. Спасибо за внимание.

UPD:

Пользователь FadeToBlack предложил два варианта шейдера без условного оператора:


Этот шейдер можно использовать только с текстурами, в которых прозрачность указана через цветовой ключ. Текстуры с реальной прозрачностью будут отображаться не правильно. Шейдер из статьи корректно обрабатывает как текстуры с реальной прозрачностью, так и с «прозрачным цветом».


			void main()
			{
			     LOWP vec4 pixel = texture2D(u_texture, v_texCoords);
			     gl_FragColor = v_color * pixel;
			     gl_FragColor.a = float(pixel.rgb != vec3(1.0,0.0,1.0));
			}

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


			void main()
			{
			     LOWP vec4 pixel = texture2D(u_texture, v_texCoords);
			     gl_FragColor = v_color * pixel;
			     gl_FragColor.a = gl_FragColor.a * float(pixel.rgb != vec3(1.0,0.0,1.0));
			}

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

  • 28 декабря 2016 в 18:09 (комментарий был изменён)

    0

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

    Фиг с ними с камнями, там на небе вылезли адские артефакты!

    На самом деле вопрос-то в другом. А какой смысл jpg использовать? В видеопамяти это всё-равно не будет храниться в jpg. Да и для пиксельарта png явно предпочтительнее будет.

    • 28 декабря 2016 в 18:15

      0

      На небо я специально попросил не смотреть.) У меня не стандартный загрузчик. было проще на всё формат поменять, поэтому небо пострадало. В рабочем коде небо выглядит как в версии RGB888.
      Про jpg я даже придумал, зачем. Например, фотография человеки на прозрачном фоне в png может занять на диске больше. Мне не нужно, но вдруг.
      • 28 декабря 2016 в 18:21

        0

        Для этого есть формат DXT.
        В своё время еще S3 компания придумала такую штуку как S3TC — сжатые в видеопамяти текстуры. Как раз для экономии видеопамяти, их сейчас вроде бы все карточки поддерживают.

        Касаемо решаемой у вас проблемы — надо создавать текстуру в A1R5G5B5 формате и прямо при загрузке задавать альфу. То есть ваш шейдер применить при загрузке совтово. Вы и на памяти экономить будете и еще не будете ронять производительность ифами в шейдере.
        А чтобы выровнять гамму, можно как раз таки создать палитру и в шейдере проводить цветокоррекцию, чтобы цвета были идентичны тем, что в 8888 версии.

        • 28 декабря 2016 в 18:23

          0

          DXT есть для DirectX. Речь об Андроид, а там из сжатия только ETC1 обязаны поддерживать. Либо так, либо сильно уменьшается число поддерживаемых устройств.
      • 28 декабря 2016 в 22:59

        0

        Вообще и камни должны бы выглядеть нормально. Если бы рисовались под такой формат изначально (что для пиксельарта логично вполне, он же канонично палитровый). Автоматика при сильном сжатии цветового пространства очень портит картинку.
  • 28 декабря 2016 в 18:22 (комментарий был изменён)

    0

    Без условного оператора можно сделать так: gl_FragColor.a = (float)(pixel.rgb!= vec3(1.0,0.0,1.0));
    • 28 декабря 2016 в 18:24

      0

      А это мысль! bool приводится к float в шейдерах? Я ж только учусь…
      • 28 декабря 2016 в 18:29

        0

        , но лучше протестировать, как будет быстрее. возможно, отключение блендинга и discard с условием будет производительнее
        • 28 декабря 2016 в 18:31

          0

          Проверил. В GLESv2 такое не компилируется.(
          • 28 декабря 2016 в 18:34 (комментарий был изменён)

            0

            прошу прощения, вот так будет правильно
            gl_FragColor.a = float (pixel.rgb!= vec3(1.0,0.0,1.0));
        • 28 декабря 2016 в 18:35

          0

          Про discard отличная идея! Можно не умножать цвет текселя на цвет вершины, если она прозрачна. Спасибо, дополню статью.
          • 28 декабря 2016 в 18:38

            0

            Лучше замерить производительность в стресс-тестах (очень много спрайтов с прозрачностью, сотни тысяч) на мобильных устройствах в режимах с условием, без условия и discard без блендинга (отключение режима прозрачности). Тогда статья будет еще полезнее.
          • 28 декабря 2016 в 18:40

            0

            Отключение умножения не поможет — насколько я знаю, шейдер все равно выполняется весь, без разницы, в каком месте произойдет discard. Думаю, если я не прав, меня поправят.
            • 28 декабря 2016 в 18:49

              0

              Действительно, про discard пишут о критическом падении скорости, в зависимости от оборудования и вариантов использования. Всё сложно.) Если соберусь проверить производительность разных вариантов прозрачности, обязательно напишу статью.
              • 28 декабря 2016 в 18:53

                0

                можно же так, чтобы обе прозрачности учитывались
                gl_FragColor.a = pixel.a * float (pixel.rgb!= vec3(1.0,0.0,1.0));
                • 28 декабря 2016 в 18:58

                  0

                  Думаю, это — ответ победитель.) Слегка подправил, чтобы прозрачность вершины тоже учитывалась и всё встало на свои места. Даже немного стыдно, что до столь простого варианта сам не додумался.
                  • 28 декабря 2016 в 19:01

                    0

                    Ничего страшного, все приходит с опытом. Есть некоторые вопросы, на которые не могут ответить бывалые программисты.
                    • 28 декабря 2016 в 19:06

                      0

                      Ох, вы на меня потратили первый комментарий за 4 года… Я тут тоже почти бесправный, поэтому могу отблагодарить за подсказку только искренним Спасибо!
                      • 28 декабря 2016 в 19:34

                        0

                        Я, честно сказать, недавно узнал, что мне теперь можно комментировать. Я хотел однажды написать статью для инвайта, но ее забрили без объяснения причин, по-свински, как это было принято на хабре много лет назад.
              • 29 декабря 2016 в 03:09 (комментарий был изменён)

                0

                Все верно — discard ломает всю внутреннюю оптимизацию для любых «tile-based deferred rendering»-gpu, например, всех power sgx-ы (а это все девайсы мобильные девайсы apple). Поэтому тот же альфа-блендинг предпочтительнее по скорости, чем discard по условию. Почитать подробнее можно, например, вот тут: http://www.seas.upenn.edu/~pcozzi/OpenGLInsights/OpenGLInsights-TileBasedArchitectures.pdf.

© Habrahabr.ru