Введение
В своей работе я больше склоняюсь к стилизованной картинке, однако полученный в статье результат можно будет адаптировать и для реалистичного стиля/PBR. В этом посте мы поговорим о шейдере воды, относящемся к береговой линии, не касаясь материала песка и других деталей воды, например преломления и каустики (о них можно прочитать в Water Shader Breakdown или по другим ссылкам в разделе Water на странице Resources). Впрочем, шейдер выполняет и смешение прозрачности/альфы, чтобы затенить материал под ним для симуляции мокрого песка.
Мы поговорим о двух способах наложения волн в сцене: при помощи текстуры глубин и ручных UV. Ниже я перечислю замечания, плюсы и минусы каждого способа. В последующих разделах мы поработаем над ними по очереди.
-
Береговая полоса на основе глубин
Потенциально более простая схема, но в ней волны следуют/проецируются на землю под водой. Поэтому она может выглядеть корректной только на побережьях с достаточно плавным подъёмом и с видом сверху вниз.
При таком способе сложнее придать волнам конкретную форму/текстуру. Глубина образует градиент в направлении побережья, но не горизонтальный вдоль него. Можно накладывать текстуры (например шум) планарным образом в пространстве мира, но они не будут скроллиться в том же направлении, что и волны.
Кроме того, из-за этого шейдера волны/пена будут появляться на всех объектах, пересекающихся с водой. (В зависимости от ситуации, это может быть как плюсом, так и минусом)
-
Побережья на основе UV
Требует 3D-моделирования отдельного меша побережья/воды и ручной настройки соответствующих UV для правильного наложения волн.
Однако, в отличие от способа с глубинами, волны действительно находятся на поверхности воды и у вас больше контроля над их внешним видом (потому что можно накладывать текстуры на эти UV)
Волны/пена не появляются на других объектах. Можно также использовать глубину для отдельного управления этим пересечением (как в показанном ниже примере с пеной на краях) или применить альтернативный способ отображения взаимодействия с водой, например, создание частиц.
Побережье на основе глубин
Один из простейших способов реализации взаимодействия воды с остальной частью сцены — это использование текстуры глубин, например, очень часто применяемой методики «depth intersection / edge foam», которая встречается в туториалах по шейдерам мультяшной/стилизованной воды. Пример:
Или использование компонента W/A из сырой Screen Position в качестве входных данных B, что аналогично этой методике, но работает только для перспективных проекций, в то время как abs(positionVS.z)
должно работать и ортографических!
Однако при такой методике часто возникают искажения при вращении камеры, вероятно, из-за того, что значения глубин берутся относительно плоскости камеры.
Аналогично тому, как это сделано в Fog Plane Shader Breakdown, я обычно вместо этого воссоздаю позицию в мире из значения глубины, а затем беру её координату Y и вычитаю её из Y плоскости воды.
Если для GameObject плоскости воды не указан static batching, то мы можем получить его координату Y из нода Object (вывод Position), возвращающего точку начала координат меша. Или же можно использовать Float Property и просто управлять этим значением из инспектора материалов.
Таким образом мы получаем градиент глубины поверхности под водой, для которого можно для получения пены на краях выполнять Step аналогично предыдущему примеру. Но важнее здесь то, что поскольку у него нет искажения, то при условии наклонённости рельефа берега с его помощью можно накладывать и волны!
Опционально можно добавить после этого нод Multiply, чтобы применить масштаб, однако масштабирование тоже будет происходить в зависимости от крутизны спуска побережья.
Также можно использовать Saturate, чтобы ограничить все значения интервалом 0-1, а затем One Minus, чтобы инвертировать градиент и он соответствовал описанному ниже способу с UV, а графы в дальнейшем давали корректные результаты.
Этот результат может задействоваться много раз, поэтому если вы хотите избежать длинных запутанных соединений, то можете использовать мой пакет ShaderGraphVariables, чтобы добавить ноды Register Variable и Get Variable (он устанавливается через Package Manager -> Add package via git URL :
https://github.com/Cyanilux/ShaderGraphVariables.git
)
Побережье на основе UV
Ещё один способ получения градиента по направлению к берегу — использование специального меша, в котором можно отобразить цвета вершин (Vertex Colors) или особым образом развернуть UV. (Вероятно, это может сработать и с набором тайлов для процедурно генерируемой геометрии, например частей побережья, которые, объединяясь, образуют остров).
В отличие от способа с глубинами, этот позволит нам накладывать волны на поверхность воды, а не проецировать их внизу. Кроме того, использование UV также позволяет нам накладывать текстуры на волны для большего контроля за их внешним видом (также это потенциально может быть менее затратным, если избавиться от части вычислений для дополнительных текстур, но это уже вам придётся тестировать/профилировать самостоятельно).
Например, если вы моделируете воду линии побережья как полосу, то при создании меша для этого способа в Blender можно выполнить UV-развёртку следующим образом:
Выбрать один четырёхугольник в режиме выбора граней
-
Использовать UV (в меню или при помощи горячей клавиши U) → Reset
Также можно проверить ориентацию, наложив текстуру. В частности, для UV.y воды/моря я использую значение 0, а для побережья/песка — значение 1. Впрочем, это не так важно, потому что позже можно будет поменять оси или инвертировать значения в шейдере
Выбрать все четырёхугольники вдоль побережья
Удерживая shift, дважды щёлкнуть правой клавишей, чтобы отменить выделение и повторно применить выделение уже развёрнутого четырёхугольника (чтобы установить его в качестве «active face», если это ещё не сделано)
Использовать UV → Follow Active Quads
Опционально масштабировать UV по горизонтали в окне UV Editor (или можно применить масштабирование позже в шейдере)
Дополнительное моделирование/настройки
В Shader Graph сначала используем нод UV, а затем Swizzle (в моём случае по оси Y). (Или же используем Split и разъём G). Так мы получим градиент в направлении побережья/пляжа, что важно, если мы хотим накладывать волны и выполнять их скроллинг в этом направлении.
Вывод этой группы может использоваться многократно, но вместо длинных соединений можно просто дублировать группу, потому что она мала и не выполняет никаких вычислений.
Накатывающиеся волны
В некоторых виденных (и созданных) мной шейдерах стилизованной воды используются очень маленькие линии скроллинга, чтобы «растворять/комбинировать» пену на краях с остальной частью воды. Это та же техника, но обычно более сжатая и со скроллингом в обратном направлении.
Во-первых, мы можем добавить шума (или сэмплируя бесшовную текстуру шума, или процедурно при помощи нода Gradient Noise), чтобы немного исказить градиент, а затем использовать Time, чтобы волны скроллились/двигались. В этом случае нужно использовать Subtract, потому что градиент равен 0 на побережье, а в воде постепенно увеличивается до 1. (Если у вас происходит наоборот, то используйте для времени Add или One Minus для градиента, чтобы инвертировать его).
Для создания повторяющихся линий можно выполнить Multiply на какое-то значение (например, 3.2) и использовать один из двух вариантов:
Fraction (
frac
в hlsl), повторяющееся при каждом целочисленном значении-
Cosine, создающий линию на каждых
2*Pi
(Tau
), Sine тоже подойдёт, но будет смещён на1/2 Pi
Чтобы сделать это более согласованным, я использовал Multiply на Constant TAU, но можно и настроить это вручную, использовав другое значение масштабирования (например, 20 вместо 3.2)
Затем применяем Smoothstep для преобразования интервала значений, чтобы сделать линии тоньше. Также это ограничивает все отрицательные значения. (Или же можно использовать Inverse Lerp и Saturate)
Чтобы волны постепенно становились ярче, используем Smoothstep для градиента (например, с Edge1, равным 0, и Edge2 равным 0.5), а затем Multiply, чтобы применить это изменение
Также можно дополнительно маскировать волны шумом, чтобы сделать линии менее регулярными, как показано ниже. Если вы используете для градиента береговой полосы способ с глубинами, то вместо UV на меше воды может больше подойти planar mapping на плоскость XZ.
Чтобы применить цвета, я использую Lerp с градиентом побережья на вводе T, чтобы выполнить интерполяцию между синим и голубым; благодаря этому цвет становится светлее рядом с побережьем. Затем добавим ещё один Lerp, чтобы применить цвет пены волн (присвоим ему значение белого / 1,1,1,1)
Примечание: я использую градиент на основе UV, поэтому в превью цвета отображаются правильно. При использовании градиента на основе глубин превью может быть окрашено в сплошной цвет, но всё равно должно работать в сцене при проецировании на другие объекты.
Волны прибоя
В реальной жизни после столкновения волн с берегом вверх по побережью движется пенный слой воды, пока не потеряет свою энергию, после чего он возвращается в море. Проще всего симулировать это в шейдере, добавив ещё одну волну, которая движется вперёд и назад, синхронизированно со скроллингом остальных (хотя не буду врать, мне понадобилось много времени, чтобы разобраться, как это правильно синхронизировать).
Для обработки такого движения мы можем использовать нод Cosine (или Sine, если вы использовали его для накатывающихся волн). Это может не совсем походить на реальную жизнь: поток воды по направлению к берегу обычно длится меньше, чем при возврате в море, но мне кажется, этого вполне достаточно.
В качестве ввода нужно использовать Time, умноженное на те же Scroll Speed и и Wave Count (значение в группе «no. of waves»), что и в разделе «Накатывающиеся волны». Я в обоих местах преобразовал их в Float Properties, чтобы их можно было настраивать из материала и они всегда совпадали.
Также нам нужно выполнить Multiply на Constant TAU (если вы только не использовали для повторяющихся линий способ с Cosine/Sine и не удалили Divide ранее!)
Сами движения не будут выровненными, потому что одна волна синусоидальная, а вторая скроллится линейно, но их длительности одинаковы. Однако фаза пока может не совпадать, поэтому, как показано ниже нам также добавить (Add) свойство Float Property, которое мы назовём Time Offset. Им можно управлять из материала, поэтому мы можем вручную синхронизировать его. (Например, при использовании Wave Count 3.2 хорошим значением кажется приблизительно -2.5)
Так как Cosine возвращает значение от -1 до 1, мы выполняем Multiply на малое значение, чтобы настроить расстояние перемещения волны. Возможно, вы захотите, чтобы это значение зависело от Wave Count (например, вычислялось как 0.5 * (1/waveCount)
), но они не обязаны совпадать идеально. Я просто задал значение 0.1.
Мы применяем это к созданному ранее искажённому градиенту побережья при помощи нода Add, а затем используем Smoothstep для преобразования и позиционирования волны (центрированной примерно возле 0.8 вдоль этого градиента 0-1)
Комбинируем волны
Вывод этого блока можно скомбинировать с накатывающимися волнами при помощи нода Maximum, добавленного перед Lerp, применяющим цвет пены (группа «water vs (wave) foam»)
Кроме того, возьмём вывод группы «apply offset» и используем ещё один Smoothstep со значениями Edge1 Edge2 0.85 и 0.84, чтобы создать маску завершения воды/приливных волн. Мы временно используем всё это для вывода Alpha в Master Stack.
Здесь также можно использовать Step, но я выбрал Smoothstep, чтобы край был немного сглаженным. Существуют и более качественные способы создания сглаженной версии step при помощи fwidth/нода DDXY, например, как в статье Ronja.
Добавляем диаграмму Вороного
Всё может работать уже и так, но выглядит немного скучно, поэтому давайте добавим приливной волне текстуру. Я использую запечённый бесшовный Voronoi/Worley Noise. (Нод Voronoi тоже подойдёт, но здесь использовать текстуру, вероятно, будет менее затратно)
Для сэмплирования этой текстуры используем нод Sample Texture 2D. Как и в случае с шумом, при использовании способа на основе глубин, применение наложения на плоскость XZ для ввода UV подойдёт больше.
Для способа на основе UV можно также заставить текстуру двигаться вместе с приливной волной, воспользовавшись выводом группы «apply offset» из последнего скриншота с графом в качестве Y нода Vector2, а для X использовать R/X вывода UV
Чтобы скомбинировать эту текстуру с нашей волной, мы можем выполнить Multiply вывода R с малым значением (например, с 0.15), а затем добавить Add между группой «apply offset» и Smoothstep
Анимируем значения
Чтобы лучше анимировать волну, возьмём вывод Cosine / группы «swash motion» и преобразуем его в интервал 0-1 при помощи нода Inverse Lerp с входами A и B, равными -1 и 1. (Или можно добавить Multiply на 0.5, а затем Add 0.5)
Используем One Minus, чтобы инвертировать значение, что даст нам значение 0, когда волна вдали, и 1, когда она полностью достигла берега. Мы можем Multiply это значение на нашу текстуру voronoi (до Add), чтобы текстура проявлялась при приближении к берегу.
Чтобы добавить небольшой эффект «растворения», мы можем также анимировать Edge1 (нода Smoothstep), использовав вывод Inverse Lerp как T нода Lerp, задав A значение 0.7, а B — значение 0.8. (При желании вы можете настроить эти значения, но B не должно быть больше, чем вход Edge2, или же результаты Smoothstep станут противоположными!)
Мокрый песок
Я хотел имитировать достаточно важный эффект — смачивание приливной волной песка, который, по сути, становится темнее (и повышается его отражающая способность)
Вместо того, чтобы делать пиксели/фрагменты за приливной волной полностью прозрачными, мы можем использовать чёрный цвет с очень низкой альфой. Это основная причина того, что я выбрал альфа-смешение (alpha blending) в вместо смещения вершин (vertex displacement)
Для порта Base Color в Master Stack мы можем взять текущий результат и выполнить Multiply на то, что у нас сейчас есть в Alpha (вывод группы «sand (0) / water (1)»)
Для порта Alpha мы снова используем искажённый градиент (из начала раздела «Накатывающиеся волны»), и ещё один Smoothstep, чтобы замаскировать области, в которой песок должен быть мокрым. Также это нужно для того, чтобы мы не видели верхние грани геометрии/меша линии побережья.
Мы скомбинируем это с текущим значением альфа, использовав Add, а затем Saturate, чтобы значения не превышали 1. (Или же можно применить Maximum)
Чтобы вода и мокрый песок блестели, можно использовать дубликат этой системы для порта Smoothness, если применить тип Lit Graph, но со значением больше 0.2. Я редко работаю с PBR, поэтому оставлю это упражнение для вас.
Думаю, можно также вычислять блики вручную даже в Unlit Graph: в моём пакете Custom Lighting есть нод, который может помочь в этом при работе с URP.
Комментарии (3)
Jijiki
14.01.2025 06:57интересное решение, я заметил, главное плавность хода. амплитуды не много достаточно и тогда вроде с любым берегом человек подумает вода ну точно вода )))))
tas
Напомнило: https://habr.com/ru/companies/unigine/articles/167075/