ASCII‑art Шейдер на Unity для новичков

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

В закладки
Слушать

Совсем недавно вышла статья в разделе геймдева, которая должна была нам объяснить как добиться такой магии:

Однако ничего кроме сложных слов из - разряда Luminance, Lut текстура и pikabu я в ней не нашёл. Она и побудила меня попробовать написать шейдер, пусть и откладывал это дело я наверное месяц.

Реализация данного шейдера будет состоять из пары шагов:

1) Разбивание изображения камеры на монолитные прямоугольники равного размера.

2) Замещение этих прямоугольников текстурами символов, подбираемых в зависимости от цвета.

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

Подготовка к работе

Для начала создадим простой Unlit Shader, и удалим оттуда всё лишнее (связанное с туманом). А также вместо return col для начала вернём свой кастомный цвет float(0, 0, 0, 1). Получаем на выходе:

Shader "Unlit/AsciiImageEffect" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); return float4(0, 0, 0, 1); //return col; } ENDCG } } }

Обратите внимание на блок Properties - сюда мы будем кидать поля, блок над функцией v2f vert - здесь мы будем инициализировать поля, а также на функцию fixed4 frag - в ней мы с вами будем срать кодом.

Несмотря на то, что шейдер у нас уже готов, надо заставить камеру его как то рендерить. Для этого создаём скрипт - AsciiCamera, и переопределяем у него метод OnRenderImage, заставляя нашу камеру использовать кастомный материал. Кодом это будет выглядеть так:

public class AsciiCamera : MonoBehaviour { public Material material; void OnRenderImage(RenderTexture source, RenderTexture destination) { Graphics.Blit(source, destination, material); } }

Теперь создаём материал, и устанавливаем ему наш шейдер. На самой же сцене вешаем наш AsciiCamera скрипт на камеру, и линкуем материал внутрь монобеха. Если вы всё сделали правильно, то вы увидете, что камера рендерит чёрный экран. Ну разве не прекрасно?

Когда с приготовлениями покончено, можно в функции frag раскомментировать правильный ретурн, и убрать строку с возвратом float4(0, 0, 0, 1) куда подальше.

Шаг 1

Итак, мы подошли к первому шагу. Нам необходимо разбить исходное изображение:

На пиксели. Выставить видеоряд в 144р так сказать.

Для этого нам подойдёт функция frag из шейдера. Видите код:

fixed4 col = tex2D(_MainTex, i.uv);

Здесь наш шейдер берёт конкретный пиксель экрана. Uv - это по факту координата пикселя, а _MainTex - текстура, в данном случае наше изображение с камеры. Следует учесть, что uv - это относительная величина. Т.е. самый левый верхний пиксель имеет координату (0,0), а самый нижний правый - (1,1).

Давайте введём в шейдер такие понятия, как ширина и высота. В блоке properties шейдера, прямо под строкой "_MainTex ("Texture", 2D) = "white" {}" добавим вот такой код:

Width("Width", float) = 1024 Height("Height", float) = 768

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

private void Update() { material.SetFloat("Width", Screen.width); material.SetFloat("Height", Screen.height); }

А также над функцией шейдера v2f vert добавим 2 соответствующих поля:

sampler2D _MainTex; //Новые поля float Width; float Height; //Искомая функция v2f vert (appdata v)

Вообще говоря куда вы их поместите не принципиально, важно только чтобы они были в блоке Pass, и имели такое же имя и тип как в Property.

Теперь создайте для шейдера 2 параметра, ширины и высоты ячейки (CellWidth и CellHeight), на этот раз сами. Когда всё будет готово, вернёмся наконец к нашей функции frag. По факту нам нужно разбить всё изображение на блоки, и сделать, чтобы каждый блок был монолитного цвета. Это очень важно. Цветом блока я решил выбрать центральный пиксель. Код будет выглядеть вот так:

fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); //Берём номер пикселя. Функция ceil - берёт целую часть от результата float pixelX = ceil(i.uv.x * Width); float pixelY = ceil(i.uv.y * Height); //Берём оффсеты для рассчёта центрального пикселя блока float halfCellX = ceil(CellWidth / 2); float halfCellY = ceil(CellHeight / 2); //Возвращаем координату центрального элемента каждой ячейки float xColorPos = (ceil(pixelX / CellWidth) * CellWidth + halfCellX) / Width; float yColorPos = (ceil(pixelY / CellHeight) * CellHeight + halfCellY) / Height; return tex2D(_MainTex, float2(xColorPos, yColorPos)); }

В результате мы получаем такую картину:

​А также медаль за сжатие 3ей степени.

Шаг 2

Теперь нам необходимо заменить каждый блок из исходного изображения на символ соответсвующего цвета из второй текстуры. Немного повозившись в фотошопе я быстренько нарисовал себе таблицу символов 10х3.

В данной таблице строка и ряд ничего не значат. Это просто набор символов.

Добавим в шейдер текстуру с символами (AddTex("Texture", 2D) = "white" {} внутри Property, и sampler2D AddTex в Pass'e').

Символ, который будет рисоваться вместо блока будет определяться следующим образом - суммируем rgb цвета исходного блока, и остаток от деления на 10 - это индекс по X символа, а на 3 - это Y замещаемого символа. В конечном итоге наша функция frag должна превратиться вот в такого небольшого монстра:

fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float pixelX = ceil(i.uv.x * Width); float pixelY = ceil(i.uv.y * Height); float halfCellX = ceil(CellWidth / 2); float halfCellY = ceil(CellHeight / 2); float xColorPos = (ceil(pixelX / CellWidth) * CellWidth + halfCellX) / Width; float yColorPos = (ceil(pixelY / CellHeight) * CellHeight + halfCellY) / Height; fixed4 resultCol = tex2D(_MainTex, float2(xColorPos, yColorPos)); //Cчитаем индекс пикселя внутри ячейки float pixelXIndex = (i.uv.x * Width) % CellWidth; float pixelYIndex = (i.uv.y * Height) % CellHeight; //Рассчитываем оффсеты для взятия символа из второй текстуры. Если их не брать - то замещение будет только на первый символ из таблицы float sum = resultCol.r + resultCol.g + resultCol.b; float xOffset = ceil(sum / 0.1) * 0.1; float yOffset = ceil(sum / 0.333) * 0.333; //Берём координату первого символа из таблицы и добавляем к ней оффсет float2 percentPosition = i.uv; percentPosition.x = pixelXIndex / CellWidth / 10 + xOffset; percentPosition.y = pixelYIndex / CellHeight / 3 + yOffset; //Возвращаем конкретный пиксель по указанным координатам уже со второй текстуры return tex2D(AddTex, percentPosition); }

Результат? Ну, он немного стрёмный:

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

Немного перерисовав символы у меня получилось добиться такого результата:

На этом всё, пойду играть в Апекс. Ведь это у меня получается лучше чем написание шейдеров и статей. А вам желаю хорошо провести остаток этого воскресенья!

Upd: Залил улучшенную версию на гитхаб:

{ "author_name": "Игнат Емельянов", "author_type": "self", "tags": ["pragma","include"], "comments": 39, "likes": 135, "favorites": 302, "is_advertisement": false, "subsite_label": "gamedev", "id": 103609, "is_wide": false, "is_ugc": true, "date": "Sun, 16 Feb 2020 11:14:49 +0300", "is_special": false }
0
39 комментариев
Популярные
По порядку
Написать комментарий...
1

Эй! А где unreal engine?

Ответить
49

Когда мне был 21 год я мечтал делать трипл А на анриле, но скоро я осознал что деньги не пахнут и начал делать мобилки на юнити...

Ответить
0

Ок. Кстати, есть такой на ue?

Ответить
0

Комментарий удален по просьбе пользователя

Ответить
0

Наверное 30

Ответить
0

Можно  делать не ААА а оригинальные проекты, главное силы оценивать свои заранее)

Ответить
1

ООО!!! Спасибо огромное!!!
Опять придётся садиться ковырять немножко шейдеры в юнити =)

Ответить
1

Тоже так делаю когда появляется статья о них)

Ответить
1

