Еще один шейдер травы. Часть 1

Еще один шейдер травы. Часть 1

Введение

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

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

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

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

Обзор шейдера

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

  • Шейдер генерирует на каждый треугольник переменное количество травинок в случайных положениях, высотах и ориентациях.
  • Максимальное количество установленных травинок на данный момент составляет 15.
  • Шейдер также вычисляет новые UV-координаты для сгенерированных травинок.
  • Для небольшой оптимизации шейдер будет генерировать меньше травинок в зависимости от расстояния до камеры.
  • Травинки будут колебаться по текстуре смещения, напоминающей движение на ветру.
  • Травинки будут градиентно окрашены, с использованием собственных цветов вершин. Чтобы подчеркнуть движение на ветру, при изгибе они будут получать дополнительный серебристый цвет.

Алгоритм генерации травинки следующий:

  • Мы получаем вектор нормали текущего треугольника и количество травинок, которые породим (в зависимости от расстояния до камеры).
  • Для каждой травинки:
  • Мы получаем случайные числа, основанные на мировом положении вершин треугольника
  • Рассчитываем случайную точку в треугольнике, используя эту технику. Назовем ее «серединой».
  • Рассчитываем две другие точки, по одной на каждой стороне от средней точки. В качестве ориентира для направления смещения этих точек мы используем одну из вершин треугольника.
  • Рассчитываем вклад ветра путем выборки текстуры смещения.
  • Рассчитываем смещение высоты путем выборки текстуры шума.
  • Верхняя точка травинки будет в том же положении, что и средняя точка, но она будет смещена вдоль вектора нормали треугольника. Точка также будет смещена по осям X и Z на основе случайной величины, умноженной на внешнее свойство, и также смещена по величине ветра.
  • В цветах вершин для верхней точки мы сохраняем вклад ветра в зеленом канале и устанавливаем для других каналов значение 1.
  • Для остальных двух точек, которые мы рассчитали, цвет вершин будет установлен на черный.
  • Три точки добавляются к потоку треугольника
    Мы добавляем исходные вершины треугольника к потоку треугольника.
  • ???
  • PROFIT!!!

Более визуализированное описание алгоритма генерации травинки будет таким:

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

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

Код

Давайте теперь посмотрим на код:

