Gamedev Глеб Диденко
1 176

Анимированное изображение с помощью шейдера Unity

Как вдохнуть жизнь в картинку, нарисованную от руки.

В закладки

Для необычного внешнего вида игре иногда достаточно постобработки. Инди-разработчик Раду Муресан (Radu Muresan) опубликовал в блоге на портале Gamasutra заметку, в которой подробно описал создание шейдера, анимирующего статическое изображение.

DTF публикует перевод статьи.

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

...получить это:

В первую очередь упомяну клип Take on Me группы A-ha, который послужил источником вдохновения.

В моей игре Semispheres — органичный, «текучий» визуальный стиль: всё слегка движется и «плывёт», поэтому я решил, что статичная картинка будет смотреться на этом фоне неуместно.

Поговорим об этом шейдере. Мы пошагово пройдём процесс настройки и пронаблюдаем, как всё приходит к финальному результату. Весь код будет работать в Unity, а его идею можно «перевести» на другие диалекты шейдеров.

Мы начнём с шейдера с функцией опроса текстуры, её фрагмент будет выглядеть так:

fixed4 frag_mult(v2f_vct i) : SV_Target{
return tex2D(_MainTex, i.texcoord);
}

Это даст результат, близкий к первому изображению в статье.

Теперь заменим нарисованные вручную линии. Для этого мы воспользуемся дополнительной текстурой штриховки. Такую легко можно найти в Google по запросу «hatch texture». Не забывайте, что от лицензии, по которой распространяется изображение, зависит, в каком типе проектов его можно использовать. Лучше, если текстура будет бесшовной — это избавит вас от артефактов при тайлинге.

Когда вы выбрали текстуру и добавили её в проект, можно вписать строчку в группу «Properties» поверх шейдера:

_HatchTex("Hatch Tex", 2D) = "white" {}

И соответствующую переменную:

sampler2D _HatchTex;

Если сомневаетесь, просто найдите вашу главную текстуру и следуйте примеру (в нашем случае — _MainTex).

Теперь мы можем сэмплировать (sample) эту текстуру, чтобы изменить результат.

return tex2D(_HatchTex, i.texcoord);

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

return tex2D(_HatchTex, i.texcoord * 10);

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

Вот как это выглядит:

Слишком плотно. Остановимся на значении 3 вместо 10:

Гораздо приемлемее. Отклоняясь от темы: когда пробуете разные значения в шейдере, постарайтесь следовать правилу «Double or halve» (из статьи Сида Мейера — значения стоит увеличивать или уменьшать вдвое, чтобы лучше понимать результат). Так вы будете замечать изменения и прослеживать их зависимость от ваших действий.

Впрочем, это не важно, если вы уже получили нужное изображение. Попытаемся улучшить результат, совместив две картинки. Мы сделаем это с помощью альфа-композитинга (или альфа-смешивания).

Есть разные варианты формулы, вот тот, что использую я:

fixed4 alphaBlend(fixed4 dst, fixed4 src) {
fixed4 result = fixed4(0, 0, 0, 0);
result.a = src.a + dst.a*(1 - src.a);
if (result.a != 0)
result.rgb = (src.rgb*src.a + dst.rgb*dst.a*(1 - src.a)) / result.a;
return result;
}

Добавив эту функцию в шейдер, можно изменить код фрагмента на:

fixed4 original = tex2D(_MainTex, i.texcoord);
fixed4 hatch = tex2D(_HatchTex, i.texcoord*3);
return alphaBlend(original, hatch);

Разделим изображение на основе оригинала. Способ в той или иной степени будет зависеть от того, «запечён» ли чёрный фон в картинку, или он прозрачен. Главное — штриховка должна отображаться только там, где на оригинальном изображении есть рисунок.

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

fixed4 original = tex2D(_MainTex, i.texcoord);
fixed4 output = fixed4(0, 0, 0, 1);
fixed4 hatch;
if (original.a > .1) {
hatch = tex2D(_HatchTex, i.texcoord * 7);
output = alphaBlend(output, hatch*.8);
}
return output;

Код будет выводить текстуру штрихов на всех участках оригинального изображения, где что-то есть (значение альфы больше 0.1).

До того, как мы продолжим, давайте подробнее рассмотрим, что зависит от конкретных переменных.

«.1» в «original.a > .1» определяет отсечение. Вот что будет, если использовать значение «.5»:

Заметьте, насколько слабее закрашено изображение.

«7» в «i.texcoord * 7» определяет частоту линий, уменьшение значения до «3» даст следующий результат:

И последнее — значение «.8» в «hatch*.8» определяет то, насколько влияет шейдер на изображение. Повышение значения, например, до «1.3», сделает штриховку более «интенсивной»:

Теперь, когда мы понимаем, как всё устроено, давайте поработаем со слоями. Добавим ещё один слой для альфы больше .4, с другой частотой линий (9) и более высоким влиянием (1.2):

