Большинство технических художников на каком-то этапе карьеры пытается создать правдоподобные отражения каустики. Если вы разработчик игр, то одна из основных причин чтения Twitter заключается в бесконечном потоке вдохновения, которое из него можно почерпать. Несколько дней назад Флориан Гельценлихтер (kolyaTQ в twitter) опубликовал GIF эффекта каустики, созданного в Unity при помощи шейдеров. Пост (представлен ниже) быстро набрал 1,5 тысячи лайков, что показывает искренний интерес к подобного типа контенту.


Хотя меня обычно больше привлекают более длинные и технически сложные серии статей (например, про объёмное атмосферное рассеяние света [перевод на Хабре] и инверсную кинематику [первая и вторая части перевода на Хабре]), я не смог удержаться перед искушением написать короткий и милый туториал об эффектах Флориана.

В конце этой статьи есть ссылка на скачивание пакета Unity и всех необходимых ассетов.

Что такое каустика


Возможно, вам неизвестно понятие каустики, хотя вы и ежедневно встречаетесь с этим эффектом. Каустика — это отражения света, вызванные искривлёнными поверхностями. В общем случае любая искривлённая поверхность может вести себя подобно линзе, фокусируя свет на одних точках и рассеивая его в других. Самыми распространёнными средами, обеспечивающими создание такого эффекта, являются стекло и вода, порождающая так называемую каустику волн (см. ниже).


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

Анатомия эффекта


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

Флориану удалось создать правдоподобный эффект, начав с одной текстуры каустики. Для создания своего туториала я использовал показанную ниже текстуру, взятую с OpenGameArt.


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

Получив текстуру, Флориан предлагает сделать три шага:

  • Дважды наложить паттерн каустики на поверхность модели, каждый раз используя разные размеры и скорости
  • Смешать два паттерна при помощи оператора min
  • Разделить RGB-каналы при сэмплировании.

Давайте посмотрим, как можно реализовать каждый из этапов в Unity.

Создание шейдера


Первый шаг — это создание нового шейдера. Поскольку этот эффект скорее всего будет использоваться в 3D-игре, в которой также присутствует реальное освещение, лучше всего начать с поверхностного шейдера. Поверхностные шейдеры (Surface shaders) — это один из множества типов шейдеров, поддерживаемых Unity (к прочим относятся вершинные и фрагментные шейдеры для неосвещённых материалов, экранные шейдеры для эффектов постобработки и вычислительные шейдеры для симуляций за пределами экрана).

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

Давайте создадим два свойства шейдера:

Properties
{
    ...

    [Header(Caustics)]
    _CausticsTex("Caustics (RGB)", 2D) = "white" {}
		
    // Tiling X, Tiling Y, Offset X, Offset Y
    _Caustics_ST("Caustics ST", Vector) = (1,1,0,0)
}

и соответствующие им Cg-переменные:

sampler2D _CausticsTex;
float4 _Caustics_ST;

Свойства шейдера соответствуют полям, отображаемым в инспекторе материалов Unity. Соответствующие им Cg-переменные — это сами значения, которые можно использовать в коде шейдера.

Как видно из кода выше, _Caustics_ST является float4, то есть содержит четыре значения. Мы будем использовать их для управления сэмплированием текстуры каустики. А именно:

  • _Caustics_ST.x: масштаб текстуры каустики по оси X;
  • _Caustics_ST.y: масштаб текстуры каустики по оси Y;
  • _Caustics_ST.z: смещение текстуры каустики по оси X;
  • _Caustics_ST.w: смещение текстуры каустики по оси Y;.

Почему переменная называется _Caustics_ST?
Если у вас уже есть небольшой опыт работы с шейдерами, то вы уже видели другие свойства, заканчивающиеся суффиксом _ST. В Unity _ST можно использовать для добавления дополнительной информации о том, как сэмплируется текстура.

Например, если создать Cg-переменную _MainTex_ST, то её можно использовать для задания размера и смещения при наложении текстуры на модель.

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

Сэмплирование текстуры


Каждый поверхностный шейдер содержит функцию, обычно называемую surf, которая используется для определения цвета каждого отрисовываемого пикселя. «Стандартная» функция surf выглядит так:

void surf (Input IN, inout SurfaceOutputStandard o)
{
    // Albedo comes from a texture tinted by color
    fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    // Metallic and smoothness come from slider variables
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
}

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

