Working with 3D-graphics
Game development
Unity3D
Game design

Создание шейдера травы в движке Unity

Original author: Erik Roystan Ross
Translation

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

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

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

Требования


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

Скачать заготовку проекта (.zip).

Приступаем к работе


Скачайте заготовку проекта и откройте его в редакторе Unity. Откройте сцену Main, а затем откройте в своём редакторе кода шейдер Grass.

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

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

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


Геометрические шейдеры — это необязательная часть конвейера рендеринга. Они выполняются после вершинного шейдера (или шейдера тесселяции, если используется тесселяция) и до того, как вершины обрабатываются для фрагментного шейдера.


Графический конвейер Direct3D 11. Заметьте, что на этой схеме фрагментный шейдер называется пиксельным (pixel shader).

Геометрические шейдеры получают на входе одиночный примитив и могут сгенерировать ноль, один или множество примитивов. Мы начнём с того, что напишем геометрический шейдер, получающий на входе вершину (или точку), а на выход подающий один треугольник, представляющий травинку.

// Add inside the CGINCLUDE block.
struct geometryOutput
{
	float4 pos : SV_POSITION;
};

[maxvertexcount(3)]
void geo(triangle float4 IN[3] : SV_POSITION, inout TriangleStream<geometryOutput> triStream)
{
}

…

// Add inside the SubShader Pass, just below the #pragma fragment frag line.
#pragma geometry geo

Представленный выше код объявляет геометрический шейдер под названием geo с двумя параметрами. Первый, triangle float4 IN[3], сообщает, что он будет брать в качестве ввода один треугольник (состоящий из трёх точек). Второй, типа TriangleStream, настраивает шейдер для вывода потока треугольников, чтобы каждая вершина использовала для передачи своих данных структуру geometryOutput.

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

void geo(point vertexOutput IN[1], inout TriangleStream<geometryOutput> triStream)

Однако поскольку наш входящий меш (в данном случае это GrassPlane10x10, находящийся в папке Mesh) имеет топологию меша из треугольников, это вызовет несоответствие между топологией входящего меша и требуемым примитивом ввода. Хоть это и допускается в DirectX HLSL, но не допускается в OpenGL, поэтому будет выведена ошибка.

Кроме того, мы добавляем последний параметр в квадратных скобках над объявлением функции: [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);


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

// Update the return call in the vertex shader.
//return UnityObjectToClipPos(vertex);
return vertex;

…

// Update each assignment of o.pos in the geometry shader.
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));


Теперь наш треугольник рендерится в мире правильно. Однако, похоже, он создаётся только один. На самом деле, по одному треугольнику отрисовывается для каждой вершины нашего меша, но позиции, присваиваемые вершинам треугольника, постоянны — они не изменяются для каждой входящей вершины. Поэтому все треугольники располагаются один на другом.

Мы исправим это, сделав выходящие позиции вершин смещениями относительно входящей точки.

// Add to the top of the geometry shader.
float3 pos = IN[0];

…

// Update each assignment of 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));


Почему некоторые вершины не создают треугольника?

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

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

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


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

2. Касательное пространство


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


В касательном пространстве оси X, Y и Z задаются относительно нормали и позиции поверхности (в нашем случае вершины).

Как и любое другое пространство, мы можем задать касательное пространство вершины тремя векторами: right, forward и up. С помощью этих векторов мы можем создать матрицу для поворота травинки из касательного в локальное пространство.

Можно получить доступ к векторам right и up, добавив новые входящие данные вершин.

// Add to the CGINCLUDE block.
struct vertexInput
{
	float4 vertex : POSITION;
	float3 normal : NORMAL;
	float4 tangent : TANGENT;
};

struct vertexOutput
{
	float4 vertex : SV_POSITION;
	float3 normal : NORMAL;
	float4 tangent : TANGENT;
};

…

// Modify the vertex shader.
vertexOutput vert(vertexInput v)
{
	vertexOutput o;
	o.vertex = v.vertex;
	o.normal = v.normal;
	o.tangent = v.tangent;
	return o;
}

…

// Modify the input for the geometry shader. Note that the SV_POSITION semantic is removed.
void geo(triangle vertexOutput IN[3], inout TriangleStream<geometryOutput> triStream)

…

// Modify the existing line declaring pos.
float3 pos = IN[0].vertex;

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

// Place in the geometry shader, below the line declaring float3 pos.		
float3 vNormal = IN[0].normal;
float4 vTangent = IN[0].tangent;
float3 vBinormal = cross(vNormal, vTangent) * vTangent.w;

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

