Пользователь
0,0
рейтинг
20 февраля 2014 в 13:57

Разработка → Создание игры на ваших глазах — часть 4: Шейдер для fade in по палитре (а-ля NES) tutorial

Сегодня я расскажу о реализации шейдера, позволяющего сделать fade in/out по палитре, как это делалось в старых NES-играх и т.п.

Суть состоит в том, что при наличии ограниченной палитры цветов нельзя было постепенно затемнить (или наоборот вывести из темноты) картинку, т.к. просто напросто не существовало нужных цветов в палитре. И это решалось путем использования разных цветов, которые воспринимаются как более темные. То есть нужно вам сделать fade in желтого объекта, а в палитре нет темно-желтых оттенков — вот и приходится сначала делать объект синим (выглядит темным), потом — красным и т.п.

Ниже я покажу, как выглядит конечный вариант написанного шейдера:



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

Итак, для начала напишем болванку шейдера:
shader
Shader "Custom/Palette Shader" 
{
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
	}

	SubShader {
		Pass {
			ZTest Always Cull Off ZWrite Off Fog { Mode off }

			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag
			#pragma fragmentoption ARB_precision_hint_fastest
			#include "UnityCG.cginc"
			#pragma target 3.0

			struct v2f 
			{
				float4 pos      : POSITION;
				float2 uv       : TEXCOORD0;
			};

			uniform sampler2D _MainTex;

			v2f vert(appdata_img v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord);
				return o;
			}

			half4 frag(v2f i): COLOR
			{
				half4 color = tex2D(_MainTex, i.uv);

				// здесь будет код, преобразующий цвет

				half4 rc = color;
				return rc;
			}

			ENDCG
		}
	}
	FallBack "Diffuse"
}
c#
using UnityEngine;

[ExecuteInEditMode]
[RequireComponent(typeof(Camera))]

public class PaletteShader : MonoBehaviour 
{
	public Shader shader;
	private Material _material;
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	protected Material material
	{
		get
		{
			if (_material == null)
			{
				_material = new Material(shader);
				_material.hideFlags = HideFlags.HideAndDontSave;
			}

			return _material;
		}
	}
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	private void OnRenderImage(RenderTexture source, RenderTexture destination)
	{
		if (shader == null) return;
		Material mat = material;
		Graphics.Blit(source, destination, mat);
	}
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	void OnDisable()
	{
		if (_material)	DestroyImmediate(_material);
	}
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}


А теперь давайте подумаем…

Для начала — немного теории. Как я уже сказал выше, цвета воспринимаются по-разному. Синий воспринимается самым темным и т.д. Вообще, если вы возьмете таблицу настройки телевизора и посмотрите ее на ч/б телевизоре — то она будет упорядочена от cветлого к темному:



Описывается такая конвертация цвета в ч/б волшебной формулой: R*0.21 + G*0.72 + B*0.07. Будет называть этот параметр «яркость».

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

С опусканием яркости все просто — мы будем примитивно умножать цвет на коэффициент. А вот с поиском ближайшего цвета в палитре — сложнее.

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

Решение просто и изящно — создать текстуру, которая бы служила конвертером цвета. И очень удачно, что существует такая штука, как трехмерные текстуры. То есть мы берем и заранее вычисляем таблицу конвертации исходного цвета в индекс цвета в палитре. А еще лучше — сразу в конечный цвет. В такой текстуре по трем осям будут расположены значения компанент R/G/B, а цвет пикселя в этой точке и будет нашим результирующим цветом. Все просто! Осталось только создать такую текстуру.

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

Итак, для начала давайте создадим палитру и сразу же для каждого цвета запомним его яркость:

const int depth = 3; // кол-во градаций цвета в результирующей палитре
const float f_depth = 1.0f / (1.0f * depth - 1.0f);

Color[] palette = new Color[depth*depth*depth];
float[] palette_grey = new float[depth*depth*depth];

		// заполняем палитру используемых цветов
		for (int r = 0; r < depth; r++)
		{
			for (int g = 0; g < depth; g++)
			{
				for (int b = 0; b < depth; b++)
				{
					Color c = new Color(r * f_depth / 2, g * f_depth, b * f_depth, 1);
					int n = r*depth*depth + g*depth + b;
					palette[n] = c;
					palette_grey[n] = c.r*0.21f + c.g*0.72f + c.b*0.07f;
				}
			}
		}

Стоит обратить внимание на то, что я в конечном итоге поделил R компаненту на 2, т.к. мне не понравилось, что в результирующей палитре красный цвет уж очень «выпирал».

