Создание игры на ваших глазах — часть 2: Шейдеры для стилизации картинки под ЭЛТ/LCD

Game developmentImage processingUnity3D
Tutorial
Поговорим на этот раз о технологии. В этой статье я расскажу и покажу, как в Unity создать шейдер для стилизации графики под старые ЭЛТ. Такой шейдер подойдет для пиксель-арта и для стилизации картинки под древнюю технику. Злоупотреблять им не стоит, но иногда использовать к месту — можно. (Специально уточню — я не предлагаю использовать такой эффект постоянно. Но, например, в заставках — он может прийтись к месту).



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



Что такое шейдеры и как они работают?


Тут нужно знать следующее: шейдер — это небольшая программа, выполняемая на процессоре видеокарты для каждой вершины (вершинные шейдеры) и для каждого отрисовываемого пикселя (пиксельные или «fragmental» шейдеры).

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

Важная ремарка. Каждый шейдер читает входные параметры и выдает выходные. При чем, происходит это в любом порядке. В современных картах большое количество шейдерных потоков, которые могут быть запущенны параллельно. То есть когда один шейдер обрабатывает пиксель по координатам (0, 0), другой в этот же момент может рассчитывать пиксель по координатам (10, 10).

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

В Unity можно использовать разные языки шейдеров, но я советую CG, т.к. он отлично компилится и в OpenGL и в DirectX. Соответственно, нам не надо писать два разных шейдера.

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

Итак, в бой


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

Для этого создадим новый шейдер (файл с расширением .shader) и скопируем туда эту болванку:

Shader "Custom/CRTShader" 
{
	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);
				return color;
			}

			ENDCG
		}
	}
	FallBack "Diffuse"
}

Поясню основные моменты:
  • Properties — описывает входные параметры шейдера (параметры, приходящие извне). На данный момент это только текстура.
  • vert — вершинный (vertex) шейдер, frag — пиксельный (fragmental)
  • struct v2f — описывает структуру данных, передаваемую из вертексного в пиксельный шейдеры
  • uniform — создает ссылку из языка шейдера на тот самый параметр(ы) из п.1
  • В нашем примере вершинный шейдер производит операцию с матрицей для вычисления координат вершины и координат для текстуры. Примем это за магию, которая работает ;)
  • В этом же примере пиксельный шейдер читает текстуру по координатам, полученным от вершинного шейдера (команда tex2D) и выдает результирующий color, который и пойдет в отрисовку.
  • В шейдерном языке часто нужны многокомпонентные структуры. Например, 3 координаты или 4 компоненты цвета. Для их описания используются типы вроде float2 (означает структуру из двух float'ов) или, например, int4. К компонентам же можно обращаться через точку .x .y .z .w или же .r .g .b .a

Осталось совсем чуть-чуть. Нам надо применить шейдер на камеру.

Для этого создадим еще управляющий скрипт на C#:
Скрытый текст
using UnityEngine;

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

public class TVShader : 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);
		}
	}
}

Осталось только накинуть его на камеру и в поле «shader» указать наш шейдер.

Как понять, что это дело работает? Попробуйте в шейдере перед "return color" написать что-нибудь вроде color.r = 0; и если все ок, то вы получить картинку без красного цвета.

Итак, с шейдерами разобрались.

Приступаем к реализации эффекта.


Что мы хотим добиться? Для начала, давайте попробуем реализовать эффект, когда изображение состоит из цветных RGB пикселей. То есть так:


Как сделать — довольно очевидно. Нужно с циклом в 3 пикселя оставлять только R, G и B компоненту у каждого пикселя на экране.

Задача №1 — получить в пиксельном шейдере экранные координаты текущей точки.

Для этого нам нужно будет посчитать кое-что в вершинном шейдере и пробросить это дело в пиксельный. Как было сказано выше, для обмена данными между вертексным и пиксельным шейдерами служит конструкция v2f в которой в данный момент два поля — pos и uv. Добавим туда:
float4 scr_pos	: TEXCOORD1;

а также добавим строчку в вершинный шейдер:
o.scr_pos = ComputeScreenPos(o.pos);

Теперь в пиксельном шейдере мы получим координату экрана в диапазоне от (0...1). Нам нужны пиксели. Это тоже делается просто:
float2 ps = i.scr_pos.xy *_ScreenParams.xy / i.scr_pos.w;

Ура! В ps мы имеем пиксельные координаты на экране. Дальше все достаточно просто. Нужно написать что-то вроде:
int pp = (int)ps.x % 3; // остаток от деления на 3
float4 outcolor = float4(0, 0, 0, 1);
if (pp == 1) outcolor.r = color.r; else if (pp == 2) outcolor.g = color.g; else outcolor.b = color.b;
return outcolor;

