Шейдер травы
В этом тьюториале вы найдете пошаговый рецепт написания шейдера травы в Unity. Он получит входную сетку, и из каждой вершины сетки с помощью геометрического шейдера сгенерирует травинку. Для красоты и реализма травинки будут иметь произвольные размеры и повороты, и они будут двигаться под действием ветра. Чтобы контролировать плотность травы, входная сетка будет поделена с помощью тесселяции. Трава сможет отбрасывать и получать тени.
В конце статьи дан готовый код. Для понятности он сопровождается обильными комментариями.
Подготовка
Чтобы понять этот урок, вам понадобятся практические знания движка Unity и промежуточное понимание синтаксиса и функциональности шейдеров.
Эти тьюториалы были написаны и поддерживаются бесплатно и с открытым исходным кодом благодаря вашей поддержке. Если вам они нравятся, пожалуйста, подумайте об идее стать моим спонсором через Patreon.
Начало
Скачайте приведенный выше начальный проект и откройте его в редакторе Unity. Откройте главную сцену и шейдер Grass в любимом редакторе кода.
Этот файл содержит шейдер, который выводит белый цвет, а также некоторые функции, которые мы будем использовать в этом тьюториале. Вы заметите, что эти функции вместе с вершинным шейдером заключены в блок CGINCLUDE, размещенный снаружи SubShader. Код, размещенный в этом блоке, будет автоматически включен в любые проходы в шейдере; это окажется полезно позже, так как наш шейдер будет иметь несколько проходов.
Мы начнем с написания геометрического шейдера, который генерирует треугольники из каждой вершины на поверхности нашей сетки.
1. Геометрические шейдеры
Геометрические шейдеры являются необязательной частью конвейера визуализации. Они выполняются после вершинного шейдера (или тесселяционного – если используется тесселяция) и до обработки вершин для фрагментного шейдера.
Геометрические шейдеры принимают в качестве входных данных один примитив и могут генерировать ноль, один или несколько примитивов. Мы начнем с написания геометрического шейдера, принимающего вершину (или точку) в качестве входных данных, и выведем один треугольник, представляющий травинку.
// Добавьте внутри блока CGINCLUDE. struct geometryOutput { float4 pos : SV_POSITION; }; [maxvertexcount(3)] void geo(point float4 IN[1] : SV_POSITION, inout TriangleStream triStream) { } … // Добавьте внутри прохода SubShader, чуть ниже фрагмента строки #pragma. #pragma geometry geo
Выше объявляется геометрический шейдер с названием geo, имеющий два параметра. Первый float4 IN [1] утверждает, что в качестве входных данных мы возьмем одну точку. Второй, типа TriangleStream настраивает наш шейдер на вывод потока треугольников, причем каждая вершина использует структуру geometryOutput для переноса своих данных.
Кроме того, мы добавляем последний параметр перед объявлением функции в квадратных скобках: [maxvertexcount (3)]. Это говорит GPU, что мы будем выводить (но не обязательно) максимум 3 вершины. Мы также гарантируем, что наш SubShader использует геометрический шейдер, объявив тот внутри Pass.
Геометрический шейдер пока ничего не делает; чтобы вывести треугольник, добавьте внутри него следующий код.
geometryOutput o; o.pos = float4(0.5, 0, 0, 1); triStream.Append(o); o.pos = float4(-0.5, 0, 0, 1); triStream.Append(o); o.pos = float4(0, 1, 0, 1); triStream.Append(o);
Это дало странный результат. Панорамирование камеры показывает, что треугольник отображается в пространстве экрана. Что разумно, поскольку геометрический шейдер происходит непосредственно перед обработкой вершины. Он перехватывает инициативу у вершинного шейдера, чтобы обеспечить вывод вершин в пространстве отсечения. Мы изменим код, чтобы отразить это.
// Обновление ответа в вершинном шейдере. return UnityObjectToClipPos(vertex); return vertex; … // Обновление каждого назначения o.pos в геометрическом шейдере. o.pos = UnityObjectToClipPos(float4(0.5, 0, 0, 1)); … o.pos = UnityObjectToClipPos(float4(-0.5, 0, 0, 1)); … o.pos = UnityObjectToClipPos(float4(0, 1, 0, 1));
Теперь треугольник правильно отображается в мире. Однако создается впечатление, что он только один. В действительности, треугольник рисуется для каждой вершины в нашей сетке. Но позиции, которые мы назначаем вершинам треугольника, являются постоянными. Они не меняются для каждой входной вершины, и все треугольники размещены друг над другом.
Мы исправим это, обновив позиции выходных вершин, чтобы они были смещены относительно входной точки.
// Добавить вверху геометрического шейдера. float3 pos = IN[0]; … // Обновить каждое назначение o.pos. o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0)); … o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0)); … o.pos = UnityObjectToClipPos(pos + float3(0, 1, 0));
Почему некоторые вершины не выводят треугольник?
Мы определили входной примитив как точку. Однако реальная входная сетка (в нашем случае GrassPlane10x10 из папки Mesh) имеет треугольную топологию. Для Direct3D нет необходимости, чтобы входны примитивы шейдера совпадали с топологий сетки. Он просто передаст примитив (в нашем случае треугольник) и проигнорирует последние две точки.
Это можно устранить, если брать сетки с топологий точечного типа, как входная сетка для нашего геометрического шейдера.
Треугольники теперь правильно нарисованы, а их основание расположено на вершине-источнике. Прежде чем двигаться дальше, установите объект GrassPlane неактивным на сцене и активируйте объект GrassBall. Поскольку мы хотим, чтобы трава генерировалась правильно для всех видах поверхностей, важно проверить ее на сетках различной формы.
Прямо сейчас все треугольники выводятся в одном направлении, а не наружу от поверхности сферы. Чтобы решить эту проблему, мы построим травинки в касательном пространстве.
2. Касательное пространство
В идеале, следовало бы построить травинки (применяя произвольно ширину, высоту, кривизну, вращение) без необходимости учитывать угол поверхности, с которой они по отдельности выводятся. Проще говоря, мы определим травинку в пространстве, локальном для выводящей его вершины, и затем преобразуем его в локальное для сетки. Это пространство называется касательным.
Как и любой вид пространства, мы можем определить касательное пространство нашей вершины тремя векторами: направо, вперед и вверх. С помощью этих векторов мы можем построить матрицу для поворота травинки из касательного в локальное пространство.
Доступ к векторам направо и вверх можно получить, добавив несколько новых вершинных входов.
// Добавьте к блоку CGINCLUDE. struct vertexInput { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; struct vertexOutput { float4 vertex : SV_POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; … // Измените вершинный шейдер. vertexOutput vert(vertexInput v) { vertexOutput o; o.vertex = v.vertex; o.normal = v.normal; o.tangent = v.tangent; return o; } … // Измените вход геометрического шейдера. Обратите внимание, что выражение SV_POSITION удалено. void geo(point vertexOutput IN[1], inout TriangleStream triStream) … // Измените текущую строку для объявления pos. float3 pos = IN[0].vertex;
Третий вектор можно рассчитать, взяв векторное произведение двух первых. Оно возвращает вектор, перпендикулярный двум входным.
// Поместите внутри геометрического шейдера, под строкой ,объявляющей положение float3. float3 vNormal = IN[0].normal; float4 vTangent = IN[0].tangent; float3 vBinormal = cross(vNormal, vTangent) * vTangent.w;
Почему результат векторного произведения умножается на касательную координату w?
Когда сетка экспортируется из пакета трехмерного моделирования, в ней обычно есть бинормали (также называемые битангентами), уже сохраненные в данных меша. Вместо того, чтобы импортировать эти бинормали, Unity просто захватывает направление каждой и присваивает его координате w касательной. Это дает преимущество в экономии памяти, в то же время гарантируя, что правильную бинормаль можно будет восстановить позже. Дальнейшее обсуждение этой темы можно найти здесь.
С тремя векторами мы можем построить матрицу преобразования между касательным и локальным пространством. Мы умножим каждую вершину травинки на эту матрицу до передачи в UnityObjectToClipPos, которая работает с вершинами в локальном пространстве.
// Добавьте после строк, объявляющих три вектора. float3x3 tangentToLocal = float3x3( vTangent.x, vBinormal.x, vNormal.x, vTangent.y, vBinormal.y, vNormal.y, vTangent.z, vBinormal.z, vNormal.z );
Перед использованием матрицы мы переместим выходной код вершины в функцию, чтобы избежать повторной записи одних и тех же строк кода. Это часто называют принципом DRY или “не повторяйте себя”.
// Добавьте к блоку CGINCLUDE. geometryOutput VertexOutput(float3 pos) { geometryOutput o; o.pos = UnityObjectToClipPos(pos); return o; } … // Уберите из геометрического шейдера. geometryOutput o; o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0)); triStream.Append(o); o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0)); triStream.Append(o); o.pos = UnityObjectToClipPos(pos + float3(0, 1, 0)); triStream.Append(o); //...и замените приведенным ниже кодом. triStream.Append(VertexOutput(pos + float3(0.5, 0, 0))); triStream.Append(VertexOutput(pos + float3(-0.5, 0, 0))); triStream.Append(VertexOutput(pos + float3(0, 1, 0)));
Наконец, мы умножим выходные вершины на матрицу tangentToLocal, правильно выровняв их по нормали относительно входной точки.
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0)))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0)))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 1, 0))));
Это выглядит ближе к тому, что мы хотим, но не совсем правильно. Проблема в том, что мы изначально определили направление «вверх», чтобы быть на оси Y; в касательном пространстве, однако, соглашение обычно диктует направление вверх вдоль оси Z. Сделаем это изменение сейчас.
// Измените положение третьей из выводимых вершин. triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1))));
3. Облик травы
Чтобы треугольники казались более похожими на настоящие травинки, необходимо добавить цвет и небольшое разнообразие. Мы начнем с градиента цвета от вершины к основанию травинки.
3.1 Градиент цвета
Наша цель – позволить дизайнеру определить два цвета (верх и низ) – и интерполировать их между концом травинки и основанием. Эти цвета уже определены в файле шейдера как _TopColor и _BottomColor. Для их правильной выборки нам потребуется предоставить фрагментный шейдер с UV-координатами.
// Добавьте к структуре geometryOutput. float2 uv : TEXCOORD0; … // Измените сигнетуру функции VertexOutput. geometryOutput VertexOutput(float3 pos, float2 uv) … // Добавьте к VertexOutput, прямо под строкой объявляющей o.pos. o.uv = uv; … // Измените текущие строки геометрического шейдера. triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1)), float2(0.5, 1)));
Мы строим UV-шки для травинки в форме треугольника, с двумя вершинами основания слева и справа, а вершина кончика травинки размещена по центру.
Теперь мы можем сэмплировать наши верхний и нижний цвета в фрагментном шейдере, используя UV, и интерполировать между ними, используя lerp. Нам также понадобится изменить параметры фрагментного шейдера, чтобы в качестве входных данных принимать geometryOutput, а не только положение float4.
// Измените сигнатуру функции фрагментного шейдера. float4 frag (geometryOutput i, fixed facing : VFACE) : SV_Target … // Замените текущий вызов возврата. return float4(1, 1, 1, 1); return lerp(_BottomColor, _TopColor, i.uv.y);
3.2 Случайное направление
Чтобы создать разнообразие и придать более естественный вид, теперь мы развернем каждую травинку в случайном направлении. Для этого нам нужно построить матрицу вращения на случайную величину по вертикальной оси травинки.
В файле шейдера есть две функции, которые помогут нам сделать это: rand, которая генерирует случайное число из трехмерного ввода, и AngleAxis3x3, которая принимает угол (в радианах) и возвращает матрицу вращений на определенную величину вокруг указанной оси. Последняя функция работает так же, как и Quaternion.AngleAxis в C# (но возвращает матрицу, а не кватернион).
Функция rand возвращает число в диапазоне 0 … 1; мы умножим это на два Пи, чтобы получить полный набор угловых значений.
// Добавьте ниже строки, объявляющей матрицу tangentToLocal. float3x3 facingRotationMatrix = AngleAxis3x3(rand(pos) * UNITY_TWO_PI, float3(0, 0, 1));
Мы используем входную позицию pos в качестве случайного начального данного для вращения. Таким образом, каждая травинка получит различное вращение, но оно будет согласованным между кадрами.
Вращение можно применить к травинке, умножив его на существующую матрицу tangentToLocal. Не забывайте, что умножение матриц не является коммутативным; порядок операндов имеет значение.
// Добавьте под строкой, объявляющей facingRotationMatrix. float3x3 transformationMatrix = mul(tangentToLocal, facingRotationMatrix); … // Замените операнд матрицы умножения новым transformationMatrix. triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0.5, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-0.5, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, 1)), float2(0.5, 1)));
3.3 Рандомизованный наклон
Если травинки стоят ровно, они выглядят слишком правильно. Это может быть хорошо для ухоженной травы, как на лужайке для гольфа, но не точно представляет траву в дикой природе. Мы создадим новую матрицу для поворота травы вдоль оси X и свойство для управления этим вращением.
// Добавьте как новое свойство. _BendRotationRandom("Bend Rotation Random", Range(0, 1)) = 0.2 // Добавьте к блоку CGINCLUDE. float _BendRotationRandom; // Добавьте к геометрическому шейдеру, под строкой, объявляющей facingRotationMatrix. float3x3 bendRotationMatrix = AngleAxis3x3(rand(pos.zzx) * _BendRotationRandom * UNITY_PI * 0.5, float3(-1, 0, 0));
Мы снова используем это положение в качестве нашего случайного начала, на этот раз применяя свизлинг, чтобы создать уникальное. Мы также умножаем UNITY_PI на 0,5; это дает нам случайный диапазон 0 … 90 градусов.
Еще раз, мы применяем эту матрицу для вращения, стараясь добавить ее в правильном порядке.
// Измените текущую строку. float3x3 transformationMatrix = mul(mul(tangentToLocal, facingRotationMatrix), bendRotationMatrix);
3.4 Ширина и высота
Размеры травинок сейчас равны 1 единице в ширину и 1 единице в высоту. Мы добавим свойства для управления этим, а также свойства, чтобы внести случайные изменения.
// Добавьте как новые свойства. _BladeWidth("Blade Width", Float) = 0.05 _BladeWidthRandom("Blade Width Random", Float) = 0.02 _BladeHeight("Blade Height", Float) = 0.5 _BladeHeightRandom("Blade Height Random", Float) = 0.3 … // Добавьте к блоку CGINCLUDE. float _BladeHeight; float _BladeHeightRandom; float _BladeWidth; float _BladeWidthRandom; … // Добавьте к геометрическому шейдеру, выше вызовов triStream.Append. float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight; float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth; … // Измените текущие позиции новой высотой и шириной. triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(width, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-width, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, height)), float2(0.5, 1)));
Треугольники теперь намного больше напоминают травинки, но их слишком мало. Во входной сетке просто недостаточно вершин, чтобы создать видимость плотного поля.
Одним из решений было бы создание новой, более плотной сетки, либо с помощью C #, либо с использованием программного обеспечения для 3D-моделирования. Хотя так и будет работать, мы не можем динамически контролировать плотность травы. Вместо этого мы разделим входную сетку используя тесселяцию.
4. Тесселяция
Тесселяция – это необязательный этап в конвейере визуализации, который происходит после вершинного шейдера и перед геометрическим шейдером (если он есть). Его задача состоит в том, чтобы разделить одну входную поверхность на множество примитивов. Тесселяция реализуется в два программируемых этапа: шейдер поверхности и зональный шейдер.
Для поверхностных шейдеров Unity имеет встроенную реализацию тесселяции. Однако, поскольку мы не используем поверхностные шейдеры, необходимо реализовать пользовательские шейдерные оболочки и зональные шейдеры. В этой статье не будет подробно рассказываться про реализацию тесселяции – вместо этого мы используем включенный файл CustomTessellation.cginc. Он взят с изменениями из этой статьи от Catlike Coding, которая является отличным справочником по означенной теме.
Если мы включим объект TessellationExample в сцене, мы увидим, что к нему уже применен материал, реализующий тесселяцию. Изменение свойства Tessellation Uniform продемонстрирует эффект разбиения.
Мы будем применять тесселяцию в шейдере для травы, чтобы контролировать плотность плоскости и, следовательно, количество произведенных травинок. Во-первых, нам нужно включить файл CustomTessellation.cginc. Мы будем ссылаться на него относительным путем к нашему шейдеру.
// Добавьте внутри блока CGINCLUDE, под другими выражениями #include. #include "Shaders/CustomTessellation.cginc"
Если открыть CustomTessellation.cginc, вы заметите, что он уже определил структуры vertexInput и vertexOutput, а также вершинный шейдер. Нет необходимости переопределять их в нашем шейдере для травы; поэтому их можно удалить.
struct vertexInput { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; struct vertexOutput { float4 vertex : SV_POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; vertexOutput vert(vertexInput v) { vertexOutput o; o.vertex = v.vertex; o.normal = v.normal; o.tangent = v.tangent; return o; }
Обратите внимание, что вершина от вершинного шейдера в CustomTessellation.cginc просто передает данные непосредственно на стадию тесселяции. Работа по созданию структуры vertexOutput выполняется функцией tessVert, вызываемой внутри зонального шейдера.
Теперь можно добавить шейдер поверхности и зональный в наш шейдер травы. Также мы добавим новое свойство _TessellationUniform для управления степенью разбивки – соответствующая переменная для этого свойства уже объявлена в CustomTessellation.cginc.
// Добавьте новое свойство. _TessellationUniform("Tessellation Uniform", Range(1, 64)) = 1 … // Добавьте под другими выражениями #pragma в проходе подчиненного шейдера. #pragma hull hull #pragma domain domain
Изменение свойства Tessellation Uniform теперь позволяет контролировать плотность травы. Я обнаружил, что хорошие результаты дает значение 5.
5. Ветер
Мы реализуем ветер путём сэмплирования текстуры искажения. Эта текстура будет похожа на карту нормалей, но у нее только два канала (красный и зеленый) вместо трех. Мы будем использовать эти два канала в качестве направления ветра X и Y.
Перед сэмплированием текстуры ветра нам нужно построить координаты UV. Вместо использования текстурных координат, назначенных сетке, мы будем использовать позицию входной точки. Таким образом, если в мире есть несколько травяных сеток, это создаст иллюзию, что они все являются частью одной и той же системы ветра. Также мы будем использовать встроенную переменную шейдера _Time для прокрутки текстуры ветра вдоль поверхности травы.
// Добавьте как новые свойства. _WindDistortionMap("Wind Distortion Map", 2D) = "white" {} _WindFrequency("Wind Frequency", Vector) = (0.05, 0.05, 0, 0) … // Добавьте к блоку CGINCLUDE. sampler2D _WindDistortionMap; float4 _WindDistortionMap_ST; float2 _WindFrequency; … // Добавьте к геометрическому шейдеру, прямо над строкой, объявляющей transformationMatrix. float2 uv = pos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y;
Мы применяем масштаб и смещение _WindDistortionMap к нашей позиции, а затем дополнительно смещаем их по _Time.y, масштабируемому по _WindFrequency. Теперь мы используем этот UV для сэмплирования нашей текстуры и создадим свойство для контроля силы ветра.
// Добавьте как новое свойство. _WindStrength("Wind Strength", Float) = 1 … // Добавьте к блоку CGINCLUDE. float _WindStrength; … // Добавьте ниже строки, объявляющей float2 uv. float2 windSample = (tex2Dlod(_WindDistortionMap, float4(uv, 0, 0)).xy * 2 - 1) * _WindStrength;
Обратите внимание, что мы изменяем масштаб сэмплированного значения из текстуры с диапазона 0…1 на -1…1. Далее мы можем построить нормализованный вектор, представляющий направление ветра.
// Добавье ниже строки, объявляющей float2 windSample. float3 wind = normalize(float3(windSample.x, windSample.y, 0));
Теперь мы можем построить матрицу, чтобы вращаться вокруг этого вектора, и умножить ее на transformMatrix.
// Добавьте ниже строки, объявляющей ветер float3. float3x3 windRotation = AngleAxis3x3(UNITY_PI * windSample, wind); … // Измените текущую строку. float3x3 transformationMatrix = mul(mul(mul(tangentToLocal, windRotation), facingRotationMatrix), bendRotationMatrix);
Наконец, в редакторе Unity примените текстуру ветра (находится в корне проекта) к слоту карты искажения ветра материала травы Wind Distortion Map. Также установите Tiling текстуры на 0.01, 0.01.
Если трава не анимируется в представлении Scene, переключите небо, туман и другие эффекты, чтобы включить анимированные материалы.
Издалека все смотрится правильно. Но если мы взглянем на травинки близко, мы заметим, что они вращаются целиком, в результате чего основание больше не прижимается к земле.
Исправим это, определив вторую матрицу преобразования, которую применим только к двум базовым вершинам. Она не будет включать матрицы windRotation или bendRotationMatrix и гарантирует, что основание травинки остается прикрепленным к порождающей его поверхности.
// Добавьте ниже строки, объявляющей float3x3 transformationMatrix. float3x3 transformationMatrixFacing = mul(tangentToLocal, facingRotationMatrix); … // Измените текущие строки, выводя базовые положения вершин. triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(width, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(-width, 0, 0)), float2(1, 0)));
6. Кривизна травинки
Прямо сейчас отдельные травинки определены одним треугольником. Хотя это не проблема для больших расстояний, травинки выглядят слишком жесткими и геометрическими, а не органическими и живыми. Мы исправим это, построив наши стебли из нескольких треугольников и согнув их вдоль кривой.
Каждая травинка будет разделена на несколько сегментов. Каждый сегмент будет иметь прямоугольную форму и состоять из двух треугольников, исключая самый верхний – то будет один, представляющий кончик травинки.
До сих пор мы выводили только три вершины для одного треугольника. И как тогда при большом количестве вершин геометрический шейдер узнает, какие из них должны соединиться друг с другом и образовать треугольники? Ответ лежит в структуре данных треугольной полосы. Первые три вершины связаны, чтобы сформировать треугольник – как и прежде – с каждой дополнительной вершиной, образующей треугольник с двумя предыдущими.
Это эффективнее использует память и позволяет быстро создавать последовательности треугольников в коде. Если бы мы хотели иметь несколько треугольных полос, мы могли бы вызвать функцию RestartStrip в TriangleStream.
Прежде чем мы начнем выводить больше вершин из геометрического шейдера, нам нужно увеличить maxvertexcount. Используем оператор #define, чтобы позволить автору шейдера контролировать количество сегментов и вычислять количество выведенных из этого вершин.
// Добавьте к блоку CGINCLUDE. #define BLADE_SEGMENTS 3 … // Измените текущую строку, объявляющую maxvertexcount. [maxvertexcount(BLADE_SEGMENTS * 2 + 1)]
Мы изначально определяем число сегментов как 3 и обновляем maxvertexcount, чтобы вычислить количество вершин на основе числа сегментов.
Чтобы создать сегментированную травинку, мы будем использовать цикл for. Каждая итерация цикла добавит две вершины: левую и правую. После того, как вершина завершена, мы добавим окончательную вершину на конце травинки.
Прежде чем мы сделаем это, будет полезно переместить часть кода, вычисляющего позиции вершин травы, в функцию, так как мы будем использовать код несколько раз внутри и вне цикла. Добавьте следующее в блок CGINCLUDE.
geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float2 uv, float3x3 transformMatrix) { float3 tangentPoint = float3(width, 0, height); float3 localPosition = vertexPosition + mul(transformMatrix, tangentPoint); return VertexOutput(localPosition, uv); }
Эта функция несет те же обязанности, что и аргументы, которые мы в настоящее время передаем VertexOutput для генерации вершин травинок. Взяв положение, ширину и высоту, она корректно преобразует вершину по предоставленной матрице и назначает ей координату UV. Мы обновим текущий код с этой функцией, чтобы проверить правильность работы.
// Обновите текущий код для вывода вершин. triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix));
Когда функция работает правильно, мы готовы переместить код генерации вершин в цикл for. Добавьте следующую строку под строкой с объявлением изменяемой ширины.
for (int i = 0; i < BLADE_SEGMENTS; i++) { float t = i / (float)BLADE_SEGMENTS; }
Мы объявляем цикл, который будет запускаться один раз для каждого сегмента травинки. Внутри цикла мы добавляем переменную t. Эта переменная будет содержать значение от 0 … 1, представляющее, как далеко мы находимся вдоль длины травинки. Это значение используется для вычисления ширины и высоты сегмента в каждой итерации цикла, что мы можем сделать сейчас.
// Добавьте под строкой, объявляющей float t. float segmentHeight = height * t; float segmentWidth = width * (1 - t);
По мере продвижения вдоль по травинке высота увеличивается, а ширина уменьшается. Теперь мы можем добавить вызовы GenerateGrassVertex в наш цикл, чтобы добавить вершины в поток треугольника. Также сделаем один вызов GenerateGrassVertex вне цикла, чтобы добавить вершину на конце травинки.
// Добавьте в конце строки, объявляющей float segmentWidth. float3x3 transformMatrix = i == 0 ? transformationMatrixFacing : transformationMatrix; triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, float2(0, t), transformMatrix)); triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, float2(1, t), transformMatrix)); … // Добавьте чуть ниже цикла, чтобы вставить вершину на конце травинки. triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix)); … // Удалите текущие вызовы к triStream.Append. triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix));
Обратите внимание на строку, объявляющую float3x3 transformMatrix – здесь мы выбираем между двумя матрицами преобразования, TransformationMatrixFacing для вершин в основании и TransformationMatrix для всех остальных.
Травинки теперь поделены на несколько сегментов, но поверхность их плоская – вновь добавленные треугольники еще не используются. Мы добавим кривизну к травинке, сместив положение Y вершин. Для начала нужно изменить функцию GenerateGrassVertex, чтобы она принимала смещение Y, которое мы будем вызывать вперед.
// Обновите сигнатуру функции GenerateGrassVertex. geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float forward, float2 uv, float3x3 transformMatrix) … // Измените назначение координат Y для tangentPoint. float3 tangentPoint = float3(width, forward, height);
Чтобы вычислить смещение вперед для каждой вершины, мы вставим t в функцию возведения в степень. Если брать t как степень, его влияние на прямое смещение будет нелинейным и превратит травинку в кривую.
// Добавьте как новые свойства. _BladeForward("Blade Forward Amount", Float) = 0.38 _BladeCurve("Blade Curvature Amount", Range(1, 4)) = 2 … // Добавьте к блоку CGINCLUDE. float _BladeForward; float _BladeCurve; … // Добавьте внутри геометрического шейдера, ниже строки, объявляющей изменяемую ширину. float forward = rand(pos.yyz) * _BladeForward; … // Добавьте внутри цикла, под строкой, объявляющей segmentWidth. float segmentForward = pow(t, _BladeCurve) * forward; … // Измените вызовы GenerateGrassVertex внутри цикла. triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, segmentForward, float2(0, t), transformMatrix)); triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, segmentForward, float2(1, t), transformMatrix)); … // Измените вызовы GenerateGrassVertex снаружи цикла. triStream.Append(GenerateGrassVertex(pos, 0, height, forward, float2(0.5, 1), transformationMatrix));
Выше приведен большой кусок кода, но вся проделанная работа аналогична работе для ширины и высоты травинки. Малые значения _BladeForward и _BladeCurve приведут к более организованному, хорошо ухоженному полю травы, в то время как большие создадут обратный эффект.
7. Освещение и тени
В качестве последнего шага для завершения данного шейдера мы добавим возможность накладывать и получать тени. Также добавим несколько простых подсветок, полученных от основного направленного света.
7.1 Отбрасывание теней
Чтобы отбрасывать тени в Unity, в шейдер должен быть добавлен второй проход. Проход используется отбрасывающими тень источниками света светящимися тенями в сцене, чтобы визуализировать глубину травы в их карте теней. Это означает, что наш геометрический шейдер также должен работать в проходе теней, чтобы обеспечить наличие травы для отбрасывания теней.
Поскольку геометрический шейдер написан внутри блоков CGINCLUDE, он доступен для использования на любых проходах в файле. Мы создадим второй проход, который будет использовать все те же шейдеры, что и начальный, за исключением фрагментного шейдера. Тот определим новым и заполним макросом, который обрабатывает вывод для нас.
// Добавьте ниже текущего Pass. Pass { Tags { "LightMode" = "ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma geometry geo #pragma fragment frag #pragma hull hull #pragma domain domain #pragma target 4.6 #pragma multi_compile_shadowcaster float4 frag(geometryOutput i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG }
Помимо наличия нового фрагментного шейдера, в этом проходе есть пара ключевых отличий. Тег LightMode имеет значение ShadowCaster вместо ForwardBase – это сообщает Unity, что данный проход следует использовать для рендеринга объекта в карты теней. У нас также есть директива препроцессора multi_compile_shadowcaster. Это гарантирует, что шейдер компилирует все варианты, необходимые для отбрасывания теней.
Установите в сцене активный игровой объект Забор (Fence). Так мы получим поверхность, на которую травинки отбрасывают тень.
7.2 Получение теней
После того, как Unity визуализирует карту теней с точки зрения источника света, он запускает проход, «собирая» тени в текстуру пространства экрана. Чтобы сэмплировать эту текстуру, нам нужно рассчитать положение этих вершин в пространстве экрана и передать их в фрагментный шейдер.
// Добавьте к структуре geometryOutput. unityShadowCoord4 _ShadowCoord : TEXCOORD1; … // Добавьте к функции VertexOutput, прямо над вызовом возврата. o._ShadowCoord = ComputeScreenPos(o.pos);
В фрагментном шейдере прохода ForwardBase мы можем использовать макрос для получения значения с плавающей запятой, представляющего, находится ли поверхность в тени или нет. Это значение находится в диапазоне 0 … 1, где 0 полностью затенено, а 1 полностью подсвечено.
Почему UV-координата экранного пространства называется _ShadowCoord? Это не соответствует предыдущим соглашениям.
Многие из встроенных макросов для шейдеров Unity делают предположения об именах определенных полей в различных структурах шейдеров (некоторые даже делают предположения об именах самих структур). Макрос, который мы используем ниже, SHADOW_ATTENUATION, ничем не отличается. Если мы получим код для этого макроса из Autolight.cginc, мы увидим, что для координат тени требуется конкретное имя.
#define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)
Если нужно сделать другое имя для координаты, можно просто скопировать приведенное выше определение в наш собственный шейдер.
// Добавьте к фрагментному шейдеру прохода ForwardBase, заменив текущий вызов. return SHADOW_ATTENUATION(i); return lerp(_BottomColor, _TopColor, i.uv.y);
Наконец, мы должны убедиться, что шейдер правильно настроен на получение теней. Для этого добавим директиву препроцессора в проход ForwardBase, чтобы скомпилировать все необходимые варианты шейдеров.
// Добавьте к директивам препроцессора для прохода ForwardBase, ниже #pragma target 4.6. #pragma multi_compile_fwdbase
При увеличении мы можем увидеть какие-то артефакты на поверхности травинок; это вызвано тем, что отдельные травинки отбрасывают на себя тени. Мы можем исправить это, применив линейное смещение или немного переместив положения вершин в пространство отсечения на экране. Для этого мы будем использовать макрос Unity и включим его в инструкцию #if, чтобы операция выполнялась только во время теневого прохода.
// Добавьте в конце функции VertexOutput чуть выше вызова. #if UNITY_PASS_SHADOWCASTER // Смещение убережет от появление артефактов на поверхности. o.pos = UnityApplyLinearShadowBias(o.pos); #endif
Почему по краям травинок есть артефакты, которые получили тень?
Даже когда мультисэмплированное сглаживание (MSAA) включено, Unity не применяет сглаживание к текстуре глубины сцены, которая используется при построении карты теней экранного пространства. Следовательно, когда сглаженная сцена сэмплирует не сглаженную карту теней, возникают артефакты.
Одним из решений является использование сглаживания, применяемого во время постобработки. Оно доступно в пакете постобработки Unity. Однако иногда постобработанное сглаживание не всегда подходит (например, при работе с виртуальной реальностью). В этой теме на форумах Unity есть хорошее обсуждение альтернативных решений проблемы.
7.3 Освещение
Мы будем реализовывать освещение, используя этот общий, очень простой алгоритм для расчета рассеянного освещения.
… где N – нормаль поверхности, L – нормализованное направление основного направленного света, а I – расчетное освещение. Про отраженное освещение не будем говорить в этом тьюториале.
Прямо сейчас вершины в наших травинках не имеют назначенных нормалей. Как и в случае с положениями вершин, мы сначала вычислим нормали в касательном пространстве, а затем преобразуем их в локальные.
Когда значение кривизны травинки установлено на 1, все травинки направлены в одном и том же направлении в касательном пространстве: прямо назад по оси Y. В качестве первого прохода нашего решения мы вычислим нормальное значение в предположении отсутствия кривизны.
// Добавьте к функции GenerateGrassVertex, расположенной под строкой с объявлением tangentPoint. float3 tangentNormal = float3(0, -1, 0); float3 localNormal = mul(transformMatrix, tangentNormal);
Параметр tangentNormal, определяемый как обратное направление по оси Y, преобразуется той же самой матрицей, которую мы использовали для преобразования точек касания в локальное пространство. Теперь мы можем передать это в функцию VertexOutput, а затем в структуру geometryOutput.
// Измените вызов в GenerateGrassVertex. return VertexOutput(localPosition, uv, localNormal); … // Добавьте к структуре geometryOutput. float3 normal : NORMAL; … // Изменить сигнатуру текущей функции. geometryOutput VertexOutput(float3 pos, float2 uv, float3 normal) … // Добавьте к функции VertexOutput, чтобы передать нормаль через фрагментный шейдер. o.normal = UnityObjectToWorldNormal(normal);
Обратите внимание, что мы преобразуем нормаль в мировое пространство, прежде чем вывести ее. Такое преобразование необходимо потому, что в Unity основной направленный свет отображается на шейдеры в мировом пространстве.
Теперь мы можем визуализировать нормали в фрагментном шейдере ForwardBase, чтобы проверить работу.
// Добавьте к фрагментному шейдеру ForwardBase. float3 normal = facing > 0 ? i.normal : -i.normal; return float4(normal * 0.5 + 0.5, 1); // Удалите текущий вызов. return SHADOW_ATTENUATION(i);
Поскольку для нашего шейдера Cull установлено на значение Off, отображаются обе стороны травинки. Чтобы нормаль шла в правильном направлении, мы используем опциональный параметр VFACE, который включили в фрагментный шейдер.
Аргумент для фиксированного положения будет возвращать положительное число, если мы видим переднюю поверхность, и отрицательное, если мы видим заднюю. Мы используем это выше, чтобы инвертировать при необходимости нормаль.
Когда значение кривизны травинки больше 1, каждая вершина будет иметь свою касательную Z, то есть положительное смещение, переданное в функцию GenerateGrassVertex. Мы будем использовать это значение для пропорционального масштабирования оси Z наших нормалей.
// Измените текущую строку в GenerateGrassVertex. float3 tangentNormal = normalize(float3(0, -1, forward));
Наконец, мы добавим кое-какой код в фрагментный шейдер, чтобы объединить тени, направленный свет и окружающий свет. Для более подробного ознакомления с реализацией нестандартного освещения в шейдерах я бы порекомендовал взглянуть на мой тьюториал по тун-шейдерам.
// Добавьте к фрагментному шейдеру ForwardBase ниже строки, объявляющей float3 normal. float shadow = SHADOW_ATTENUATION(i); float NdotL = saturate(saturate(dot(normal, _WorldSpaceLightPos0)) + _TranslucentGain) * shadow; float3 ambient = ShadeSH9(float4(normal, 1)); float4 lightIntensity = NdotL * _LightColor0 + float4(ambient, 1); float4 col = lerp(_BottomColor, _TopColor * lightIntensity, i.uv.y); return col; // Удалите текущий вызов. return float4(normal * 0.5 + 0.5, 1);
Заключение
В этом тьюториале трава покрывает небольшую область 10х10. Чтобы расширить этот шейдер для охвата обширных открытых полей, вероятно, потребуется оптимизация для поддержания его производительности. Тесселяция на основе расстояния может использоваться для уменьшения количества травинок, удаленных от камеры. Кроме того, на дальней дистанции можно не рисовать каждую травинку по отдельности. Пучки травы можно было бы нарисовать с помощью одного четырехугольника с текстурным отображением.
Можно улучшить освещение и тени. Если хочется использовать стандартную модель освещения Unity, изначально невозможно использовать геометрические шейдеры вместе с поверхностными. Но этот репозиторий GitHub демонстрирует обходной путь с помощью отложенного рендеринга и ручного заполнения G-буферов.
Посмотреть исходники в репозиторие GitHub
Взаимодействие
Без взаимодействия графические эффекты могут казаться статичными или пустыми для игроков. Поскольку этот тьюториал уже очень длинный, я воздержался от включения раздела о том, как объекты в мире взаимодействуют с травой и влияют на нее.
Реализация для интерактивной травы будет содержать два компонента: что-то в игровом мире, способное передавать данные в шейдер, чтобы сообщить ему, с какой частью травы взаимодействует, и некоторый код в шейдере, чтобы интерпретировать эти данные.
Пример того, как сделать это с водой, можно найти здесь. Он может быть адаптирован для работы с травой: вместо того, чтобы рисовать рябь там, где находится персонаж, для имитации приминающейся под ногами травы травинки можно вращать вниз.