if (original.a > .4) {
hatch = tex2D(_HatchTex, i.texcoord * 9);
output = alphaBlend(output, hatch*1.2);
}

Выглядит это так:

Уже лучше. Ещё один похожий участок:

if (original.a > .8) {
hatch = tex2D(_HatchTex, i.texcoord * 1.2);
output = alphaBlend(output, hatch*1.5);
}

Просто для проверки — так теперь выглядит полный код фрагмента:

fixed4 frag_mult(v2f_vct i) : SV_Target{
fixed4 original = tex2D(_MainTex, i.texcoord);
fixed4 output = fixed4(0, 0, 0, 1);
fixed4 hatch;
if (original.a > .1) {
hatch = tex2D(_HatchTex, i.texcoord * 7);
output = alphaBlend(output, hatch*.8);
}
if (original.a > .4) {
hatch = tex2D(_HatchTex, i.texcoord * 9);
output = alphaBlend(output, hatch*1.2);
}
if (original.a > .8) {
hatch = tex2D(_HatchTex, i.texcoord * 10);
output = alphaBlend(output, hatch*1.5);
}
return output;
}

Отлично. Получилось интересно, но абсолютно статично. Исправим это.

Анимируем штрихи так, чтобы они постоянно немного менялись. Для понятности процесса вернёмся к простому отображению текстуры штриховки:

return tex2D(_HatchTex, i.texcoord);

Представим, что мы распределяем текстуру штриха по таблице в три строки и три столбца — получится девять похожих «мини-текстур». Вот пример:

float row = 0;
float col = 0;
float2 adjustedTexCoord = (i.texcoord / 3) + float2(row / 3, col / 3);
return tex2D(_HatchTex, adjustedTexCoord);

Можно «поиграть» с параметрами row и col, присваивая им значения 0, 1 или 2 и получая другие «мини-текстуры». Таким образом мы ограничиваем вариации текстур значением 3, идущим от 0-1 к 0-1/3, потом добавляем 0, 1/3 или 2/3, приводя их к одной из девяти мини-текстур.

Попробуем анимацию, привязанную ко времени. Чтобы всё упростить, добавим участок кода, демонстрирующий текстуру по колонкам и строкам в функцию:

float2 texLookup(float2 texcoord, float row, float col) {
row = row % 3;
col = col % 3;
return (texcoord / 3) + float2(row/3, col/3);
}

Заметьте, «% 3» позволяет пропускать любые интегральные значения и приводит их к 0, 1 или 2. Теперь остаётся только использовать некоторые интегральные вычисления.

Самый простой способ сделать это — использовать параметр времени, применяющийся к шейдеру каждый кадр. В Unity переменная the _Time заполняется разными значениями, например, компонент «y» будет содержать время с момента загрузки уровня.

Попробуем менять текстуры каждую секунду или около того:

floor(_Time.y % 9)

Вышло медленно, давайте ускорим процесс в 10 раз:

float texIndex = floor(_Time.y*10 % 9);
float row = 1 + texIndex % 2;
float col = floor(texIndex / 3);
return tex2D(_HatchTex, texLookup(i.texcoord, row, col));

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

texLookup(i.texcoord*20, row, col)

Заметьте, значение «multiplication» мы выставляем на 20 (вместо 7, как мы делали вначале) – поскольку теперь мы должны учитывать, что текстура штриховки разделена на 9 частей:

Что-то уже получается. Совместим это с предыдущей многослойной отрисовкой. Код фрагмента будет выглядеть так:

fixed4 frag_mult(v2f_vct i) : SV_Target{
fixed4 original = tex2D(_MainTex, i.texcoord);
fixed4 output = fixed4(0, 0, 0, 1);
fixed4 hatch;
float texIndex = floor(_Time.y * 10 % 9);
float row = 1 + texIndex % 2;
float col = floor(texIndex / 3);
if (original.a > .1) {
hatch = tex2D(_HatchTex, texLookup(i.texcoord * 21, row, col));
output = alphaBlend(output, hatch*.8);
}
if (original.a > .4) {
hatch = tex2D(_HatchTex, texLookup(i.texcoord * 27, row+1, col));
output = alphaBlend(output, hatch*1.2);
}
if (original.a > .8) {
hatch = tex2D(_HatchTex, texLookup(i.texcoord * 30, row, col+1));
output = alphaBlend(output, hatch*1.5);
}
return output;
}

Ещё раз, заметьте, как мы умножили стартовые значения на 3 для отображения штриховки. Эти цифры можно менять для лучшего результата, а вот что получилось у нас:

Приближаемся к завершению. Когда я впервые столкнулся с этим шейдером, мне казалось, что чего-то не хватает. В последний раз вернёмся к «мольберту». Начнём с функции, выводящей простую картинку. После мы используем технику, которая детально разбиралась в одной из моих предыдущих статей — «волны воздуха над огнём». В ней текстура шума (noise texture) использовалась для смещения выводимой текстуры. Вот преувеличенная версия, чтобы сделать эффект очевиднее:

float2 displacedTexCoord = i.texcoord +
tex2D(_NoiseTex, i.vertex.xy/700 + float2((_Time.w%10) / 10, (_Time.w%10) / 10)) / 400;
return tex2D(_MainTex, displacedTexCoord) * i.color;

Слишком «рябит»:

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

Чтобы не противоречить первой части статьи, сделаем картинку менее «текучей» и заставим её «прыгать»:

floor((_Time.y * 5) % 5)

Значение будет меняться 5 раз в секунду, и если мы добавим его вместо завышенного «10» в примере выше, результат будет выглядеть так:

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

И наконец, после добавления полупрозрачного градиента в игре он выглядит так:

#арт #опыт #unity

Материал опубликован пользователем. Нажмите кнопку «Написать», чтобы поделиться мнением или рассказать о своём проекте.

Написать
{ "author_name": "Глеб Диденко", "author_type": "self", "tags": ["unity","\u043e\u043f\u044b\u0442","\u0430\u0440\u0442"], "comments": 8, "likes": 33, "favorites": 20, "is_advertisement": false, "subsite_label": "gamedev", "id": 5202, "is_wide": false, "is_ugc": true, "date": "Thu, 16 Mar 2017 14:56:32 +0300" }
{ "id": 5202, "author_id": 5399, "diff_limit": 1000, "urls": {"diff":"\/comments\/5202\/get","add":"\/comments\/5202\/add","edit":"\/comments\/edit","remove":"\/admin\/comments\/remove","pin":"\/admin\/comments\/pin","get4edit":"\/comments\/get4edit","complain":"\/comments\/complain","load_more":"\/comments\/loading\/5202"}, "attach_limit": 2, "max_comment_text_length": 5000, "subsite_id": 64954, "possessions": [] }

8 комментариев 8 комм.

Популярные

По порядку

Написать комментарий...
4

Спасибо DTF за то, что есть функция "добавить в избранное". Жаль только, что таких полезных статей тут меньше, чем всего остального.

Ответить
3

очень прикольная статья ) спасиб

Ответить
0

Форматирования и подсветки кода очень не хватает.

Ответить
0

Код постарались выделить, есть предложения, как это можно сделать лучше и просто?

Ответить
0

Если только скриншотом кода с IDE.) Только код придется вручную переписывать.
Было бы проще при наличии интеграции с каким-нибудь онлайн-сервисом для загрузки фрагментов кода (типа Pastebin и тому подобное). Это уже скорее пожелания для админа.

Ответить
0

Шейдер необоснованно тяжелый. Особенно для фулскрина. Можно попроще похожий эффект получить. Но за статью спасибо, побольше бы таких, технических. Пока хабр рулит в этом плане.

Ответить
0

Да. И 80lvl.
Добавьте уже редактирование)

Ответить
0

Прямой эфир

[ { "id": 1, "label": "100%×150_Branding_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox_method": "createAdaptive", "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfl" } } }, { "id": 2, "label": "1200х400", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfn" } } }, { "id": 3, "label": "240х200 _ТГБ_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fizc" } } }, { "id": 4, "label": "240х200_mobile", "provider": "adfox", "adaptive": [ "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "flbq" } } }, { "id": 5, "label": "300x500_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfk" } } }, { "id": 6, "label": "1180х250_Interpool_баннер над комментариями_Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "ffyh" } } }, { "id": 7, "label": "Article Footer 100%_desktop_mobile", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjxb" } } }, { "id": 8, "label": "Fullscreen Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjoh" } } }, { "id": 9, "label": "Fullscreen Mobile", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjog" } } }, { "id": 10, "label": "Native Partner Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyb" } } }, { "id": 11, "label": "Native Partner Mobile", "provider": "adfox", "adaptive": [ "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyc" } } }, { "id": 12, "label": "Кнопка в шапке", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fdhx" } } }, { "id": 13, "label": "DM InPage Video PartnerCode", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox_method": "createAdaptive", "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "flvn" } } }, { "id": 14, "label": "Yandex context video banner", "provider": "yandex", "yandex": { "block_id": "VI-250597-0", "render_to": "inpage_VI-250597-0-1134314964", "adfox_url": "//ads.adfox.ru/228129/getCode?pp=h&ps=clmf&p2=fpjw&puid1=&puid2=&puid3=&puid4=&puid8=&puid9=&puid10=&puid21=&puid22=&puid31=&puid32=&puid33=&fmt=1&dl={REFERER}&pr=" } }, { "id": 15, "label": "Плашка на главной", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "p1": "byudo", "p2": "ftjf" } } }, { "id": 17, "label": "Stratum Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvb" } } }, { "id": 18, "label": "Stratum Mobile", "provider": "adfox", "adaptive": [ "tablet", "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvc" } } } ]
В Steam появилась функция продажи
подержанных цифровых копий игр
Подписаться на push-уведомления