Просто поковырять можно и онлайн (:

https://www.shadertoy.com/view/wt3XR4

Ответить
0

Знаю)) Там очень много крутых вещей. Но я переключаюсь на такие вещи только когда это прям интересно. Потом интерес может улететь и я снова переключаюсь на другое.)) Сейчас вот это можно глянуть. Для собственного развития)))

Ответить
0

Пажжи, если может быть слеплена из говна и палок то зачем код, там же есть Shader Graph 🤔

Ответить
3

Shader Graph

слеплен 

из говна и палок

поправил 

Ответить
2

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

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

Ответить
0

У шейдер графа ужасный функционал, он спустя 2 года до сих пор амплифай даже близко не догоняет. То, что в шейдерграфе пайплайны захардкожены и он у тебя сломается, если ты SRP попробуешь переписать — вообще смехота. Вообще все, что касается SRP-стэка кроме, наверное, VFX-графа, полный провал и ужас, который уже сейчас надо переписывать с нуля с реальной гибкостью в основе.

Ответить
0

Привет! Я вот только увидел ваш пост, здорово, что мои мысли без примеров кода побудили Вас сделать шейдер. Кстати, Вы могли бы погуглить что такое "luminance" (грубо говоря это просто grayscale значение исходной картинки), и учесть ее в вашем шейдере вместо рандомного выбора из таблицы, было бы красивее.

Ответить
1

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

Ответить
0

Статья не для новичков, максимум они код копирнут) принцип работы для новичка будет не тривиален, много мусора в коде тоже делу не поможет. И мне кажется можно было атлас сделать однострочным, код был бы проще, яркость можно считать юнитевскими функциями 

Ответить
0

На самом деле я использовал в конечном итоге однострочный атлас, а блоки сделал квадратными, как итог код стал ещё меньше и понятнее, а результат стал бомбическим. Выше в комментах есть ссылка на гифку. Просто мне стало влом переписывать статью. 
А вот считать яркость юнитёвскими функциями - это как забивать гвозди головой. Просто берёшь (0.3 * R) + (0.59 * G) + (0.11 * B) в качестве отступа, и сэмплишь по однострочной таблице.

Ответить
0

Очень реквестую автора таки дописать и допилить статью, потому что задел просто отличный

Ответить
1

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

Ответить
0

DTF, DTF никогда не меняется...
Вариант с гитом тоже хороший, мое почтение

Ответить
1

Ковыряйся на здоровье:
https://github.com/Ignatus911/AsciiShader

Ответить
0

Так она это и делает) 

Ответить
0

Точка самому темному? Эмм ... по моему гораздо логичнее самому темному цвету задать — пробел , то есть отсутствие символа . 

Ответить
0

Логично. Так и сделал. Вышло куда круче.

Ответить
0

Результат в студию 

Ответить
0

Как всегда добавлю в закладки, которые никогда не читаю)

Ответить
0

@Игнат Емельянов замени самые темные области не на точку, а вообще на пустоту. Тогда добьешься такой же картинки, как в роликах Watch_Dogs 2

Ответить
0

Ссылка битая

Ответить
0

Интересно, а если так

Ответить
0

Ага, двоеточие замени на точку : —> . 

Ответить
0

Конечно. Вы сначала хоть почитайте статью, или хотя бы сравните картинки.

Ответить
0

Ну, тип, в статье можно же написать "техническая реализация {ссылка на статью}".
Даже на превьюшку то же изображение кинули.

Ответить
0

Классная статья, вообще отличная. "Всем здарова. Есть ASCII. Это круто. Из них можно делать преколы. Еще из них делали арты для кейгенов. Я бы сделал прекол нарисовав два овала, а потом сову. Всё пока"

Ответить
0

Там дан алгоритм. Ну, и людям то она понравилась, один хрен.

Ответить
1

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

Ответить

Комментарии

{ "jsPath": "/static/build/dtf.ru/specials/DeliveryCheats/js/all.min.js?v=05.02.2020", "cssPath": "/static/build/dtf.ru/specials/DeliveryCheats/styles/all.min.css?v=05.02.2020", "fontsPath": "https://fonts.googleapis.com/css?family=Roboto+Mono:400,700,700i&subset=cyrillic" }