Получим что-то такое:


Сразу видно два момента — во-первых — эффект получился очень уж сильным, а во-вторых — картинка стала темнее. Слава богу, исправив первое — исправится и второе.

Предлагаю делать не жесткое разделение по R/G/B, а в любом случае оставлять все компоненты, просто в разной пропорции. То есть в «красном» столбике оставить 100% R, и около 50% G и B. А еще лучше, если мы сможем это дело настраивать.

По сути, наше преобразование можно сделать умножением цвета на некий мультипликатор. Чтобы оставить только R, нам нужно умножить color на float4(1, 0, 0, 1) (4-й компонент — альфа, ее мы не меняем). Мы же хотим настраивать коэффициенты. То есть умножать красный столбик на (1, k1, k2, 1), зеленый — на (k2, 1, k1, 1) и синий на (k1, k2, 1, 1).

Для начала, добавим описание двух параметров в самое начало шейдера:
_VertsColor("Verts fill color", Float) = 0
_VertsColor2("Verts fill color 2", Float) = 0

затем пропишем ссылки:
uniform float _VertsColor;
uniform float _VertsColor2;

Теперь идем в код пиксельного шейдера и произведем манипуляции с цветом:
if (pp == 1) { muls.r = 1; muls.g = _VertsColor2; }
else
	if (pp == 2) { muls.g = 1; muls.b = _VertsColor2; }
	else
		{ muls.b = 1; muls.r = _VertsColor2; }

color = color * muls;

Осталось только научиться управлять этими параметрами в Unity. Пропишем их в наш C#:
[Range(0, 1)]
public float verts_force = 0.0f;
[Range(0, 1)]
public float verts_force_2 = 0.0f;

И в метод OnRenderImage добавим перед Graphics.Blit:
mat.SetFloat("_VertsColor", 1-verts_force);
mat.SetFloat("_VertsColor2", 1-verts_force_2);

Здесь я вычитаю из 1, чтобы было более наглядно. Чем больше параметр — тем сильнее затемнение столбика.

Если вы все сделали правильно, то в инспекторе Unity при выборе камеры, у вас должны появиться ползунки:


Теперь посмотрим на эффект:


Лучше, но все равно хочется яркости. Давайте добавим регулировки яркости и контрастности в наш шейдер.
_Contrast("Contrast", Float) = 0
_Br("Brightness", Float) = 0
....
uniform float _Contrast;
uniform float _Br;
....
color += (_Br / 255);
color = color - _Contrast * (color - 1.0) * color *(color - 0.5); 

C# скрипт:
	[Range(-3, 20)]
	public float contrast = 0.0f;
	[Range(-200, 200)] 
	public float brightness = 0.0f;
...
	mat.SetFloat("_Contrast", contrast);
	mat.SetFloat("_Br", brightness);

Результат:

(значения contrast = 2.1, brightness = 27)

Теперь давайте реализуем scanlines. Тут вообще все просто. Каждый 3-й ряд нужно затемнять.
if ((int)ps.y % 3 == 0) muls *= float4(_ScansColor, _ScansColor, _ScansColor, 1);




А последним штрихом можно Bloom-эффект. Взять такой шейдер можно, например, здесь.

Готово! Мы получаем картинку из верха статьи!



Да, и конечно же — этот шейдер будет смотреться лучше всего именно на тройном пикселе, как у меня в примерах.

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

Все статьи серии:
  1. Идея, вижен, выбор сеттинга, платформы, модели распространения и т.п
  2. Шейдеры для стилизации картинки под ЭЛТ/LCD
  3. Прикручиваем скриптовый язык к Unity (UniLua)
  4. Шейдер для fade in по палитре (а-ля NES)
  5. Промежуточный итог (прототип)
  6. Поговорим о пиаре инди игр
  7. 2D-анимации в Unity («как во флэше»)
  8. Визуальное скриптование кат-сцен в Unity (uScript)
Tags:шейдерыunityshadersGLSLCRTvhstoryпримерытуториал
Hubs: Game development Image processing Unity3D
+94
72.5k 392
Comments 32

Popular right now

Профессия iOS-разработчик
March 1, 202190,000 ₽SkillFactory
Node.js: серверный JavaScript
March 1, 202127,000 ₽Loftschool
Основы HTML и CSS
March 1, 2021FreeНетология
Курс по аналитике данных
March 1, 202164,200 ₽SkillFactory