А теперь — самое интересное. Нужно создать 3D текстуру для конвертации.
const int dim = 16; // кол-во градаций цвета в исходной палитре
const float f_dim = 1.0f / (1.0f * dim - 1.0f);

Texture3D tex = new Texture3D(dim, dim, dim, TextureFormat.RGB565, false);
tex.filterMode = FilterMode.Point; // обязательно отключаем фильтрацию!
tex.wrapMode = TextureWrapMode.Clamp; 
Color[] t = new Color[dim*dim*dim];

// заполняем текстуру конвертирования
for (int r = 0; r < dim; r++)
{
	for (int g = 0; g < dim; g++)
	{
		for (int b = 0; b < dim; b++)
		{
			float grey = (r * 0.21f + g * 0.72f + b * 0.07f) * f_dim;
			// теперь найдем самый ближайший цвет по яркости серого
			int idx = 0;
			float min_d = grey;
			for (int i = 1; i < palette_grey.Length; i++)
			{
				float d = Mathf.Abs(palette_grey[i] - grey);
				if (d < min_d)
				{
					min_d = d;
					idx = i;
				}
			}
			t[r * dim * dim + g * dim + b] = palette[idx]; // заполним палитру конвертации
		}
	}
}

tex.SetPixels(t);
tex.Apply();

Ну, собственно осталось еще написать сам шейдер, но тут все просто:
half4 color = tex2D(_MainTex, i.uv);
half4 rc = tex3D(_PaletteTex, color.rgb * _Br);

float d = abs(Luminance(color) - Luminance(rc));

if ((d < 0.15) || (_Br == 1)) rc = color;

return rc;

Тут стоит обратить внимание на строчку с if. Второе условие — очевидно — «если яркость == 1, то возвращаем исходный цвет нетронутым». А вот первое — это некое условие, что «когда цвет из палитры довольно близок (в пределах 15%) к результирующему, то тоже оставлять исходный цвет. Это сделано для того, чтобы снизить некое ненужное „дребезжание“ цветов. Некий „snapping“, если будет угодно. И именно по этому вы можете видеть, что некоторые элементы на нашем скрине становятся своего цвета раньше конечной фазы. Иначе бы они до последнего были не своего цвета, а максимально близкого из палитры. Что плохо бы смотрелось для темных цветов.

Собственно, все.

Конечный вариант:
shader
Shader "Custom/Palette Shader" 
{
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_Br("Brightness", Float) = 0
		_PaletteTex ("Pelette texture", 3D) = "white" {} 
	}

	SubShader {
		Pass {
			ZTest Always Cull Off ZWrite Off Fog { Mode off }

			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag
			#pragma fragmentoption ARB_precision_hint_fastest
			#include "UnityCG.cginc"
			#pragma target 3.0

			struct v2f 
			{
				float4 pos      : POSITION;
				float2 uv       : TEXCOORD0;
			};

			uniform sampler2D _MainTex;
			uniform sampler3D _PaletteTex;
			uniform float _Br;

			v2f vert(appdata_img v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord);
				return o;
			}

			half4 frag(v2f i): COLOR
			{
				half4 color = tex2D(_MainTex, i.uv);
				half4 rc = tex3D(_PaletteTex, color.rgb * _Br);

				float d = abs(Luminance(color) - Luminance(rc));

				if ((d < 0.15) || (_Br == 1)) rc = color;

				return rc;
			}

			ENDCG
		}
	}
	FallBack "Diffuse"
}

c#
using UnityEngine;

[ExecuteInEditMode]
[RequireComponent(typeof(Camera))]

public class PaletteShader : MonoBehaviour 
{
	public Shader shader;
	private Material _material;

	[Range(0, 1)] public float brightness = 0.0f;
	[Range(0, 1)] public float random = 1f;