Shader "Geometry/GrassGeometryShaderUnlit"
{
    Properties
    {
        // Цвет
        _Color("Color", Color) = (1,1,1,1)
        _GradientMap("Gradient map", 2D) = "white" {}
         
        // Шум и ветер
        _NoiseTexture("Noise texture", 2D) = "white" {} 
        _WindTexture("Wind texture", 2D) = "white" {}
        _WindStrength("Wind strength", float) = 0
        _WindSpeed("Wind speed", float) = 0
        _WindColor("Wind color", Color) = (1,1,1,1)
 
        // Положение и размеры
        _GrassHeight("Grass height", float) = 0
        _GrassWidth("Grass width", Range(0.0, 1.0)) = 1.0
        _PositionRandomness("Position randomness", float) = 0
 
        // Травинки
        _GrassBlades("Grass blades per triangle", Range(0, 15)) = 1
        _MinimunGrassBlades("Minimum grass blades per triangle", Range(0, 15)) = 1
        _MaxCameraDistance("Max camera distance", float) = 10
    }
    SubShader
    {
 
        CGINCLUDE
         
            #include "UnityCG.cginc"
 
            struct appdata
            {
                float4 vertex : POSITION;
            };
 
            struct v2g
            {
                float4 vertex : POSITION;
            };
 
            struct g2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float4 col : COLOR;
            };
 
            fixed4 _Color;
            sampler2D _GradientMap;
 
            sampler2D _NoiseTexture;
            float4 _NoiseTexture_ST;
            sampler2D _WindTexture;
            float4 _WindTexture_ST;
            float _WindStrength;
            float _WindSpeed;
            fixed4 _WindColor;
 
            float _GrassHeight;
            float _GrassWidth;
            float _PositionRandomness;
 
            float _GrassBlades;
            float _MinimunGrassBlades;
            float _MaxCameraDistance;
 
            float random (float2 st) {
                return frac(sin(dot(st.xy,
                                    float2(12.9898,78.233)))*
                    43758.5453123);
            }
 
 
            g2f GetVertex(float4 pos, float2 uv, fixed4 col) {
                g2f o;
                o.vertex = UnityObjectToClipPos(pos);
                o.uv = uv;
                o.col = col;
                return o;
            }
 
            v2g vert (appdata v)
            {
                v2g o;
                o.vertex = v.vertex;
                return o;
            }
 
            // 3 + 3 * 15 = 48
            [maxvertexcount(48)]
            void geom(triangle v2g input[3], inout TriangleStream < g2f > triStream)
            {
                float3 normal = normalize(cross(input[1].vertex - input[0].vertex, input[2].vertex - input[0].vertex));
                int grassBlades = ceil(lerp(_GrassBlades, _MinimunGrassBlades, saturate(distance(_WorldSpaceCameraPos, mul(unity_ObjectToWorld, input[0].vertex)) / _MaxCameraDistance)));
 
                for (uint i = 0; i < grassBlades; i++) {
                    float r1 = random(mul(unity_ObjectToWorld, input[0].vertex).xz * (i + 1));
                    float r2 = random(mul(unity_ObjectToWorld, input[1].vertex).xz * (i + 1));
 
                    // Случайные барицентрические координаты по https://stackoverflow.com/a/19654424
                    float4 midpoint = (1 - sqrt(r1)) * input[0].vertex + (sqrt(r1) * (1 - r2)) * input[1].vertex + (sqrt(r1) * r2) * input[2].vertex;
 
                    r1 = r1 * 2.0 - 1.0;
                    r2 = r2 * 2.0 - 1.0;
 
                    float4 pointA = midpoint + _GrassWidth * normalize(input[i % 3].vertex - midpoint);
                    float4 pointB = midpoint - _GrassWidth * normalize(input[i % 3].vertex - midpoint);
 
                    float4 worldPos = mul(unity_ObjectToWorld, midpoint);
 
                    float2 windTex = tex2Dlod(_WindTexture, float4(worldPos.xz * _WindTexture_ST.xy + _Time.y * _WindSpeed, 0.0, 0.0)).xy;
                    float2 wind = (windTex * 2.0 - 1.0) * _WindStrength;
 
                    float noise = tex2Dlod(_NoiseTexture, float4(worldPos.xz * _NoiseTexture_ST.xy, 0.0, 0.0)).x;
                    float heightFactor = noise * _GrassHeight;                        
 
                    triStream.Append(GetVertex(pointA, float2(0,0), fixed4(0,0,0,1)));
 
                    float4 newVertexPoint = midpoint + float4(normal, 0.0) * heightFactor + float4(r1, 0.0, r2, 0.0) * _PositionRandomness + float4(wind.x, 0.0, wind.y, 0.0);
                    triStream.Append(GetVertex(newVertexPoint, float2(0.5, 1), fixed4(1.0, length(windTex), 1.0, 1.0)));
 
                    triStream.Append(GetVertex(pointB, float2(1,0), fixed4(0,0,0,1)));
 
                    triStream.RestartStrip();
                }
                 
 
                for (int i = 0; i < 3; i++) {
                    triStream.Append(GetVertex(input[i].vertex, float2(0,0), fixed4(0,0,0,1)));
                }
 
 
                triStream.RestartStrip();
            }
 
            fixed4 frag (g2f i) : SV_Target
            {
                fixed4 gradientMapCol = tex2D(_GradientMap, float2(i.col.x, 0.0));
                fixed4 col = (gradientMapCol + _WindColor * i.col.g) * _Color;
                return col;
            }
             
 
        ENDCG
 
        Pass
        {
            Tags { "RenderType"="Opaque"}
            Cull Off
            CGPROGRAM
            #pragma vertex vert
            #pragma geometry geom
            #pragma fragment frag
             
            ENDCG
        }
 
    }
}

И еще не паникуйте заранее. Этот шейдер следует иному формату, который будет полезен во второй части. Вы можете заметить, что проход отделен от большей части логики и вместо «CGPROGRAM» в строке 29 «CGINCLUDE». Этот формат означает, что мы можем заранее объявить функциональность фрагмента / вершинного / геометрического шейдера, а затем просто включить его в проход. Если бы нам, например, потребовалось сделать второй проход с тем же геометрическим шейдером, но с другим фрагментным, этот формат очень полезен, потому что не надо снова писать тело шейдера.

Перейдем теперь к свойствам:

Свойства

_Color Цвет будет умножен на результат карты градиента
_GradientMap Текстура карты градиента, которая будет использоваться для закрашивания
_NoiseTexture Текстура шума, которая определяет случайную высоту травинок
_WindTexture Текстура смещения для эффекта ветра
_WindStrength Сила эффекта ветра
_WindSpeed Скорость, с которой применяется текстура смещения ветром
_WindColor Это странное название говорит о том, что данный цвет будет добавлен к конечному цвету для имитации блеска травы, гнущейся под порывами ветра
_GrassHeight Максимальная высота травинок
_GrassWidth Ширина травинок
_PositionRandomness Величина случайного смещения, которое кончик травинки будет иметь по осям X и Z. При 0 травинки будут вертикально прямыми.
_GrassBlades Максимальное количество травинок на треугольник.
_MinimunGrassBlades Минимальное количество травинок на треугольник, когда камера находится далеко
_MaxCameraDistance Расстояние от камеры, при котором травинок становится меньше