В только что созданном поверхностном шейдере albedo берётся из текстуры под названием _MainTex. Поскольку эффект каустики накладывается поверх существующей текстуры, нам придётся выполнять дополнительное сэмплирование текстуры в _CausticsTex.

Техника под названием UV-наложение позволяет понять, какую часть текстуры надо сэмплировать в зависимости от того, какую часть геометрии нужно рендерить. Это выполняется при помощи uv_MainTex — переменной float2, хранящейся в каждой вершине 3D-модели и обозначающей координату текстуры.

Наша задумка заключается в использовании _Caustics_ST для масштабирования и смещения uv_MainTex для растяжения и перемещения текстуры каустики по модели.

void surf (Input IN, inout SurfaceOutputStandard o)
{
    // Albedo comes from a texture tinted by color
    fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;

    // Caustics sampling
    fixed2 uv = IN.uv_MainTex * _Caustics_ST.xy + _Caustics_ST.zw;
    fixed3 caustics = tex2D(_CausticsTex, uv).rgb;

    // Add
    o.Albedo.rgb += caustics;

    // Metallic and smoothness come from slider variables
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
}

Что будет, если Albedo превысит 1?
В представленном выше коде мы складываем две текстуры. Цвет обычно находится в интервале от $0$ до $1$, однако нет гарантии, что в результате некоторые значения не превысят этот интервал.

В старых шейдерах это могло вызвать проблему. Здесь же это на самом деле особенность. Если значение цвета пикселя превышает единицу, то это значит, что его влияние должно «растечься» за его границы и повлиять на соседние пиксели.

Именно это и происходит, когда получаются очень яркие зеркальные отражения. Однако такой эффект не должен создаваться только поверхностным шейдером. Чтобы эффект работал, у камеры должен быть включён HDR. Это свойство расшифровывается как High Dynamic Range; оно позволяет цветовым значениям превышать $1$. Также для размытия излишнего количества цветов на соседние пиксели потребуется эффект постобработки.

Unity имеет свой собственный стек постобработки, в котором имеется фильтр bloom, выполняющий именно эту задачу. Подробнее об этом можно прочитать в блоге Unity: PostFX v2 – Amazing visuals, upgraded.

Предварительные результаты показаны ниже:


Анимированная каустика


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

Анимирование материалов в шейдерах можно реализовать при помощи предоставляемого Unity свойства _Time. Его можно использовать для доступа к текущему игровому времени, то есть добавлять в уравнения время.

Проще всего будет просто смещать текстуру на основании текущего времени.

// Caustics UV
fixed2 uv = IN.uv_MainTex * _Caustics_ST.xy + _Caustics_ST.zw;
uv += _CausticsSpeed * _Time.y;

// Sampling
fixed3 caustics = tex2D(_CausticsTex, uv).rgb;

// Add
o.Albedo.rgb += caustics;

Поле _Time.y содержит текущее игровое время в секундах. Если отражение движется слишком быстро, то можно домножить его на коэффициент. Для этого в представленном выше коде использована переменная _CausticsSpeed типа float2.

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

Показанные ниже результаты по-прежнему довольно посредственные. Это нормально: нам ещё многое предстоит сделать, чтобы отражения выглядели красивыми.


Множественное сэмплирование


Эффект становится живым, если сэмплировать текстуры каустики не один, а два раза. Если наложить их одну на другую и двигать с разными скоростями, то результат будет совершенно иным.

Первым делом мы дублируем свойства _Caustics_ST и _CausticsSpeed, чтобы сэмплы двух текстур имели разные масштабы, смещения и скорости:

[Header(Caustics)]
_CausticsTex("Caustics (RGB)", 2D) = "white" {}
		
// Tiling X, Tiling Y, Offset X, Offset Y
_Caustics1_ST("Caustics 1 ST", Vector) = (1,1,0,0)
_Caustics2_ST("Caustics 1 ST", Vector) = (1,1,0,0)

// Speed X, Speed Y
_Caustics1_Speed("Caustics 1 Speed", Vector) = (1, 1, 0 ,0)
_Caustics2_Speed("Caustics 2 Speed", Vector) = (1, 1, 0 ,0)

Теперь, когда у нас есть два сэмпла каустики, их можно смешать при помощи оператора min. Если просто взять среднее значение, то результат будет не очень хорошим.

// Caustics samplings
fixed3 caustics1 = ...
fixed3 caustics2 = ...

// Blend
o.Albedo.rgb += min(caustics1, caustics2);

