Сегодня я расскажу о реализации шейдера, позволяющего сделать fade in/out по палитре, как это делалось в старых NES-играх и т.п.
Суть состоит в том, что при наличии ограниченной палитры цветов нельзя было постепенно затемнить (или наоборот вывести из темноты) картинку, т.к. просто напросто не существовало нужных цветов в палитре. И это решалось путем использования разных цветов, которые воспринимаются как более темные. То есть нужно вам сделать fade in желтого объекта, а в палитре нет темно-желтых оттенков — вот и приходится сначала делать объект синим (выглядит темным), потом — красным и т.п.
Ниже я покажу, как выглядит конечный вариант написанного шейдера:
![](https://habrastorage.org/getpro/habr/post_images/b44/ec1/d1f/b44ec1d1f78b349acd274cfa285cfced.gif)
Сразу только оговорюсь — применять или нет подобный шейдер в нашей игре, мы еще не решили. Так как выглядит он на современном пиксель-арте с большим количеством цветов, немного спорно.
Итак, для начала напишем болванку шейдера:
А теперь давайте подумаем…
Для начала — немного теории. Как я уже сказал выше, цвета воспринимаются по-разному. Синий воспринимается самым темным и т.д. Вообще, если вы возьмете таблицу настройки телевизора и посмотрите ее на ч/б телевизоре — то она будет упорядочена от cветлого к темному:
![](https://habrastorage.org/r/w1560/getpro/habr/post_images/47b/51e/9aa/47b51e9aae7515e42063367fff8eb8a2.png)
Описывается такая конвертация цвета в ч/б волшебной формулой:
Шейдер будет работать следующим образом: он будет брать исходное изображение, изменять его яркость (понижать), а дальше пытаться найти цвет из палитры доступных, который бы был наиболее близок по яркости. То есть по сути, шейдер делится на две части: 1) опустить яркость и 2) выбрать цвет из палитры.
С опусканием яркости все просто — мы будем примитивно умножать цвет на коэффициент. А вот с поиском ближайшего цвета в палитре — сложнее.
Те, кто знаком с шейдерами, понимают, что любой цикл в шейдере — равносильно самоубийству. Так что перебирать палитру в поисках подходящего цвета каждого пикселя — плохая идея. Как же быть?
Решение просто и изящно — создать текстуру, которая бы служила конвертером цвета. И очень удачно, что существует такая штука, как трехмерные текстуры. То есть мы берем и заранее вычисляем таблицу конвертации исходного цвета в индекс цвета в палитре. А еще лучше — сразу в конечный цвет. В такой текстуре по трем осям будут расположены значения компанент R/G/B, а цвет пикселя в этой точке и будет нашим результирующим цветом. Все просто! Осталось только создать такую текстуру.
Конечно, для точной конвертации цвета пришлось бы создавать монстропободную текстуру, где размерность по каждой из осей соответствовала бы количеству градаций каждой компаненты. То есть 256х256х256. Но в нашем случае нам совсем не важна точность, т.к. мы занимаемся тем, что как раз понижаем цветовую глубину и сводим все цвета до нескольких цветов в палитре.
Итак, для начала давайте создадим палитру и сразу же для каждого цвета запомним его яркость:
Стоит обратить внимание на то, что я в конечном итоге поделил R компаненту на 2, т.к. мне не понравилось, что в результирующей палитре красный цвет уж очень «выпирал».
А теперь — самое интересное. Нужно создать 3D текстуру для конвертации.
Ну, собственно осталось еще написать сам шейдер, но тут все просто:
Тут стоит обратить внимание на строчку с
Собственно, все.
Конечный вариант:
Стоит еще отметить что в коде выше я ввел такой параметр, как „random“. Это было сделано для того, чтобы иметь простую возможность перестраивать таблицу цветов на лету и можно было удобнее подбирать параметры палитры. То есть поменял код, генерирующий палитру и сдвинув ползунок „random“ заставил игру перегенерить палитру.
Все статьи серии:
Суть состоит в том, что при наличии ограниченной палитры цветов нельзя было постепенно затемнить (или наоборот вывести из темноты) картинку, т.к. просто напросто не существовало нужных цветов в палитре. И это решалось путем использования разных цветов, которые воспринимаются как более темные. То есть нужно вам сделать fade in желтого объекта, а в палитре нет темно-желтых оттенков — вот и приходится сначала делать объект синим (выглядит темным), потом — красным и т.п.
Ниже я покажу, как выглядит конечный вариант написанного шейдера:
![](https://habrastorage.org/getpro/habr/post_images/b44/ec1/d1f/b44ec1d1f78b349acd274cfa285cfced.gif)
Сразу только оговорюсь — применять или нет подобный шейдер в нашей игре, мы еще не решили. Так как выглядит он на современном пиксель-арте с большим количеством цветов, немного спорно.
Итак, для начала напишем болванку шейдера:
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ветлого к темному:
![](https://habrastorage.org/getpro/habr/post_images/47b/51e/9aa/47b51e9aae7515e42063367fff8eb8a2.png)
Описывается такая конвертация цвета в ч/б волшебной формулой:
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“ заставил игру перегенерить палитру.
Все статьи серии:
- Идея, вижен, выбор сеттинга, платформы, модели распространения и т.п
- Шейдеры для стилизации картинки под ЭЛТ/LCD
- Прикручиваем скриптовый язык к Unity (UniLua)
- Шейдер для fade in по палитре (а-ля NES)
- Промежуточный итог (прототип)
- Поговорим о пиаре инди игр
- 2D-анимации в Unity («как во флэше»)
- Визуальное скриптование кат-сцен в Unity (uScript)