28 декабря 2016 в 17:36

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

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





RGBA8888


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



RGBA4444


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



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

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


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


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



Вот так мы легко и непринуждённо уменьшили потребление памяти вдвое, практически без потерь качества и скорости (всё-таки от условного оператора в шейдере хочется избавиться). Но, хочется большего. Хочется сжать текстуры в формат 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;
 }

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



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


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


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



Видим, что с профилем «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));
			}
@maaGames
карма
17,0
рейтинг 0,3
Программист
Похожие публикации

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

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

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

    На самом деле вопрос-то в другом. А какой смысл jpg использовать? В видеопамяти это всё-равно не будет храниться в jpg. Да и для пиксельарта png явно предпочтительнее будет.
    • 0
      На небо я специально попросил не смотреть.) У меня не стандартный загрузчик. было проще на всё формат поменять, поэтому небо пострадало. В рабочем коде небо выглядит как в версии RGB888.
      Про jpg я даже придумал, зачем. Например, фотография человеки на прозрачном фоне в png может занять на диске больше. Мне не нужно, но вдруг.
      • 0
        Для этого есть формат DXT.
        В своё время еще S3 компания придумала такую штуку как S3TC — сжатые в видеопамяти текстуры. Как раз для экономии видеопамяти, их сейчас вроде бы все карточки поддерживают.

        Касаемо решаемой у вас проблемы — надо создавать текстуру в A1R5G5B5 формате и прямо при загрузке задавать альфу. То есть ваш шейдер применить при загрузке совтово. Вы и на памяти экономить будете и еще не будете ронять производительность ифами в шейдере.
        А чтобы выровнять гамму, можно как раз таки создать палитру и в шейдере проводить цветокоррекцию, чтобы цвета были идентичны тем, что в 8888 версии.
        • 0
          DXT есть для DirectX. Речь об Андроид, а там из сжатия только ETC1 обязаны поддерживать. Либо так, либо сильно уменьшается число поддерживаемых устройств.
      • 0
        Вообще и камни должны бы выглядеть нормально. Если бы рисовались под такой формат изначально (что для пиксельарта логично вполне, он же канонично палитровый). Автоматика при сильном сжатии цветового пространства очень портит картинку.
        • 0
          В файле на диске всего 32 цвета и он занимает 20 килобайт. Но Андроид не умеет в палитры, поэтому 32 цвета из палитры в видеопамяти превращаются в 32 бита на пиксель. Вручную красивее перерисовать не получится, потому что банально нет другого цвета, т.е. нельзя оттенки использовать, потому что их нет. Всего 4 бита на канал.
          • 0
            Кажется дошло откуда проблема. Там не палитра, а просто обрезанная компонента.
            А передавать в шейдер палитру отдельной текстурой нельзя?
            • 0
              Можно! Это уже тема для следующей статьи.) Там ещё вдвое можно использование памяти уменьшить, но несколько усложняется подготовка файлов. Нужно выгружать палитру в отдельный полноцветный файл, сохранять текстуру в градациях серого и сохранять во второй файл (андроид поддерживает однобайтные текстуры альфа-канала, который можно использовать не как альфа-канал). А потом уже в шейдере брать тексель из серой текстуры и по его значению брать цвет из текстуры-палитры.
              Зато становятся доступны все фишки использования палитр и даже больше.
  • 0
    Без условного оператора можно сделать так: gl_FragColor.a = (float)(pixel.rgb != vec3(1.0,0.0,1.0));
    • 0
      А это мысль! bool приводится к float в шейдерах? Я ж только учусь…
      • 0
        но лучше протестировать, как будет быстрее. возможно, отключение блендинга и discard с условием будет производительнее
        • 0
          Проверил. В GLESv2 такое не компилируется.(
          • +1
            прошу прощения, вот так будет правильно
            gl_FragColor.a = float(pixel.rgb != vec3(1.0,0.0,1.0));
        • 0
          Про discard отличная идея! Можно не умножать цвет текселя на цвет вершины, если она прозрачна. Спасибо, дополню статью.
          • 0
            Лучше замерить производительность в стресс-тестах (очень много спрайтов с прозрачностью, сотни тысяч) на мобильных устройствах в режимах с условием, без условия и discard без блендинга (отключение режима прозрачности). Тогда статья будет еще полезнее.
          • 0
            Отключение умножения не поможет — насколько я знаю, шейдер все равно выполняется весь, без разницы, в каком месте произойдет discard. Думаю, если я не прав, меня поправят.
            • 0
              Действительно, про discard пишут о критическом падении скорости, в зависимости от оборудования и вариантов использования. Всё сложно.) Если соберусь проверить производительность разных вариантов прозрачности, обязательно напишу статью.
              • +1
                можно же так, чтобы обе прозрачности учитывались
                gl_FragColor.a = pixel.a * float(pixel.rgb != vec3(1.0,0.0,1.0));
                • 0
                  Думаю, это — ответ победитель.) Слегка подправил, чтобы прозрачность вершины тоже учитывалась и всё встало на свои места. Даже немного стыдно, что до столь простого варианта сам не додумался.
                  • 0
                    Ничего страшного, все приходит с опытом. Есть некоторые вопросы, на которые не могут ответить бывалые программисты.
                    • 0
                      Ох, вы на меня потратили первый комментарий за 4 года… Я тут тоже почти бесправный, поэтому могу отблагодарить за подсказку только искренним Спасибо!
                      • 0
                        Я, честно сказать, недавно узнал, что мне теперь можно комментировать. Я хотел однажды написать статью для инвайта, но ее забрили без объяснения причин, по-свински, как это было принято на хабре много лет назад.
              • 0
                Все верно — discard ломает всю внутреннюю оптимизацию для любых «tile-based deferred rendering»-gpu, например, всех power sgx-ы (а это все девайсы мобильные девайсы apple). Поэтому тот же альфа-блендинг предпочтительнее по скорости, чем discard по условию. Почитать подробнее можно, например, вот тут: http://www.seas.upenn.edu/~pcozzi/OpenGLInsights/OpenGLInsights-TileBasedArchitectures.pdf.
                • 0
                  Тут ситуация такая, что условие в любом случае присутствует. Т.е. либо блендинг, либо дискард, но только после условия. Но я понял, что надо экспериментировать.
                  К счастью, уже нашёлся вариант без условного оператора.
                  • 0
                    Внутри это все-равно условие, но в таком виде оно не вызывает бранчинг.
  • 0
    Не понял. А для чего это надо? Картинки в png с прозрачностью и все работает.
    • 0
      4 байта на пиксель, против 2-3 байт с использованием шейдера. Только для экономии видеопамяти.
      • 0
        Я не понимаю как вы экономите… формат 4444 у вас, так?
        Значит вы резервируете по 4 бита на пиксель на прозрачность, которую не используете. И при этом урезаете по паре бит.
        • 0
          Нет. 4444 — это если 4 бита на альфа-канал и этот формат я использовать не хочу. Я использую формат 565, но один цвет используется в качестве «прозрачного», который в шейдере делается прозрачным. Т.е. вместо 4096 (в формате 4444) цветов можно использовать 65535 цветов + прозрачный цвет.
  • 0
    Попробуйте при конвертации из RGBA8888 в RGBA4444 или RGBA5651 использовать дизеринг, в вашем случае должно хорошо помочь.
    Вот результаты (кликабельно):
    image
    • 0
      Я просил не смотреть на небо.) В игре оно загружается в формат 565 и качество не страдает. Речь только о текстурах уровня, а там всего 32 цвета изначально, никакой дизеринг не поможет — разрядности не хватает.
      Результат 4444 выглядит так, будто вы просто уменьшили количество цветов, а не уменьшили разрядность. В чём ужимали цвета?
      • 0
        Результат 4444 выглядит так, будто вы просто уменьшили количество цветов, а не уменьшили разрядность

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

        	for (int i = 0; i < pixCount; i++)
        	{
        		// RGBA5651
        		int r = pix[i].r & 0xF8;
        		int g = pix[i].g & 0xFC;
        		int b = pix[i].b & 0xF8;
        		pix[i].r = qrand() % 8 > pix[i].r - r ? r : r + 7;
        		pix[i].g = qrand() % 4 > pix[i].g - g ? g : g + 3;
        		pix[i].b = qrand() % 8 > pix[i].b - b ? b : b + 7;
        
        		// RGBA4444
        //		int r = pix[i].r & 0xF0;
        //		int g = pix[i].g & 0xF0;
        //		int b = pix[i].b & 0xF0;
        //		int dr = pix[i].r - r;
        //		int dg = pix[i].g - g;
        //		int db = pix[i].b - b;
        //		pix[i].r = qrand() % 16 > dr ? r : r + 15;
        //		pix[i].g = qrand() % 16 > dg ? g : g + 15;
        //		pix[i].b = qrand() % 16 > db ? b : b + 15;
        	}
        

        • 0
          Вот! Вы вот правильно округлили до ближайшего, а автоконверт округляет до меньшего, поэтому в данном случае искажения цвета получились сильнее. В оригинале цвет rgb(42;30;37) был округлён до rgb(34;17;34). Странные цифры из-за float. Если нормально округлить до (48;32;32), то выглядит более прилично даже без дизеринга.
          Но принципиально ничего не изменилось: RGB444 — 4096 цветов, RGB565 — 65535 доступных цветов.
          • 0
            Способ округления и формулы дизеринга — это достойно отдельной статьи.
            В вашем случае можно вообще написать свой конвертер, благо это просто и быстро. 32 цвета — это 5 бит, ну пусть 6 вместе с битом прозрачности. Хотя можно пожертвовать одним цветом для хромакея. В итоге один конвертер будет переводить картинку в массив пикселей по 5 бит на штуку (на этапе разработки и компоновки ресурсов игры), а другой (в клиенте игры) разжимать все это обратно в любой удобный формат. Вполне вероятно, что такой массив данных удачно сожмется каким нибудь RLE.
            Но это, конечно, не изменит размер данных в оперативке, просто уменьшит вес клиента.
            • 0
              Статья как раз об уменьшении данных в оперативке. С занимаемым местом на диске проблем как раз нет. Файл из примера на диске занимает 20 килобайт, а в памяти 1 мегабайт. Использование 565 с прозрачным цветом уменьшает до 512 килобайт. Если текстур много, то для не самых новых девайсов это будет существенная разница. В данный момент на диске все текстуры занимают 400 килобайт (и можно ещё ужать), а в памяти уже 5 мегабайт. С данным шейдером они уменьшаются до 2.5 мегабайт. Некоторые текстуры будут пережаты в ETC1, чтобы в памяти ещё меньше занимать.
            • 0
              Немного не правильно посчитал, забыл умножить на 4 байта. 400КБ на диске в памяти занимают 20 мегабайт. В формате 565 с colorkey занимают 10 мегабайт.
              • 0
                Я, может быть, скажу сейчас какую-нибудь глупость… Но если цветов всего 20, может быть имеет смысл упаковать текстуру в любой формат, самый наименьший. А шейдеру подсовывать палитру для коррекции, одномерную текстуру. Или просто настроить коррекцию по палитре. Будет идеальный цвет на выходе и минимальная разрядность тестуры в памяти. В вашем случае в 1 8888 текстуру можно было бы упаковать в каждый канал по 1 спрайту, у которого будет по 256 цветов + палитра. Экономия выйдет больше, точность идеальная.
                • 0
                  Совершенно верно! Так и сделано в самой игре, но это тема для второй статьи. Плюс, там рассказывается, как именно подготовить текстуру, чтобы она занимала 1 байт на пиксель. Просто это намного сложнее, чем кажется (нет нативной поддержки палитризованных изображений), поэтому это будет отдельная статья. Вернее, с точки зрения программирования сложностей никаких, но вот подготовить текстуру индексов и текстуру с палитрой не так просто, как хотелось бы.
                  Плюс в игре реализованы некоторые визуальные эффекты за счёт динамического изменения палитры. Но я сейчас доделыванием игры занят и не хочу отвлекаться на статью, но она обязательно будет. Позже.)

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