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

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

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

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

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

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

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

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

В первую очередь упомяну клип 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);

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

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

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

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

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

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

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

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

Гораздо приемлемее. Отклоняясь от темы: когда пробуете разные значения в шейдере, постарайтесь следовать правилу «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);

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

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

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

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).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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);

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

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

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» в примере выше, результат будет выглядеть так:

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

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

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

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

4

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

3

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

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

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

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