Такое небольшое изменение приводит к огромной разнице:


Чтобы код оставался красивым, можно также обернуть код сэмплирования каустик в собственную функцию:

// Caustics
fixed3 c1 = causticsSample(_CausticsTex, IN.uv_MainTex, _Caustics1_ST, _Caustics1_Speed);
fixed3 c2 = causticsSample(_CausticsTex, IN.uv_MainTex, _Caustics2_ST, _Caustics2_Speed);

o.Albedo.rgb += min(c1, c2);

Разделение RGB


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

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

Давайте начнём с добавления свойства _SplitRGB, обозначающего силу эффекта резделения:

// Caustics UV
fixed2 uv = IN.uv_MainTex * _Caustics_ST.xy + _Caustics_ST.zw;
uv += _CausticsSpeed * _Time.y;

// RGB split
fixed s = _SplitRGB;
fixed r = tex2D(tex, uv + fixed2(+s, +s)).r;
fixed g = tex2D(tex, uv + fixed2(+s, -s)).g;
fixed b = tex2D(tex, uv + fixed2(-s, -s)).b;

fixed3 caustics = fixed3(r, g, b);

Величину смещения каналов RGB можно выбирать произвольно, но даже при использовании такого простого смещения получается очень убедительная картинка:


Заключение и скачиваемые материалы


Если вам интересно узнать, как можно создавать бесшовные текстуры каустики, то стоит прочитать интересную статью Periodic Caustic Textures.

Тем временем Флориан продолжает работать над своим шейдером каустики и добился довольно интересных улучшений, которые можно увидеть.


Полный пакет по этому туториалу доступен на Patreon, в него включены все необходимые для воссоздания данной методики ассеты. Пакет экспортирован из Unity 2019.2 и требует Postprocessing Stack v2.

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


  1. DrZlodberg
    19.09.2019 15:00

    Визуально немного напоминает 3D диаграмму Вороного. Или 4D для анимации. Чуть неравномерно размыть, чуть искривить и вполне похоже. Несколько затратно по вычислениям, но на Shadertoy есть рабочие примеры.


  1. darkdaskin
    19.09.2019 17:14
    +1

    Часто использование одной бесшовной текстуры приводит к заметным глазу квадратам при взгляде на такую плоскость издалека. Во многих играх большие водоёмы из-за этого выглядят плохо. Интересно, будет ли здесь тот же эффект или он нивелируется множественным сэмплированием?


    1. 16tomatotonns
      22.09.2019 04:18

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

      14мб гифка
      image


  1. shuhray
    19.09.2019 23:18

    А вот объясните мне, пожалуйста, такую тему. В DirectX можно читать из текстуры в режиме Sample(Linear, координаты), если координаты попадают между двумя текселями, берётся линейная комбинация соответствующих цветов. Можно ли сделать это в OpenGL? Там есть функция texture и два параметра текстуры — GL_TEXTURE_MIN_FILTER и GL_TEXTURE_MAG_FILTER, пытался их задавать и так, и этак безуспешно. Я рисую нечто во фреймбуфер, а потом оттуда читаю, причём надо делать линейную интерполяцию соседних текселей, есть чужие шейдеры на DirectX, где это делается так.


    1. shuhray
      20.09.2019 01:23

      Чуть поясню: мне надо брать линейную комбинацию текселя с одним из его соседей (сглаживание краёв методом GAA). Коэффициэнты линейной комбинации вычислены заранее. В DirectX можно задать координаты текселя с подходящим сдвигом и линейная комбинация получится автоматически. Можно ли это сделать в OpenGL?


    1. indieperson
      20.09.2019 11:25

      Можно. Вызываешь glTexParameteri ( target, GL_TEXTURE_MIN_FILTER, GL_LINEAR), где target — "тип" или, по-другому, "точка биндинга" текстуры, которая ссылается на целевую текстуру, для которой ты устанавливаешь параметр.


      Если ты создал текстуру свою как GL_TEXTURE_2D, то target = GL_TEXTURE_2D, аналогично с другими типами текстур, GL_TEXTURE_3D или GL_TEXTURE_CUBE_MAP.


      Важно перед вызовом функции забиндить свою целевую текстуру к нужному типу (точке биндинга): glBindTexture(GL_TEXTURE_2D, myTextureUintId), где первый параметр — точка биндинга (тип твоей текстуры), а второй параметр — id текстуры (имя в терминологии OpenGL), который вернула glGenTexture.