	private float _r = 0f;
	private Texture3D _tex;
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	protected Material material
	{
		get
		{
			if (_material == null)
			{
				_material = new Material(shader);
				_material.hideFlags = HideFlags.HideAndDontSave;
			}

			return _material;
		}
	}
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	private Texture3D GeneratePaletteTexture()
	{
		const int dim = 16; // кол-во градаций цвета в исходной палитре
		const int depth = 3; // кол-во градаций цвета в результирующей палитре

		const float f_dim = 1.0f / (1.0f * dim - 1.0f);
		const float f_depth = 1.0f / (1.0f * depth - 1.0f);

		Texture3D tex = new Texture3D(dim, dim, dim, TextureFormat.RGB565, false);
		tex.filterMode = FilterMode.Point;
		tex.wrapMode = TextureWrapMode.Clamp;

		Color[] palette = new Color[depth*depth*depth];
		float[] palette_grey = new float[depth*depth*depth];

		// заполняем палитру используемых цветов
		for (int r = 0; r < depth; r++)
		{
			for (int g = 0; g < depth; g++)
			{
				for (int b = 0; b < depth; b++)
				{
					Color c = new Color(r * f_depth / 2, g * f_depth, b * f_depth, 1);
					int n = r*depth*depth + g*depth + b;
					palette[n] = c;
					palette_grey[n] = c.r*0.21f + c.g*0.72f + c.b*0.07f;
				}
			}
		}

		Color[] t = new Color[dim*dim*dim];
		// заполняем текстуру конвертирования
		for (int r = 0; r < dim; r++)
		{
			for (int g = 0; g < dim; g++)
			{
				for (int b = 0; b < dim; b++)
				{
					float grey = (r * 0.21f + g * 0.72f + b * 0.07f) * f_dim;
					// теперь найдем самый ближайший цвет по яркости серого
					int idx = 0;
					float min_d = grey;
					for (int i = 1; i < palette_grey.Length; i++)
					{
						float d = Mathf.Abs(palette_grey[i] - grey);
						if (d < min_d)
						{
							min_d = d;
							idx = i;
						}
					}
					t[r * dim * dim + g * dim + b] = palette[idx]; // заполним палитру конвертации
				}
			}
		}

		tex.SetPixels(t);
		tex.Apply();
		return tex;
	}
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	private void OnRenderImage(RenderTexture source, RenderTexture destination)
	{
		if (shader == null) return;
		Material mat = material;
		mat.SetFloat("_Br", brightness);

		if (_tex == null) _tex = GeneratePaletteTexture();
		if (random != _r)
		{
			_r = random;
			_tex = GeneratePaletteTexture();
		}
		mat.SetTexture("_PaletteTex", _tex);

		Graphics.Blit(source, destination, mat);
	}
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	void OnDisable()
	{
		if (_material)	DestroyImmediate(_material);
	}
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}


Стоит еще отметить что в коде выше я ввел такой параметр, как „random“. Это было сделано для того, чтобы иметь простую возможность перестраивать таблицу цветов на лету и можно было удобнее подбирать параметры палитры. То есть поменял код, генерирующий палитру и сдвинув ползунок „random“ заставил игру перегенерить палитру.

Все статьи серии:
  1. Идея, вижен, выбор сеттинга, платформы, модели распространения и т.п
  2. Шейдеры для стилизации картинки под ЭЛТ/LCD
  3. Прикручиваем скриптовый язык к Unity (UniLua)
  4. Шейдер для fade in по палитре (а-ля NES)
  5. Промежуточный итог (прототип)
  6. Поговорим о пиаре инди игр
  7. 2D-анимации в Unity («как во флэше»)
  8. Визуальное скриптование кат-сцен в Unity (uScript)
Святослав @soulburner
карма
231,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

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

  • 0
    выглядит он на современном пиксель-арте с большим количеством цветов, немного спорно.

    Здесь трудно не согласиться. Если интересует стороннее мнение — не используйте. За статью спасибо
    • +1
      Сейчас думаем использовать на начальных титрах. Чтобы как на денди — белая надпись «KONAMI PRESENTS» фейдилась по палитре. Да и вообще — такой фейд смотрится хорошо в случае, когда есть большие области, окрашенные одним цветом.
  • 0
    В поздних досовских играх уже применялась технология замены палитры. Между рабочей палитрой и полностью темной создавалось несколько промежуточных палитр. В течение кого-то интервала палитры переключались с рабочей в полностью черную (fade out) и наоборот (fade in).
    Этот эффект возможно не такой ламповый, как ваш, но дискретность затемнения привносит ощущения древности.
    • 0
      Ну, тут не нужна палитра. Тут просто сделать дискретное изменение яркости. И будет этот выглядеть не столько лампово, сколько просто странно и дергано. Имхо, коненчо.
    • 0
      Ну собственно я так и делал под дос, когда учился демо-сцене, «затемнял» каждый спектр цвета в процентном соотношении…
  • +1
    [оффтоп] Ребята, честно скажу, очень рад что вы еще на плаву и дальше продолжаете делать качественные игры. Год назад читал вашу статью про городо-строительную игру которую вы создавали. Интересно было бы узнать, что с ней дальше случилось.
    • 0
      Спасибо. Городостроилку пришлось похоронить. Потом создали совместный проект под iOS с другой компанией. Ссылку на наш сайт публично давать не буду, но, думаю, вы сможете ее найти ;)
  • 0
    У меня такое было, когда на ноуте видеокарта полетела и все было черт знает как перевернуто в плане цветов :)
  • +2
    Было здорово добавить ссылки на предыдущие части статьи.
    • 0
      Держите =)

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