Пользователь
0,0
рейтинг
4 февраля 2014 в 11:26

Разработка → Создание игры на ваших глазах — часть 2: Шейдеры для стилизации картинки под ЭЛТ/LCD 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)
Святослав @soulburner
карма
231,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

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

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

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

  • +53
    Техническая сторона вопроса интересна, за статью спасибо, плюсанул.

    А вот всё нижесказанное ИМХО, конечно, но…

    Сама идея использовать жуткие фильтры и эффекты вроде этого — ужасна-ужасна-ужасна-Ужасна-УЖАСНА. На реальном ЭЛТ-мониторе середины 90х картинка так не выглядела, это точно.

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

    Пожалуйста, не калечьте графику. Не учите людей плохому. Эти картинки справа страшны во всех примерах. Не надо в играх такими убогими фильтрами ухудшать графику. Пожалуйста. Отличный же писксельарт на картинке. Пожалуйста. Не делайте так в реальных продутках. Если у вас пиксельарт — используйте его, не надо дубовых сглаживаний, мыльной картинки и издевательств над глазами игрока. Не надо фильтров. Не надо блумов. Не надо мыла. Как игрок прошу…
    • +7
      Солидарен с предыдущим оратором. Все картинки справа выглядят ужасно.
    • +4
      Обратите внимание на абзац в начале статьи. Я же специально сказал — надо юзать к месту. Например, если вы хотите сымитировать такой эффект в каких-то заставках и т.п. Заставлять юзера играть в таком на постоянку — не тру, согласен.
      • 0
        Просто, возможно, стоило бы показать применение шейдеров на красивом эффекте. Ну скажем рисование шлейфа за изображением и/или пульсация (см. Принц Персии — Пески Времени, момент отматывания времени назад).
    • +3
      Мне больше напомнило экран моего GBA/GBA:SP. Вот один в один.
    • 0
      Иногда подобные фильтры поверх пиксельной графики дают свой позитивный эффект на атмосферу. Пример - Lone survivor, где обычная пиксельная графика смотрелась бы хуже. Но злоупотреблять, конечно, не стоит.
      • 0
        Спорно и на любителя. Я в Silent Hill 2 дальше всегда вырубал эффект «шума экрана». Не знаю лично ни одного человека, кто бы его оставлял. Может на телевизоре он и смотрится хорошо, но на мониторе — дико на любителя.

        А вот например эффект «потертой плёнки»+«не жесткий фильтр-сепия» из того же Nosferatu (игры) смотрится изумительно. Но опять же, отключается.
    • –3
      Полностью поддерживаю.
      • –1
        Так а что, кто-то спорит?
    • 0
      А я в детстве играл в сегу и денди на ЭЛТ телевизоре, поэтому для меня исходные картинки слева выглядят страшнее.
      • 0
        Я даже на PS3 какое-то время играл на ЭЛТ, не говоря уж про денди и сегу, но с вами позволю себе не согласиться.
    • 0
      Давно делаю игры, очень люблю это. Но часто вижу такое вот «не калечьте графику»… Ну блин, вот это мое творение, можна сказать произведение моей фантазии, стрим моего мира. Это как пойти к Ван Гогу, и сказать «Ну че ты краску переводишь, давай рисуй реалистично, а не эту размазню. Можешь ведь!» Я не говорю что игры над которыми я работаю в одной линейке с его произведениями, но чувствую я о них не меньше.
      Тут должна бить какая-то такая картинка: i.imgur.com/3Y4eCy1.png
  • –3
    В статье слово шейдер используется 48 раз, теперь мне кажется что оно как-то не так звучит.

    А по сабжу: соглашусь с оратором выше, что на ЭЛТ мониторах так не выглядела картинка, и мне гораздо более нравится левый вариант.
  • +8
    Заводим текстуру, например такую

    Настраиваем текстурные координаты так, чтобы она репителась столько раз, сколько разрешение «ЭЛТ-монитора» и просто умножаем выборку из нее, на выборку из текстуры кадра (возможно с неким коэффициентом).
    • +1
      3 прохода вместо 1?
      • +2
        1 проход рендерим в текстуру кадр слева (на картинке из поста), второй проход этот и все
        • +3
          Да, согласен, так изящнее и быстрее. И никаких if'ов
          • +6
            В Unity есть одна не очевидная вещь, которой можно проверять производительность шейдеров. Откройте собранный шейдер и найдите вот такие строки:

            Program «vp» {
            // Vertex combos: 1
            // opengl — ALU: 8 to 8
            // d3d9 — ALU: 8 to 8

            и

            Program «fp» {
            // Fragment combos: 1
            // opengl — ALU: 7 to 7, TEX: 3 to 3
            // d3d9 — ALU: 6 to 6, TEX: 3 to 3

            Здесь отображается количество циклов ALU, затраченное на обработку каждой вершины и пикселя соответственно. Таким образом можно сравнить разницу между вашим методом и методом, предложенным в комментах. Это скорее к вашей разработке, чем к статье, поскольку каждая операция сложения, умножения, функция cg, особенно в фрагментных шейдерах, делает ваш шейдер тяжелее.
            • +1
              спасибо большое, не знал!
  • +1
    Bloom — зло. С ним надо быть крайне осторожным.
  • +3
    Простите но этот фильтр создает иллюзию не ЭЛТ дисплея а дешевого STN экранчика. Я помню у меня был Sony Ericsson T630, так вот там примерно такая же картинка была.
    • 0
      Вот, спасибо, я наконец-то вспомнил, где я видел последнюю правую картинку! На своем К500, и было это уже почти десять лет назад.
  • +2
    Многие картинки нравятся. Но подобные эффекты должны быть отключаемыми.
  • 0
    Можете ли для наглядности выложить финальные тексты шейдера и скрипта?
    • 0
      Присоединяюсь.
      Все хорошо объяснили, но готовое и на блюдечке было бы ещё лучше.
  • 0
    ЭЛТ характерна искажниями и иным расположением субпикселей относительно ЖК.

    Для эффекта ЭЛТ я бы сделал немного иное. Выгнул бы бикубиком изображение, чтобы ближе к углам изображение было скругленным и попытался бы эмитировать RGB триаду. На ЭЛТ пиксели стояли триадами.

    RBRBRBRB
    _G_G_G_G_
    RBRBRBRB
    _G_G_G_G_

    Соотвественно такой алгоритм, как здесь, делает скорее старые сименсовые мобильники, нежели ЭЛТ.
    • +1
      image
    • 0
      Релизация мимикрирует под трубки Trinitron — это апертурная решетка, против теневой маски, которую вы описываете
      • 0
        В таком случае долой горизонтальное затенение каждые 3 пикселя.
  • 0
    ох, вот бы еще добавить небольшой эффект линзы рыбий глаз! =)
  • 0
    Аплодирую стоя.
    Рад быть 300м, кто добавил в избранное.

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