Имея все три вектора, мы можем создать матрицу для преобразования между касательным и локальным пространствами. Мы будем умножать каждую вершину травинки на эту матрицу перед передачей в UnityObjectToClipPos, который ожидает вершину в локальном пространстве.

// Add below the lines declaring the three vectors.
float3x3 tangentToLocal = float3x3(
	vTangent.x, vBinormal.x, vNormal.x,
	vTangent.y, vBinormal.y, vNormal.y,
	vTangent.z, vBinormal.z, vNormal.z
	);

Прежде чем использовать матрицу, мы перенесём код вывода вершин в функцию, чтобы не писать снова и снова одинаковые строки кода. Это называется принципом DRY, или don't repeat yourself («не повторяйся»).

// Add to the CGINCLUDE block.
geometryOutput VertexOutput(float3 pos)
{
	geometryOutput o;
	o.pos = UnityObjectToClipPos(pos);
	return o;
}

…

// Remove the following from the geometry shader.
//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);

// ...and replace it with the code below.
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))));

image

Это уже больше похоже на то, что нам нужно, но не совсем верно. Проблема здесь заключается в том, что изначально мы назначили направление «up» (вверх) оси Y; однако в касательном пространстве направление «вверх» обычно располагается вдоль оси Z. Сейчас мы внесём эти изменения.

// Modify the position of the third vertex being emitted.
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1))));


3. Внешний вид травы


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

3.1 Цветовой градиент


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

// Add to the geometryOutput struct.
float2 uv : TEXCOORD0;

…

// Modify the VertexOutput function signature.
geometryOutput VertexOutput(float3 pos, float2 uv)

…

// Add to VertexOutput, just below the line assigning o.pos.
o.uv = uv;

…

// Modify the existing lines in the geometry shader.
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-координаты трёх вершин травинок. Хотя мы раскрашиваем травинки простым градиентом, подобное расположение текстур позволит накладывать текстуры.

Теперь мы можем сэмплировать верхний и нижний цвета во фрагментном шейдере при помощи UV, а затем интерполировать их при помощи lerp. Также нам понадобится модифицировать параметры фрагментного шейдера, сделав входящими данными geometryOutput, а не только позицию float4.

// Modify the function signature of the fragment shader.
float4 frag (geometryOutput i, fixed facing : VFACE) : SV_Target

…

// Replace the existing return call.
return float4(1, 1, 1, 1);

return lerp(_BottomColor, _TopColor, i.uv.y);


3.2 Случайное направление травинок


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

В файле шейдера есть две функции, которые помогут нам это сделать: rand, генерирующая случайное число из трёхмерного ввода, и AngleAxis3x3, получающая угол (в радианах) и возвращающая матрицу, которая выполняет поворот на эту величину вокруг указанной оси. Последняя функция работает точно так же, как функция C# Quaternion.AngleAxis (только AngleAxis3x3 возвращает матрицу, а не кватернион).

Функция rand возвращает число в интервале 0...1; мы умножим его на 2 Pi, чтобы получить полный интервал угловых значений.

// Add below the line declaring the tangentToLocal matrix.
float3x3 facingRotationMatrix = AngleAxis3x3(rand(pos) * UNITY_TWO_PI, float3(0, 0, 1));

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

Поворот можно применить к травинке, умножив его на созданную матрицу tangentToLocal. Учтите, что умножение матриц не является коммутативным; порядок операндов важен.

// Add below the line declaring facingRotationMatrix.
float3x3 transformationMatrix = mul(tangentToLocal, facingRotationMatrix);

…

// Replace the multiplication matrix operand with our new 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 Случайный изгиб forward


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

// Add as a new property.
_BendRotationRandom("Bend Rotation Random", Range(0, 1)) = 0.2

…

// Add to the CGINCLUDE block.
float _BendRotationRandom;

…

// Add to the geometry shader, below the line declaring facingRotationMatrix.
float3x3 bendRotationMatrix = AngleAxis3x3(rand(pos.zzx) * _BendRotationRandom * UNITY_PI * 0.5, float3(-1, 0, 0));

Снова используем в качестве случайного seed позицию травинки, на этот раз выполнив её свизлинг для создания уникального seed. Также мы умножим UNITY_PI на 0.5; это даст нам случайный интервал 0...90 градусов.

Мы опять применяем эту матрицу через поворот, умножая всё в правильном порядке.

// Modify the existing line.
float3x3 transformationMatrix = mul(mul(tangentToLocal, facingRotationMatrix), bendRotationMatrix);