Промежуточные рассуждения

Сначала в строках 33-48 я объявляю структуры, которые буду использовать для передачи данных из вершинного шейдера в геометрический шейдер, а затем во фрагментный шейдер. На данный момент мне просто нужно положение вершин и ничего больше. Поскольку я не добавляю текстуру в исходную сетку, мне даже не нужны UV-координаты объекта, я просто генерирую UV-координаты для травинок.

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

Вы могли также заметить, что структура appdata точно такая же, как структура v2g, и да, мы могли бы фактически использовать одну и ту же структуру в обоих случаях! Но чтобы было понятнее и чтобы сохранить весь конвейер вершинный > геометрический > фрагментный, я сделал их отдельными.

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

В строках 50-67 я заново объявляю свои свойства (не забывайте поля _ST!), а затем в строках 69-73 я добавляю случайную хэш-функцию, которую вы, возможно, уже видели в других уроках, и почти во всех шейдерах, которые используют случайные величины.

Наконец, чтобы избежать повторения кода, в строках 76-82 у меня есть метод для передачи необходимой мне информации в объект g2f и добавления ее в поток. Этот метод всего лишь конвертирует заданную позицию вершины в пространстве объектов в пространство отсечения и передает ее, UV и цвет во вновь созданный объект g2f. Достаточно просто.

Вершинный шейдер

Здесь шейдер вершин теряет значительную часть своего гламура и всего лишь передает положение из appdata в объект v2g. Даже немного грустно.

Геометрический шейдер

Вот где все сходится вместе.

Во-первых, в строке 92 максимальное число вершин у меня 48. Это потому, что, как я упоминал выше, у нас может быть максимум 15 травинок на треугольник. Следовательно, нам нужно 3 стандартных вершины граней плюс 3 вершины для каждой травинки, поэтому 3 * 15 + 3 = 48. Если мы хотим больше травинок на треугольник, это число должно увеличиться на 3 для каждой дополнительной травинки.

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

В строке 96 я определяю количество травинок, которые будут появляться в текущем треугольнике. Я делаю это, используя lerp для интерполяции между максимальным и минимальным количеством травинок, основываясь на расстоянии первой вершины от камеры. Более правильным подходом было бы взять барицентр треугольника, но так тоже работает. В большинстве случаев смещение не будет значительным. Суть данной строки кода в том, что чем меньше расстояние от камеры, тем больше будет сгенерированных травинок. Если расстояние равно или больше «_MaxCameraDistance», то количество сгенерированных травинок равно «_MinimumGrassBlades».

Двигаясь внутри цикла для каждой травинки, в строках 99 и 100 я получаю два случайных числа, основанные на положении в мире первой и второй вершин текущего треугольника, соответственно. Опять же, использование первых двух точек треугольников немного произвольно, но преобразование в мировое пространство помогает удерживать начало привязанным к мировому пространству и иметь множество участков травы без повторений, если мы захотим. Умножение на (i + 1) также помогает получить разные начала для каждой травинки. Опять же, не совсем правильно, но вы понимаете, в чем суть.

В строке 103 я вычисляю случайную среднюю точку травинки, используя этот метод, а затем в строках 105 и 106 я переназначаю случайные числа из промежутка [0,1] в [-1,1], который будет использоваться для рандомизации положения.

В строках 108-109 я вычисляю две точки основания вновь сгенерированного треугольника, смещая среднюю точку на значение «_GrassWidth» в направлении точки треугольника. Еще раз, «i% 3» является произвольным, и оно нужно, чтобы травинки не вращались одинаково. Сейчас это работает так, что основание первой травинки направлено вдоль линии к первой точке треугольника, и каждая травинка будет циклически проходить через точки.

В строке 111 я получаю положение в мировом пространстве для «средней точки», чтобы использовать в качестве координат для текстур ветра и шума. Сэмплирование текстуры ветра происходит в строках 113-114, и это все тот же самый метод, который мы видели множество раз, когда дело касается текстур смещения. Сэмплировать текстуру, сместить UV по времени (здесь это мировое положение компонентов x и z) и затем поместить все в диапазон от -1 до 1 и умножить на силу эффекта. Здесь я также умножаю мировую позицию на «_WindTexture_ST.xy», чтобы можно было регулировать масштаб ветра с помощью наложения текстуры в инспекторе материалов.

В строках 116 и 117 я вычисляю окончательную высоту травинки путем сэмплирования текстуры шума так же, как для текстуры ветра, и умножения результата на “_GrassHeight”.

