Створення шейдера трави в движку Unity
З цього туториала ви навчитеся писати геометричний шейдер для генерації травинок з вершин вхідного меша і використовувати тесселяцію для управління щільністю трави.
Стаття описує поетапний процес написання шейдера трави в 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))));
Це вже більше схоже на те, що нам потрібно, але не зовсім вірно. Проблема тут полягає в тому, що спочатку ми призначили напрям «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# Кватерніонів.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));
...
// Just Add 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 through the normal 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);
Висновок
У цьому туториале трава покриває невелику область розміром 10×10 одиниць. Щоб шейдер міг покривати великі відкриті простори зі збереженням високої продуктивності, необхідно внести оптимізації. Можна застосувати тесселяцію на підставі відстані, щоб далеко від камери рендерилось менше травинок. Крім того, на далеких відстанях замість окремих травинок можна перетворювати групи травинок за допомогою одного чотирикутника з накладеною текстурою.
Текстура трави, включена в пакет Standard Assets движка Unity. Безліч травинок намальовано на одному чотирикутнику, що знижує кількість трикутників у сцені.
Хоч нативно ми і не можемо використовувати геометричні шейдери з поверхневими шейдерами, для удосконалення або розширення функціональності освітлення і затінення при необхідності застосування стандартної моделі освітлення Unity можна вивчити цей репозиторій GitHub, демонструє вирішення проблеми за допомогою відкладеного рендеринга і ручного заповнення G-буферів.
Вихідний код шейдера в репозиторії GitHub
Додаток: ваимодействие
Без можливості взаємодії графічні ефекти можуть здаватися гравцям статичними або млявими. Цей туторіал вже і так дуже довгий, тому я не став додавати розділ про взаємодію об’єктів світу з травою.
Наївна реалізація інтерактивної трав містила б два компоненти: щось в ігровому світі, здатний передавати дані в шейдер, щоб повідомити йому, з якою частиною трави виконується взаємодія, та код шейдере для інтерпретації цих даних.
Приклад того, як це можна реалізувати з водою, показаний тут. Його можна адаптувати для роботи з травою; замість відтворення мерехтіння в місці, де знаходиться персонаж, можна повертати травинки вниз для імітації впливу кроків.