Математика в Gamedev по-простому. Кривые и рябь для эффекта дождя в Unity

    Всем привет! Меня зовут Гриша, и я основатель CGDevs. Продолжим говорить про математику что ли. Пожалуй, основное применение математики в геймдеве и компьютерной графики в целом – это VFX. Вот и поговорим про один такой эффект – дождь, а точнее про его основную часть, требующую математики – рябь на поверхности. Последовательно напишем шейдер для ряби на поверхности, и разберём его математику. Если интересно – добро пожаловать под кат. Гитхаб проект прилагается.



    Иногда наступает такой момент в жизни, когда программист должен взяться за бубен и призвать дождь. В целом сама по себе тема моделирования дождя очень глубокая. Существует множество математических работ по разным частям этого процесса от падения капли и эффектов связанных с этим до распределения капель в объёме. Разберём только один аспект – шейдер, который позволит нам создавать эффект похожий на волну от упавшей капли. Пора браться за бубен!


    Математика волны

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

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

    Уравнение плоской волны в нашем случае может быть записано как:

    Aresult = A * cos(2 * PI *(x / waveLength – t * frequency));
    Где:
    Aresult – амплитуда в точке x, в момент времени t
    А – максимальная амплитуда
    wavelength – длина волны
    frequency – частота волны
    PI – число ПИ = 3.14159 (float)

    Шейдер


    Поиграемся с шейдерами. За «верх» будет отвечать координата -Z. Так удобнее в 2D случае в Unity. При желании шейдер будет не трудно переписать на Y.

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

    r ^ 2 = x ^ 2 + y ^ 2

    нам понадобится радиус, так что уравнение приобретёт форму:

    r = sqrt(x ^ 2 + y ^2)

    и это даст нам симметрию относительно точки (0, 0) в меше, что сведёт всё к одномерному случаю плоской волны.

    Теперь напишем шейдер. Я не буду разбирать каждый шаг написания шейдера, так как это не цель статьи, но за основу берётся Standard Surface Shader из Unity, шаблон которого можно получить через Create->Shader->StandardSurfaceShader.

    Кроме этого, добавляются проперти необходимые для волнового уравнения: _Frequency, _WaveLength и _WaveHeight. Проперти _Timer (можно было бы использовать время с гпу, но при разработке и последующем анимировании удобнее его контролировать вручную.

    Напишем функцию getHeight получения высоты (сейчас это координата Z) подставив уравнение окружности в волновое уравнение

    Написав шейдер с нашим волновым уравнением и уравнением окружности — получим такой эффект.

    Код шейдера
    Shader "CGDevs/Rain/RainRipple"
    {
        Properties
        {
            _WaveHeight("Wave Height", float) = 1
            _WaveLength("Wave Length", float) = 1
            _Frequency("Frequency", float) = 1
            _Timer("Timer", Range(0,1)) = 0
            _Color ("Color", Color) = (1,1,1,1)
            
            _MainTex ("Albedo (RGB)", 2D) = "white" {}
            _Glossiness ("Smoothness", Range(0,1)) = 0
            _Metallic ("Metallic", Range(0,1)) = 0.0
        }
        SubShader
        {
            Tags { "RenderType"= "Opaque" }
            LOD 200
            
            CGPROGRAM
    
            #pragma surface surf Standard fullforwardshadows vertex:vert
            #pragma target 3.0
    
            sampler2D _MainTex;
    
            struct Input 
            {
                float2 uv_MainTex;
            };
            half _Glossiness, _Metallic, _Frequency, _Timer, _WaveLength, _WaveHeight;
            fixed4 _Color;
            
            half getHeight(half x, half y)
            {
                const float PI = 3.14159;
                half rad = sqrt(x * x + y * y);
                half wavefunc = _WaveHeight * cos(2 * PI * (_Frequency * _Timer - rad / _WaveLength));
                return wavefunc;
            }
            void vert (inout appdata_full v)  
            {
                 v.vertex.z -= getHeight(v.vertex.x, v.vertex.y);
            }
            void surf (Input IN, inout SurfaceOutputStandard o)
            {
                fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
                o.Albedo = c.rgb;
                o.Metallic = _Metallic;
                o.Smoothness = _Glossiness;
                o.Alpha = _Color.a;
            }
            ENDCG
        }
        FallBack "Diffuse"
    }
    



    Волны есть. Но хочется, чтобы анимация начиналась и заканчивалась плоскостью. В этом нам поможет функция синуса. Домножив амплитуду на sin(_Timer * PI) получим плавное появление и исчезновение волн. Так как _Timer принимает значения от 0 до 1, а синус в нуле и в PI равен нулю, это как раз то, что нужно.


    Пока совсем не похоже на падение капли. Проблема в том, что энергия волной теряется равномерно. Добавим проперти _Radius, которая будет отвечать за радиус действия эффекта. И домножим на амплитуду clamp(_Radius — rad, 0, 1) и получим уже эффект больше похожий на правду.


    Ну и заключительный шаг. То, что амплитуда в каждой отдельной точке достигает своего максимума в момент времени равный 0.5 не совсем верно, эту функцию лучше заменить.



    Тут мне стало немного лень считать, и я просто домножил синус на (1 — _Timer) и получил такую кривую.



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

    В итоге получился такой шейдер и эффект.

    Код шейдера
    Shader "CGDevs/Rain/RainRipple"
    {
        Properties
        {
            _WaveHeight("Wave Height", float) = 1
            _WaveLength("Wave Length", float) = 1
            _Frequency("Frequency", float) = 1
            _Radius("Radius", float) = 1
            _Timer("Timer", Range(0,1)) = 0
            _Color ("Color", Color) = (1,1,1,1)
            
            _MainTex ("Albedo (RGB)", 2D) = "white" {}
            _Glossiness ("Smoothness", Range(0,1)) = 0
            _Metallic ("Metallic", Range(0,1)) = 0.0
        }
        SubShader
        {
            Tags { "RenderType"= "Opaque" }
            LOD 200
            
            CGPROGRAM
    
            #pragma surface surf Standard fullforwardshadows vertex:vert
            #pragma target 3.0
    
            sampler2D _MainTex;
    
            struct Input 
            {
                float2 uv_MainTex;
            };
            half _Glossiness, _Metallic, _Frequency, _Timer, _WaveLength, _WaveHeight, _Radius;
            fixed4 _Color;
            
            half getHeight(half x, half y)
            {
                const float PI = 3.14159;
                half rad = sqrt(x * x + y * y);
                half wavefunc = _WaveHeight * sin(_Timer * PI) * (1 - _Timer) * clamp(_Radius - rad, 0, 1)
                    * cos(2 * PI * (_Frequency * _Timer - rad / _WaveLength));
                return wavefunc;
            }
            void vert (inout appdata_full v)  
            {
                 v.vertex.z -= getHeight(v.vertex.x, v.vertex.y);
            }
            void surf (Input IN, inout SurfaceOutputStandard o)
            {
                fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
                o.Albedo = c.rgb;
                o.Metallic = _Metallic;
                o.Smoothness = _Glossiness;
                o.Alpha = _Color.a;
            }
            ENDCG
        }
        FallBack "Diffuse"
    }
    




    Сетка меша – это важно

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

    Правильно:



    Неправильно:



    Даже при вдвое большем числе полигонов второй меш даёт неправильный визуал (оба меша сгенерированы с помощь Triangle.Net, просто по разным алгоритмам).

    Финальный визуал


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

    Вот сам шейдер:

    Ripple Vertex with Pole
    Shader "CGDevs/Rain/Ripple Vertex with Pole"
    {
        Properties
        {
             _MainTex ("Albedo (RGB)", 2D) = "white" {}
             _Normal ("Bump Map", 2D) = "white" {}
             _Roughness ("Metallic", 2D) = "white" {}
             _Occlusion ("Occlusion", 2D) = "white" {}
    
            _PoleTexture("PoleTexture", 2D) = "white" {}
            _Color ("Color", Color) = (1,1,1,1)
            _Glossiness ("Smoothness", Range(0,1)) = 0
            _WaveMaxHeight("Wave Max Height", float) = 1
            _WaveMaxLength("Wave Length", float) = 1
            _Frequency("Frequency", float) = 1
            _Timer("Timer", Range(0,1)) = 0
            
            
        }
        SubShader
        {
            Tags {
            "IgnoreProjector" = "True"
                "RenderType" = "Opaque"}
            LOD 200
            CGPROGRAM
           
            #pragma surface surf Standard fullforwardshadows vertex:vert
            #pragma target 3.0
            
            sampler2D _PoleTexture, _MainTex, _Normal, _Roughness, _Occlusion;       
            half _Glossiness, _WaveMaxHeight, _Frequency, _Timer, _WaveMaxLength, _RefractionK;
            fixed4 _Color;
            
            struct Input 
            {
                float2 uv_MainTex;
            };
            
            half getHeight(half x, half y, half offetX, half offetY, half radius, half phase)
            {
                const float PI = 3.14159;
                half timer = _Timer + phase;
                half rad = sqrt((x - offetX) * (x - offetX) + (y - offetY) * (y - offetY));
                half A = _WaveMaxHeight 
                        * sin(_Timer * PI) * (1 - _Timer)
                        * (1 - timer) * radius;
                half wavefunc = cos(2 * PI * (_Frequency * timer - rad / _WaveMaxLength));
                return A * wavefunc;
            }
            
            void vert (inout appdata_full v)  
            { 
                float4 poleParams = tex2Dlod (_PoleTexture, float4(v.texcoord.xy, 0, 0));
                v.vertex.z += getHeight(v.vertex.x, v.vertex.y, (poleParams.r - 0.5) * 2, (poleParams.g - 0.5) * 2, poleParams.b , poleParams.a);
            }
             
            void surf (Input IN, inout SurfaceOutputStandard o)
            {
                o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb * _Color.rgb;
                o.Normal = UnpackNormal(tex2D(_Normal, IN.uv_MainTex));
                o.Metallic = tex2D(_Roughness, IN.uv_MainTex).rgb;
                o.Occlusion = tex2D(_Occlusion, IN.uv_MainTex).rgb;
                o.Smoothness = _Glossiness;
                o.Alpha = _Color.a;
            }
            
    
            ENDCG
        }
        FallBack "Diffuse"
    }
    


    С проектом в целом и тем, как это работает можно ознакомиться тут. Правда часть ресурсов пришлось убрать из-за ограничений по весу гитхаба (hdr skybox и машина).

    Спасибо за внимание! Надеюсь, статья будет кому-то полезна, и стало чуть понятнее зачем может понадобится тригонометрия, аналитическая геометрия (всё что связано с кривыми) и другие математические дисциплины.
    Поделиться публикацией

    Похожие публикации

    Комментарии 22

      +5
      Не вижу эффекта на видео
        –3
        Первые 5 секунд с эффектом, дальше без. Чтобы как раз показать разницу. Из-за качества видео и ракурса может быть визуально не так заметно, но общее впечатление от картинки создают такие мелочи. Хотя пример с машиной далёк от идеала, так как нужно ещё сделать всплески, нормально настроить партиклы самого дождя, разбить на лужи и сделать эффект преломления. Но это не цель данной статьи. Вообще с таким ракурсом я бы чаще применял что-то попроще, но в случае когда пользователь может на всплески смотреть чуть ли не в упор (пролёт камеры в кат сценах, VR, шутеры) — незаменимая штука
          –2
          Ну то есть цель статьи и примера скорее показать математику и применение. Если углубляться, то вообще лужи должны быть, или отдельными мешами и в шейдере должна присутствовать рефракция, или должны в фрагментном шейдере основного меша делать лужу и так же с рефракцией, сейчас деформируется сама дорога. На дальних планах выглядит адекватно, работает шустро. На ближних уже не так. Собственно благодаря тому, что деформируется сам меш и получается эффект похожий на рефракцию (пример с плиткой), но по хорошему для этого должна быть в фрагментном шейдере часть которая «выдавливает» лужи, чтобы они обладали реальной глубиной. С нынешним эффектом, если бы дорога была бы по ровнее, это выглядело бы так, как будто вся дорога под водой. Нынешний шейдер с небольшой доработкой по рефракции уже будет выглядеть лучше.
            +3

            судя по всему, когда на видео странно шевелится асфальт — это оно.

            +1
            Видео бы 1080р, капли еле видны. А так спасибо за математику :)
              +2
              А зачем так делать? Обычно это делают частицами с пост дисторшином либо просто частицами брызг от поверхности, а так это выглядит не красиво, да и сам эффект еле заметен. Волны есть смысл пускать только для воды (лужи, речки и тд)
                –4
                Если речь про пример с дорогой и дождём — это то, что было под рукой по сути. С такого ракурса и на таком расстоянии особого смысла действительно не имеет. Но если взять ведро с водой, случай VR и другие случаи когда пользователь может подойти в упор, то тогда математика выглядит в разы лучше. Плюс сам по себе шейдер вертексный и дешёвый, только требовательный к геометрии. Брызги партиклами доработать — это не супер сложно. Вообще суть была в волновой математике больше, чем в видео в конце. С той же волной можно сделать красивый эффект от падения метеорита и т.п.
                  –1
                  Понятное дело, что изначальная идея, что этот эффект накладывается на лужи, где такой эффект и будет. И то с такого ракурса это не имеет смысла. Если сделать по подобному уроку на таком расстоянии тоже будет красиво, пользователь разницы не увидит точно. Статья именно про математику волны, так как изучая вопрос я столкнулся с забавной вещью. Я находил разные видео, где выражение подбиралось видимо «на глаз». Так как кроме базы скажем почему выражение в тут именно такое — непонятно. Собственно цель статьи больше показать применение математики и небольшой кейс, а не сказать «решайте эту задачу так»
                    –1

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

                      +1

                      А если ввести ещё одну текстуру и накладывать её на блеск, где есть эффект ряби, то пропадёт эффект "вся дорога под водой"?

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

                        Разберём немного способы:

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

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

                        О чём я думал, не назову это способом, просто что мне приходило в голову. Тут суть не в блеске. Если думать над тем, чтобы делать «быстро» без сложной фрагментной части в целом. У нас есть 8 параметров доступных для записи (4 координаты тангентов и вертекс колоров) Если делать процедурненький алгоритм, то мы просто записываем что-то вроде нормал мапа в тангенты, а данные о полюсах в вертекс колоры меша. Для того чтобы разбросать лужи — генерируем шум (поэтому в проекте можно найти простой генератор текстуры шума перлина, так как сама генерация интегрирована в юнити) Дальше, делаем лужи хайполи, а остальную геометрию лоуполи (процедурно — это не так сложно на самом деле, просто по шуму генерируем сетку меша специальным образом) По идее в данном случае нормали в вертексе в соответствии с «кривизной лужи» дадут правильный эффект. Шум станет картой отражений (так как лужа должна отражать сильнее, чем меш под ней) и должно получиться красиво и +- универсальненько. Но условно этот проект я собирал дня 4 где-то (так как надо ещё же найти всю информацию, которую я постарался структурировано описать), а вот всё что я описал писать ещё недельку, и мне пока не до того.
                          0
                          Хотя туплю, нормали можно в нормали и записать, тангенты использовать для чего-то ещё или не использовать вообще. Меш мы не генерируем с нуля. Просто есть базовый, и мы делаем его автоматическую ретопологию с учётом полюсов и геометрии луж. С помощью triangle.Net не так сложно такое сделать. По сути самый сложный алгоритм связанный с ретопологией — это получить контуры для мешей из текстуры шума. Но можно с каким-то шагом разбить текстуру на точки, отсечь по какому-то значению, а дальше алгоритмом распаковки подарка с ограничениями собрать контуры из получившегося облака точек. Если делать это не в реальном времени, а заранее, подобный алгоритм займёт адекватное время. Для произвольной модели единственное с чем по идее могут возникнуть проблемы — это uv маппинг, если он сложный.
                            0
                            C отдельным мешем можно уже с рилтайме делать, что с ретопологией не получится.
                              –1
                              Отдельные меши тоже надо заранее генерировать. Обычно на уровне нет задачи ретоп делать прям по ходу дела. Либо на загрузке, либо просто вообще заранее
                    0
                    Можно же в префабах держать готовые меши с материалом как надо настроенным (ночь, день, прочее)
                      +1
                      Мне кажется смотрелось бы лучше, если уменьшит амплитуду и сделать резкое затухание волны после первого колебания
                        0

                        Затухание можно сделать добавив множитель 1-(Time/maxTime)

                          0
                          Да, а то выглядит как неньютоновская жидкость
                            0

                            А это как определить? Вроде вполне ньютоновская.

                        +2
                        Сделано прикольно, вот только на дождь совсем не похоже. Совсем.

                        Вот тут для примера капли дождя падают на воду:
                        ru.depositphotos.com/51538107/stock-video-raindrops-on-water.html

                        Вот тут на асфальт:
                        ru.depositphotos.com/43157753/stock-video-raindrops-falling-on-asphalt.html
                          0
                          Ну докрутив параметры и добавив пару партиклов можно довести до ума. Статья скорее про математику волны, хотя видимо никому это неинтересно, так как на каждом моём коменте куча минусов. Надо было назвать иначе.

                          Просто в чём проблема того, что я находил по ripple шейдеру. Математика не объяснена и поэтому нереально контроллировать нормально параметры. Увеличив длинну волны и подобрав другую функцию затухания на примере гифки над «Сетка меша это важно» — можно сделать расход волн более похожий на падение капли воды + добавить партиклами всплеск и т.п.

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

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

                          Шейдер эффекта дождя от 1 капли?! Ожидал чего-то большего. Какой-то трюк, хитрость с частицами, искажение нормалей для "бликов" без затрагивания геометрии. Но тут всего этого нет. Есть лишь уравнение окружности, тригонометрическая функция и много тэгов.

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

                          Самое читаемое