3.4 Ширина и высота


Пока размеры травинок ограничены шириной в 1 единицу и высотой в 1 единицу. Мы добавим свойства для управления размером, а также свойства для добавления случайной вариативности.

// Add as new properties.
_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

…

// Add to the CGINCLUDE block.
float _BladeHeight;
float _BladeHeightRandom;	
float _BladeWidth;
float _BladeWidthRandom;

…

// Add to the geometry shader, above the triStream.Append calls.
float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight;
float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth;

…

// Modify the existing positions with our new height and width.
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. Тесселяция


Тесселяция — это необязательный этап конвейера рендеринга, выполнямый после вершинного шейдера и до геометрического шейдера (если он есть). Его задача — подразделение одной входящей поверхности на множество примитивов. Тесселяция реализуется двумя программируемыми этапами: оболочечным (hull) и domain-шейдерами.

Для поверхностных шейдеров в Unity есть встроенная реализация тесселяции. Однако поскольку мы не используем поверхностные шейдеры, нам придётся реализовать собственные оболочечный и domain-шейдеры. В этой статье я не буду подробно рассматривать реализацию тесселяции, и мы просто воспользуемся имеющимся файлом CustomTessellation.cginc. Этот файл адаптирован из статьи Catlike Coding, которая является превосходным источником информации о реализации тесселяции в Unity.

Если мы включим объект TessellationExample в сцену, то увидим, что у него уже есть материал, реализующий тесселяцию. Изменение свойства Tessellation Uniform демонстрирует эффект подразделения.


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

// Add inside the CGINCLUDE block, below the other #include statements.
#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;
}*/

Заметьте, что вершинный шейдер vert в CustomTessellation.cginc просто передаёт входные данные напрямую на этап тесселяции; задачу по созданию структуры vertexOutput берёт на себя функция tessVert, вызываемая внутри domain-шейдера.

Теперь мы можем добавить оболочечный и domain-шейдеры в шейдер травы. Также мы добавим новое свойство _TessellationUniform для управления величиной подразделения — соответствующая этому свойству переменная уже объявлена в CustomTessellation.cginc.

// Add as a new property.			
_TessellationUniform("Tessellation Uniform", Range(1, 64)) = 1

…

// Add below the other #pragma statements in the SubShader Pass.
#pragma hull hull
#pragma domain domain

Теперь изменение свойства Tessellation Uniform позволит нам управлять плотностью травы. Я выяснил, что хорошие результаты получаются при значении 5.


5. Ветер


Мы реализуем ветер сэмплированием текстуры искажения. Эта текстура будет похожа на карту нормалей, только в ней вместо трёх каналов будет только два. Мы воспользуемся этими двумя каналами как направлениями ветра по X и Y.


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

// Add as new properties.
_WindDistortionMap("Wind Distortion Map", 2D) = "white" {}
_WindFrequency("Wind Frequency", Vector) = (0.05, 0.05, 0, 0)

…

// Add to the CGINCLUDE block.
sampler2D _WindDistortionMap;
float4 _WindDistortionMap_ST;

float2 _WindFrequency;

…

// Add to the geometry shader, just above the line declaring the transformationMatrix.
float2 uv = pos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y;

Мы применяем к позиции масштаб и смещение _WindDistortionMap, а затем ещё больше смещаем её на _Time.y, отмасштабированную на _WindFrequency. Теперь мы будем использовать эти UV для сэмплирования текстуры и создадим свойство для управления силой ветра.

// Add as a new property.
_WindStrength("Wind Strength", Float) = 1

…

// Add to the CGINCLUDE block.
float _WindStrength;

…

// Add below the line declaring float2 uv.
float2 windSample = (tex2Dlod(_WindDistortionMap, float4(uv, 0, 0)).xy * 2 - 1) * _WindStrength;

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

// Add below the line declaring float2 windSample.
float3 wind = normalize(float3(windSample.x, windSample.y, 0));

Теперь мы можем создать матрицу для поворота вокруг этого вектора и умножить её на нашу transformationMatrix.

// Add below the line declaring float3 wind.
float3x3 windRotation = AngleAxis3x3(UNITY_PI * windSample, wind);

…

// Modify the existing line.
float3x3 transformationMatrix = mul(mul(mul(tangentToLocal, windRotation), facingRotationMatrix), bendRotationMatrix);

Наконец, перенесём в редакторе Unity текстуру Wind (находящуюся в корне проекта) в поле Wind Distortion Map материала травы. Также зададим для параметра Tiling текстуры значения 0.01, 0.01.


Если трава не анимируется в окне Scene, то нажмите на кнопку Toggle skybox, fog, and various other effects, чтобы включить анимированные материалы.

Издалека трава выглядит правильно, однако если мы взглянем травинки вблизи, то заметим, что поворачивается вся травинка, из-за чего основание больше не прикреплено к земле.


Основание травинки больше не прикреплено к земле, а пересекается с ней (показано красным), и висит над плоскостью земли (обозначенной зелёной линией).

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

// Add below the line declaring float3x3 transformationMatrix.
float3x3 transformationMatrixFacing = mul(tangentToLocal, facingRotationMatrix);

…

// Modify the existing lines outputting the base vertex positions.
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. Кривизна травинок


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

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

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


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

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

Прежде чем мы начнём выводить из геометрического шейдера больше вершин, нам нужно увеличить maxvertexcount. Мы воспользуемся конструкцией #define, чтобы позволить автору шейдера управлять количеством сегментов и вычислять из него количество выводимых вершин.

// Add to the CGINCLUDE block.
#define BLADE_SEGMENTS 3

…

// Modify the existing line defining the 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-координату. Мы обновим имеющийся код для правильной работы функции.

// Update the existing code outputting the vertices.
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. Добавим под строкой float width следующее:

for (int i = 0; i < BLADE_SEGMENTS; i++)
{
	float t = i / (float)BLADE_SEGMENTS;
}

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

// Add below the line declaring float t.
float segmentHeight = height * t;
float segmentWidth = width * (1 - t);

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

// Add below the line declaring 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));

…

// Add just below the loop to insert the vertex at the tip of the blade.
triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix));

…

// Remove the existing calls to 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, которое мы назовём forward.

// Update the function signature of GenerateGrassVertex.
geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float forward, float2 uv, float3x3 transformMatrix)

…

// Modify the Y coordinate assignment of tangentPoint.
float3 tangentPoint = float3(width, forward, height);

Для вычисления смещения каждой вершины мы подставим в функцию pow значение t. После возведения t в степень её влияние на смещение forward будет нелинейным и превратит травинку в кривую.

// Add as new properties.
_BladeForward("Blade Forward Amount", Float) = 0.38
_BladeCurve("Blade Curvature Amount", Range(1, 4)) = 2

…

// Add to the CGINCLUDE block.
float _BladeForward;
float _BladeCurve;

…

// Add inside the geometry shader, below the line declaring float width.
float forward = rand(pos.yyz) * _BladeForward;

…

// Add inside the loop, below the line declaring segmentWidth.
float segmentForward = pow(t, _BladeCurve) * forward;

…

// Modify the GenerateGrassVertex calls inside the loop.
triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, segmentForward, float2(0, t), transformMatrix));
triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, segmentForward, float2(1, t), transformMatrix));

…

// Modify the GenerateGrassVertex calls outside the loop.
triStream.Append(GenerateGrassVertex(pos, 0, height, forward, float2(0.5, 1), transformationMatrix));

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


7. Освещение и тени


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

7.1 Отбрасывание теней


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

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

// Add below the existing 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 отрендерит карту теней с точки зрения создающего тени источника света, он запускает проход, «собирающий» тени в текстуру экранного пространства. Для сэмплирования этой текстуры нам нужно будет вычислять позиции вершин в экранном пространстве и передавать их во фрагментный шейдер.

// Add to the geometryOutput struct.
unityShadowCoord4 _ShadowCoord : TEXCOORD1;

…

// Add to the VertexOutput function, just above the return call.	
o._ShadowCoord = ComputeScreenPos(o.pos);

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

Почему UV-координата экранного пространства называется _ShadowCoord? Это не соответствует предыдущим правилам наименований
Многие встроенные шейдеры Unity делают предположения о названиях определённых полей в различных структурах шейдеров (некоторые даже делают предположения о названиях самих структур). То же самое относится и к используемому ниже макросу SHADOW_ATTENUATION. Если мы извлечём исходный код этого макроса из Autolight.cginc, то увидим, что координата тени должна иметь определённое название.

#define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)

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

// Add to the ForwardBase pass's fragment shader, replacing the existing return call.
return SHADOW_ATTENUATION(i);

//return lerp(_BottomColor, _TopColor, i.uv.y);

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

// Add to the ForwardBase pass's preprocessor directives, below #pragma target 4.6.
#pragma multi_compile_fwdbase


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

// Add at the end of the VertexOutput function, just above the return call.
#if UNITY_PASS_SHADOWCASTER
	// Applying the bias prevents artifacts from appearing on the surface.
	o.pos = UnityApplyLinearShadowBias(o.pos);
#endif


После применения линейного сдвига теней артефакты теней в виде полос исчезают с поверхности треугольников.

Почему вдоль краёв затенённых травинок есть артефакты?

Даже при включенном многосэмпловом сглаживании (multisample anti-aliasing MSAA) Unity не применяет сглаживания к текстуре глубин сцены, которая используется для построения карты теней экранного пространства. Поэтому когда сглаженная сцена сэмплирует несглаженную карту теней, возникают артефакты.

Одно из решений — использовать сглаживание, применяемое на этапе постобработки, доступное в пакете постобработки Unity. Однако иногда сглаживание постобработки неприменимо (например при работе с виртуальной реальностью); альтернативные решения проблемы рассматриваются в этом треде форумов Unity.

7.3 Освещение


Мы будем реализовывать освещение при помощи очень простого и распространённого алгоритма вычисления рассеянного освещения.


… где N — нормаль к поверхности, L — нормализованное направление основного источника направленного освещения, а I — вычисленное освещение. В этом туториале мы не будем реализовывать отражённое освещение.

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

Когда Blade Curvature Amount имеет значение 1, все травинки в касательном пространстве направлены в одну сторону: прямо противоположно оси Y. В качестве первого прохода нашего решения мы вычислим нормаль, предполагая отсутствие кривизны.

// Add to the GenerateGrassVertex function, belowing the line declaring tangentPoint.
float3 tangentNormal = float3(0, -1, 0);
float3 localNormal = mul(transformMatrix, tangentNormal);

tangentNormal, определяемая как прямо противоположная оси Y, преобразуется той же матрицей, которую мы использовали для преобразования касательных точек в локальное пространство. Теперь мы можем передавать её в функцию VertexOutput, а затем в структуру geometryOutput.

// Modify the return call in GenerateGrassVertex.
return VertexOutput(localPosition, uv, localNormal);

…

// Add to the geometryOutput struct.
float3 normal : NORMAL;

…

// Modify the existing function signature.
geometryOutput VertexOutput(float3 pos, float2 uv, float3 normal)

…

// Add to the VertexOutput function to pass the normal through to the fragment shader.
o.normal = UnityObjectToWorldNormal(normal);

Заметьте, что перед выводом мы преобразуем нормаль в мировое пространство; Unity передаёт шейдерам направление основного источника направленного света в мировом пространстве, поэтому это преобразование необходимо.

Теперь мы можем визуализировать нормали во фрагментом шейдере ForwardBase, чтобы проверить результат своей работы.

// Add to the ForwardBase fragment shader.
float3 normal = facing > 0 ? i.normal : -i.normal;

return float4(normal * 0.5 + 0.5, 1);

// Remove the existing return call.
//return SHADOW_ATTENUATION(i);

Так как в нашем шейдере Cull присвоено значение Off, рендерятся обе стороны травинки. Чтобы нормаль была направлена в нужную сторону, мы используем вспомогательный параметр VFACE, добавленный нами во фрагментный шейдер.

Аргумент fixed facing будет возвращать положительное число, если мы отображаем переднюю грань поверхности, и отрицательное число, если обратную. Мы используем это в коде выше, чтобы при необходимости переворачивать нормаль.


Когда Blade Curvature Amount больше 1, касательная позиция Z каждой вершины будет смещена на величину forward, передаваемую функции GenerateGrassVertex. Мы воспользуемся этим значением для пропорционального масштабирования оси Z нормалей.

// Modify the existing line in GenerateGrassVertex.
float3 tangentNormal = normalize(float3(0, -1, forward));

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

// Add to the ForwardBase fragment shader, below the line declaring 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;

// Remove the existing return call.
//return float4(normal * 0.5 + 0.5, 1);


Заключение


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


Текстура травы, включённая в пакет Standard Assets движка Unity. Множество травинок отрисовано на одном четырёхугольнике, что снижает количество треугольников в сцене.

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

Исходный код шейдера в репозитории GitHub

Дополнение: ваимодействие


Без возможности взаимодействия графические эффекты могут казаться игрокам статичными или безжизненными. Этот туториал уже и так очень длинный, поэтому я не стал добавлять раздел о взаимодействии объектов мира с травой.

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

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

+47
10.1k 186
Support the author
Comments 3
Top of the day