Lospec шейдер в Unity для Demake проектов

Для пары конкурсных проектов мне понадобился механизм, который бы позволил мне получить в игре картинку из далекого прошлого. Низкое разрешение и малое количество цветов. Если с разрешением все более или менее просто — достаточно организовать рендер в текстуру малого размера, — то с цветами оказалось не все так однозначно. Да, в URP Unity есть механизм LUT, который технически может подвести картинку к нужному результату. Но все же строгих ограничений на рендер цветов он не накладывает. А хотелось бы.

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

Для начала нужно понять, каким образом мы получим картинку низкого качества на современном движке и оборудовании. Схема работв в Unity будет довольно проста:

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

Возьмем шаблонный проект 3D URP в Unity и создадим новую RenderTexture

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

Теперь настроим нашу камеру. Отключаем всякие сглаживания и в Output Texture закидываем нашу текстуру, созданную ранее.

Во вкладке Game мы сразу получим страшное сообщение, что на экран не выводит ни одна камера. Это нестрашно, нас не пугает. Это сообщение можно просто подавить, чтобы оно не раздражало. Достаточно справа вверху нажать три точки и выключить его:

Теперь нам нужно добавить Полноэкранную картинку, которая будет нам рисовать наше новое обработанное изображение. Для этого на сцене добавляем обьект UI-RawImage. Вместе с ней добавится и Панель. Это нормально 🙂

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

В поле Texture укажем нашу текстуру.

Однако мы помним, что у нас давно экраны с соотношением сторон 16:9, но ведь мы олдскулим в 320х240, а это 4:3. Картинка наша сиииильно растянется по горизонтали. В Unity есть инструмент, исправляющий это. Добавим компонент Aspect Ratio Fitter на нашу RawImage и вставим в поле Aspect Ratio значение 1.33, что практически соответствует 4:3

Итак, с разрешением мы вроде справились, во вкладке Game должно получиться что-то вроде этого:

Прекрасная отвратительно убогая картинка. Как нам и нужно.

Начинаем самое интересное — нам нужен шейдер, обрезающий палитру.

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

Итак, сделаем заготовку. Создадим новый шейдер и назовем его LospecPalette и приведем его в чистый вид, как на правой картинке. :

Так как мы крутые разработчики, то заменим слово Hidden в имени шейдера на Rikovmike, например (Оставлять Hidden не рекомендую, так как потом очень мучительно будет его искать в списке шейдеров при настройке материала)

Итак, мы решили, что нам понадобится как минимум 2 параметра. Это текстура входящая, наша рендер-текстура. И палитра, которую мы тоже будем брать из текстуры. Будем использовать однопиксельные картинки с сайта lospec.com в качестве источника палитры.

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

В блок properties вписываем параметры:

Properties {
	_ColorCount ("Color Count", Int) = 8
	_MainTex ("RenderTex", 2D) = "white" {}
	_PaletteTex ("Palette", 2D) = "white" {}
}

Теперь немного настроим сам шейдер. Перед блоком Pass впишем ключи:

Lighting Off
ZTest Always
Cull Off
ZWrite Off
Fog { Mode Off }

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

В начало блока Pass вписываем ряд флагов и подключаем библиотеки:

CGPROGRAM
#pragma exclude_renderers flash
#pragma vertex vert_img
#pragma fragment frag
#pragma fragmentoption ARB_precision_hint_fastest
#include "UnityCG.cginc"

И объявляем переменные из наших параметров

uniform int _ColorCount;
uniform sampler2D _MainTex;
uniform sampler2D _PaletteTex;

Дальше пишем код фрагментного шейдера. Пишем функцию:

fixed4 frag (v2f_img i) : COLOR
{
}

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

fixed3 original = tex2D (_MainTex, i.uv).rgb;

Этим мы получаем цвет из нашей рендер-текстуры в координатах фрагмента (помним, что проход будет от первого и к последнему)

Далее определим выходное значение цвета, которое мы будем выдавать. Изначально он будет нулевой, так как мы еще не знаем что должно быть на выходе:

fixed4 col = fixed4 (0,0,0,0);

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

fixed dist = 10000000.0;

Дальше пишем обыкновенный цикл, в котором перебираем пиксели палитры (вот тут то нам и пригодится наша переменная количества цветов)

for (int ii = 0; ii < _ColorCount; ii++) {
	fixed4 c = tex2D(_PaletteTex,float2(ii,0)/float2(_ColorCount,1));
	fixed d = distance(original, c);
	if (d < dist) {
		dist = d;
		col = c;
	}
}

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

По окончании цикла нам достаточно вернуть последнее сохраненное значение цвета

return col;

Для корректной компиляции после блока Pass поставим ENDCG, а так же после блока SubShader вставим строчку FallBack «Diffuse».

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

Итак, полный текст шейдера должен получиться таким:

Shader "Rikovmike/LospecPalette"
{
	Properties{
		_ColorCount("Color Count", Int) = 8
		_MainTex("RenderTex", 2D) = "white" {}
		_PaletteTex("Palette", 2D) = "white" {}
	}

		SubShader{
			Lighting Off
			ZTest Always
			Cull Off
			ZWrite Off
			Fog { Mode Off }

			Pass {
				CGPROGRAM
				#pragma exclude_renderers flash
				#pragma vertex vert_img
				#pragma fragment frag
				#pragma fragmentoption ARB_precision_hint_fastest
				#include "UnityCG.cginc"

				uniform int _ColorCount;
				uniform sampler2D _MainTex;
				uniform sampler2D _PaletteTex;

				fixed4 frag(v2f_img i) : COLOR
				{
					fixed3 original = tex2D(_MainTex, i.uv).rgb;
					fixed4 col = fixed4(0,0,0,0);
					fixed dist = 10000000.0;

					for (int ii = 0; ii < _ColorCount; ii++) {
						fixed4 c = tex2D(_PaletteTex,float2(ii,0) / float2(_ColorCount,1));
						fixed d = distance(original, c);
						if (d < dist) {
							dist = d;
							col = c;
						}
					}

					return col;
				}
				ENDCG
			}
	}
		FallBack "Diffuse"
}

Теперь нужно заставить его работать. Для этого создадим новый материал.
В свойствах материала выберем в параметре «Shader» наш шейдер Rikovmike-LospecPalette

Мы увидим сразу наши параметры материала — 2 текстуры и число цветов. Для начала, идем на сайт lospec.com и качаем палитру. Я выбрал 8-цветную FUNKYFUTURE 8 . Скачиваем именно 1-пиксельную картинку:

И добавляем ее в ассеты нашего проекта, убираем в ее свойствах фильтрацию и сжатие текстуры (это невероятно важно!).

Далее, в настройках материала забрасываем в RenderTex нашу рендер-текстуру, в Palette — нашу картинку, скачанную с lospec.com, выставляем количество цветов 8 (ну или столько, сколько в скачанной вами палитре).

А теперь приготовимся к магии!

Открываем свойства нашего RawImage, убираем из поля Texture нашу текстуру, а в поле Material закидываем наш материал. Опа!

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

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

Метки: , , , , , , ,