Держите в голове, что оба сэмплирования текстур здесь происходят с “tex2Dlod” вместо“tex2D”, потому что мы находимся не во фрагментном шейдере.

Теперь, наконец-то пришло время делать травинки! Перед тем, как перейти к коду, я хочу объяснить что делать здесь с UV-шками травинок:

UV координаты травинки

Если мы хотим добавить к травинкам текстуру, вот хороший способ сделать это. Как вы знаете, UV координаты идут от (0,0) внизу слева к (1,1) вверху справа. Поэтому если нам нужно, чтобы травинки покрывали весь спектр равнобедренных треугольников, первая точка в основании должна иметь UV (0,0), вторая (1,0), а верхняя — (0.5,1).

Сказав это, в строке 119 я добавляю первую точку травинки с (0,0) в качестве UV координаты и черным в качестве цвета вершины. Затем в строке 121 я вычисляю положение верхней точки следующим образом:

Я получаю среднюю точку и сначала смещаю ее по вектору нормали с помощью “heightFactor”, затем я смещаю ее по осям x и z, сначала используя полученные раньше и умноженные на “_PositionRandomness” случайные числа “r1” и “r2”, затем используя ветер.

Затем я добавляю эту точку в поток, назначая (0.5,1) в качестве ее UV координат. В качестве цвета я использую 1 для красного, синего и альфа каналов, и длину красного и зеленого значений из текстуры ветра для зеленого канала. Таким способом в дальнейшем я смогу настроить соответствие цветов и интенсивности ветра.

После этого я добавляю третью точку с UV (1,0) и также назначаю ей черную вершину. Потом я еще перезагружаю стрип, поскольку когда цикл перезагружается, мы делаем новый треугольник.

Наконец, в строках 130-132 я добавляю реальные вершины поверхности треугольника к потоку и также использую (0,0) как UV и черный как цвет вершины.

Фрагментный шейдер

Фрагментный шейдер сейчас достаточно прост: я лишь сэмплирую карту градиента в красном канале цвета вершин, который, как вы помните, был 1 на кончике травинок и 0 у основания. Это дает нам красивый серый градиент, от черного основания до белого кончика. Вдобавок, левая сторона текстуры градиента должна быть темнее, а правая светлее. Я применил описанную текстуру для этого изображения:

карта градиента для шейдера

Окончательный цвет затем вычисляется прибавлением “_WindColor”, умноженного на зеленый канал цвета вершин , к цвету из карты градиента, и затем умножению суммы на “_Color”. Цвет получился, все отлично!

Реальный проход

Как я отметил вначале, здесь применен несколько отличающийся формат. Наши шейдеры находятся не в блоке «Pass», как мы привыкли, а в состоянии неопределенности внутри блока «CGINCLUDE». Круто, однако, что они все еще находятся в одном файле с проходом. Поэтому в блоке «Pass» в строках 148–158 нам просто нужно сопоставить соответствующие директивы «#pragma» с именами шейдеров, и мы в порядке! Они будут в просто включены в проход, как если бы мы написали их там сначала.

Поскольку наши шейдеры носят имена “vert”, “geom” и “frag” для вершинного, геометрического и фрагментного соответственно, тэги “#pragma” в строках 135-155 делают для нас всю работу. Также не забудьте добавить директиву “Cull off” в строке 151, иначе большая часть травинок станет невидима с определенной точки просмотра, относительно порядка из визуализации.

Заключение

Это окончание первой части шейдера для травы! В следующий раз мы увидим некоторые более увлекательные вещи вроде лайтинга, теней и т.п. Но даже с таким шейдером вы можете сделать множество различных штук! Например, вместо генерации отдельных травинок вы можете попробовать генерацию квадов в форме звезды (смотрите GPU Gems on grass) и добавлять к ним текстуру травы. Надеюсь, при помощи этого тьюториала вы сделаете потрясающие сцены с травой. Мне бы хотелось, чтобы вы поделились своими работами или хотя бы показали их мне. Так что любым способом дайте знать, если вы хоть как-то смогли использовать данный шейдер!

Увидимся в следующий раз.


Код в тьюториалах по шейдерам распространяется по лицензии CC0, поэтому вы можете свободно использовать его в коммерческих и некоммерческих проектах без каких-либо упоминаний/благодарностей Гарри Алисавакису.

Источник: https://halisavakis.com/my-take-on-shaders-grass-shader-part-i/

Понравилась статья? Поделиться с друзьями:
Автор natalya
Переводит для Вас самые интересные статьи про разработку игр. По образованию физик-программист. Техническими переводами начала подрабатывать еще на старших курсах и постепенно это переросло в основное занятие. Интересуется гуманитарными технологиями, пробует себя в журналистике.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *