Процедурна рослинність на OpenGL і GLSL

У цьому пості я хотів би розповісти про використання апаратної тесселяції і геометричного шейдерів для генерації великої кількості геометрії на основі мінімальних вхідних даних. Сподіваюся, пост буде корисний тим, хто має початкове уявлення про програмування шейдерів, але ще не пізнав всю міць програмованого графічного конвеєра. Це не керівництво по шейдерам для початківців, тому багато моменти їх роботи завіяні під килим або забезпечені посиланням на відповідну документацію.



Оповідання буде вестися на прикладі невеликий демки, яка генерує сцену як на картинці вище. Ми пройдемо захоплюючу подорож від підготовки даних на CPU до запису значень кольору на вихід фрагментного шейдера.

Цілі та засоби
При написанні демки я поставив перед собою наступні цілі:

  • Максимально скоротити обсяг даних, що зберігаються у відеопам'яті. Як наслідок:
  • Максимально утилізувати графічний процесор, використовуючи всі доступні стадії конвеєра.
  • Зробити деякі параметри сцени налаштованим.
  • Зосередитися на геометрії і написанні шейдерів, витративши мінімум зусиль на інші компоненти. Тому був використаний найбільш звичний мені інструментарій: C++11 (gcc), Qt5 + qmake, GLSL.
  • По можливості спростити складання і запуск готової демки на різних платформах.
Виходячи з цього списку, довелося пожертвувати опрацюванням деяких моментів:

  • Основний цикл зроблений примітивно. Тому швидкість анімації і переміщення камери залежить від частоти кадрів, а значить і від положення камери в просторі.
  • В єдиний клас камери замішані її координати, орієнтація, проекція і функції для зміни всього цього. У такому вигляді його написання не зайняло багато часу і дозволило зробити досить оптимальний передача параметрів камери в шейдер.
  • Клас шейдера виконаний у вигляді достатньо тонкого обгортки над відповідним класом Qt5. Загальні для різних стадій шматки коду склеюються воєдино і віддаються на відкуп оптимізатору компілятора, який викине невикористаний код та глобальні змінні.
  • У програмі використовується єдиний шейдер, тому передача даних в нього зроблена без «новомодних» UBO. У цьому випадку вони не додали б продуктивності, ускладнивши код.
  • Лічильник кадрів в секунду зроблений на основі запитів OpenGL. Тому він показує не «справжні» FPS, а трохи завищений ідеалізований показник, в якому не враховується оверхед, привнесений Qt.
  • Круте освітлення не було метою написання даної демки, тому використовується проста реалізація освітлення Фонга з одним, захардкоженным в шейдере, джерелом світла.
  • Реалізація шумів в шейдери була взята у стороннього автора.
Щоб надати можливість читачеві оглядати весь код демки по ходу розповіді, відразу наведу посилання репозиторій.

Короткий огляд генерації геометрії
Ми будемо перетворювати набір патчів, кожен з яких містить єдину вершину. Кожна вершина, у свою чергу, містить єдиний четырехкомпонентный атрибут. Використовуючи цю мінімальну порцію даних в якості затравки, ми наростимо» на кожен такий патч (тобто на одну точку) цілий кущ ворухливих стебел. Крім того, всі кущі можуть бути схильні до дії вітру до заданих користувачем параметрами. Велика частина роботи по генерації куща виконується в шейдере тесселяції (Tesselation evaluation shader і геометричному шейдере. Так, у шейдере тесселяції генерується скелет куща з усіма деформаціями, що вносяться шевелением і вітром, а в геометричному шейдере на цей скелет натягується полігональна «плоть», товщина якої залежить від висоти кістки на скелеті. Фрагментний шейдер, як водиться, обчислює освітлення і завдає процедурно генеровану текстуру на основі діаграми Вороного.

Отже, почнемо!

CPU
Шлях до розфарбовування пікселів монітора починається з їх підготовки на CPU. Як було сказано вище, кожна «модель» сцени спочатку складається з однієї вершини. Зробимо цю вершину чотиривимірний, де перші три компоненти — це положення вершини в просторі, а четверта компонента — кількість стебел у кущі. Таким чином, кущі зможуть відрізнятися один від одного кількістю стебел. Почнемо генерацію координат з вузлів квадратної решітки кінцевого розміру, і возмутим кожну координату на випадкову величину з заданого інтервалу:

const int numNodes = 14; // Кількість вузлів гратки вздовж однієї сторони.
const GLfloat gridStep = 3.0 f; // Крок решітки.
// Максимальні зміщення в горизонтальній площині:
const GLfloat xDispAmp = 5.0 f; 
const GLfloat zDispAmp = 5.0 f;
const GLfloat yDispAmp = 0.3 f; // Максимальний зсув по вертикалі.
numClusters = numNodes * numNodes; // Кількість кущів.
GLfloat *vertices = new GLfloat[numClusters * 4]; // Буфер для генеруються вершин.
std::random_device rd;
std::mt19937 mt(rd());
std::uniform_real_distribution<GLfloat> xDisp(-xDispAmp, xDispAmp);
std::uniform_real_distribution<GLfloat> yDisp(-yDispAmp, yDispAmp);
std::uniform_real_distribution<GLfloat> zDisp(-zDispAmp, zDispAmp);
std::uniform_int_distribution<GLint> numStems(12, 64); // Кількість стебел.
for(int i = 0; i < numNodes; ++i) {
for(int j = 0; j < numNodes; j++) {
const int idx = (i * numNodes + j) * 4;
vertices[idx] = (i - numNodes / 2) * gridStep + xDisp(mt);
vertices[idx + 1] = yDisp(mt);
vertices[idx + 2] = (j - numNodes / 2) * gridStep + zDisp(mt);
vertices[idx + 3] = numStems(mt);
}
}

Згенеровані дані відправимо в відеопам'ять:

GLuint vao; // https://www.opengl.org/wiki/Vertex_Specification#Vertex_Array_Object
GLuint posVbo; // https://www.opengl.org/wiki/Vertex_Specification#Vertex_Buffer_Object
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &posVbo);
glEnableVertexAttribArray(ATTRIBINDEX_VERTEX);
glBindBuffer(GL_ARRAY_BUFFER, posVbo);
glVertexAttribPointer(ATTRIBINDEX_VERTEX, 4, GL_FLOAT, GL_FALSE, 0, 0);
glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * numClusters * 4, vertices, GL_STATIC_DRAW);
glFinish();
delete[] vertices;

Тепер метод відтворення всього газону згенерованої трави виглядає дуже лаконічно:

void ProceduralGrass::draw() {
glBindVertexArray(vao);
glPatchParameteri(GL_PATCH_VERTICES, 1);
glDrawArrays(GL_PATCHES, 0, numClusters);
glBindVertexArray(0);
}

Крім геометрії, шейдери нам знадобляться рівномірно розподілені випадкові числа. Найбільш оптимально на CPU отримати числа в інтервалі [0; 1], а на GPU в кожному конкретному місці приводити їх до необхідного інтервалу. У відеопам'ять ми їх доставимо у вигляді одномірної текстури, у якій в якості фільтрації встановлено вибір найближчого значення. Нагадаю, що в двовимірному випадку така фільтрація приводить до подібного результату:

foobar
Джерело

Код генерації та налаштування текстури:

const GLuint randTexSize = 256;
GLfloat randTexData[randTexSize];
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_real_distribution<float> dis(0.0 f, 1.0 f);
std::generate(randTexData, randTexData + randTexSize, [&](){return dis(gen);});
// Create and tune random texture.
glGenTextures(1, &randTexture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_1D, randTexture);
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MAX_LEVEL, 0);
glTexImage1D(GL_TEXTURE_1D, 0, GL_R16F, randTexSize, 0, GL_RED, GL_FLOAT, randTexData);
glUniform1i(glGetUniformLocation(grassShader.programId(), "urandom01"), 0);

Вершинний шейдер
Як правило, при використанні тесселяції вершинний шейдер виходить дуже ледачим, так як з нього починається конвеєр, а геометрії як такої ще немає. У нашому випадку вершинний шейдер тривіальний. В ньому ми просто відправимо точку входу відразу на вихід:

layout(location=0) in vec4 position;
void main(void) {
gl_Position = position;
}

Тесселяція
Апаратна тесселяція — це потужна техніка підвищення деталізації полігональних моделей засобами GPU. Не плутайте з алгоритмами розбиття полігонів на трикутники, що виконуються на центральному процесорі. Апаратна тесселяція складається з трьох стадій графічного конвеєра, дві з яких можна програмувати (виділені жовтим):



Детальніше про шейдери і їх входах/виходах розказано нижче. Тут варто сказати, що на вхід тесселяції подається патч, що складається з довільної кількості вершин, яке фіксоване для кожного виклику glDraw* і обмежена як мінімум числом 32. Атрибути цих вершин не мають яких-небудь виділених значень, а значить в обох шейдери програміст може інтерпретувати їх як завгодно. Це дає воістину фантастичні можливості, порівняно зі старими верховими шейдерами.

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

Шейдер управління теселяцією

У загальному випадку шейдеру управління теселяцією доступні всі вершини вхідної патча, що пройшли через вершинний шейдер окремо. На його вхід надходить надходить кількість вершин в патчі gl_PatchVerticesIn, порядковий номер патча gl_PrimitiveID і порядковий номер вихідної вершини gl_InvocationID, про якого пізніше. Порядковий номер патча gl_PrimitiveID вважається в рамках одного виклику glDraw*. Самі дані вершин доступні через масив структур gl_in, оголошений наступним чином:

in gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_in[gl_MaxPatchVertices];

Цей масив індексується від нуля до gl_PatchVerticesIn — 1. Найбільший інтерес в цьому оголошенні являє поле gl_Position, в якому записані дані з виходу вершинного шейдера. Кількість вершин вихідного патча задається в коді самого шейдера глобальним оголошенням:

layout (vertices = 1) out; // В даному випадку задана одна вершина

і вона не зобов'язана співпадати з кількістю вершин у вхідному патчі. Кількість викликів шейдера дорівнює кількості вихідних вершин. У кожному виклику шейдер має доступ до всіх вхідних вершин патча, але при цьому має право записувати тільки за індексом gl_InvocationID вихідного масиву gl_out, який оголошено

out gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_out[];

Тепер перейдемо до більш цікавого факту. Шейдер може записувати тільки за індексом gl_InvocationID, однак він може читати вихідний масив по кожному індексом! Ми пам'ятаємо, що робота шейдерів дуже сильно распараллелена, і порядок їх виклику не визначений. Це накладає обмеження на спільне використання даних шейдерами, але робить можливим SIMD-паралелізм і дає компілятору карт-бланш на використання найсуворіших оптимізацій. Щоб ці правила не порушувалися, в шейдере управління теселяцією доступна бар'єрна синхронізація. Виклик вбудованої функції barrier() блокує виконання до тих пір, поки всі шейдери патча не викличуть цю функцію. На виклик цієї функції стягнуто серйозні обмеження: її не можна викликати з будь-якої функції, окрім main, її не можна викликати ні в одній конструкції управління потоком (for, while, switch), і її не можна викликати після return.

І, нарешті, найцікавіше на цій стадії конвеєра: вихідні дані вершин — не головне. Полігони будуть збиратися не з координат, записаних у gl_out. Основним продуктом шейдера управління теселяцією є запис у наступні вихідні масиви:

out patch float gl_TessLevelOuter[4];
patch out float gl_TessLevelInner[2];

Ці масиви управляють кількістю вершин у так званих абстрактних латках, і саме тому ця стадія називається управлінням теселяцією. Абстрактний патч — це набір точок двовимірної геометричної фігури, який генерується на стадії tessellation primitive generation. Абстрактні патчі бувають трьох видів: трикутники, квадрати і ізолінії. При цьому для кожного виду абстрактного патча шейдер повинен заповнити тільки потрібні йому індекси gl_TessLevelOuter і gl_TessLevelInner, а інші індекси цих масивів ігноруються. Генерований патч містить не тільки вершини геометричної фігури, але і координати точок на кордонах і всередині фігури. Наприклад, квадрат при деяких значеннях gl_TessLevelOuter і gl_TessLevelInner буде сформовано з трикутників такого виду:



Лівий нижній кут квадрата завжди має координату [0; 0], правий верхній — [1; 1], а всі інші точки будуть мати відповідні координати зі значеннями від 0 до 1.

Ізолінії — це по суті теж квадрат, розбитий на прямокутники, а не на трикутники. Значення координат точок на ізолініях так само будуть належати інтервалу від 0 до 1.

А ось координати всередині трикутника влаштовані принципово по-іншому: в двовимірному трикутнику використовуються трикомпонентні барицентричні координати. При цьому їх значення так само лежать на відрізку від 0 до 1, а трикутник є рівностороннім.

Конкретний вид поділу (яке, власне, і називається теселяцією в первісному сенсі) абстрактного патча сильно залежить від gl_TessLevelOuter і gl_TessLevelInner. Ми тут не будемо зупинятися на ньому детально, як і не будемо розбирати ніж Inner відрізняється від Outer. Все це детально викладено в відповідному розділі керівництва з OpenGL.

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

gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;

Для генерації геометрії ми будемо використовувати прямокутну сітку, тобто абстрактний патч типу «ізолінії». Генерація ізоліній управляється тільки двома змінними: gl_TessLevelOuter[0] — кількість точок по координаті y, і gl_TessLevelOuter[1] — кількість точок по x. У нашій програмі цикл y буде пробігати по стеблах куща, а для кожного стебла цикл x буде пробігати уздовж стебла. Тому кількість стебел (четверту координату вхідної точки) ми записуємо на відповідний вихід:

gl_TessLevelOuter[0] = gl_in[gl_InvocationID].gl_Position.w;

Кількість точок вздовж стебла визначає кількість відрізків, з яких стебло складено, тобто його деталізацію. Щоб не витрачати ресурси даремно, зробимо рівень деталізації залежним від відстані між камерою і кущем:

uniform vec3 eyePosition; // Положення камери передається в цю змінну з об'єкта камери.
int lod() {
// Відстань від камери до куща:
float dist = distance(gl_in[gl_InvocationID].gl_Position.xyz, eyePosition);
// Кількість точок на стеблі залежно від відстані:
if(dist < 10.0 f) {
return 48;
}
if(dist < f 20.0) {
return 24;
}
if(dist < 80.0 f) {
return 12;
}
if(dist < 800.0 f) {
return 6;
}
return 4;
}

З боку CPU перед кожним викликом glDraw* заповнюються однорідні змінні:

grassShader.setUniformValue("eyePosition", camera.getPosition());
grassShader.setUniformValue("lookDirection", camera.getLookDirection());

Перша з них — це координати камери в просторі, а друга — напрям погляду. Знаючи положення камери, напрям погляду і координату куща, ми можемо дізнатися, чи цей кущ позаду камери:



Якщо кущ знаходиться спереду, то кут між напрямком з камери вперед і з камери на кущ буде гострим, в іншому випадку — тупим. Відповідно, у першому випадку скалярний добуток зображених на малюнку векторів буде більше нуля, а в другому — менше. Обчислимо скалярний добуток і скористаємося функцією step зі сходинкою в нулі, щоб отримати змінну, яка дорівнює нулю, якщо кущ позаду і одиниці, якщо він спереду:

float halfspaceCull = step(dot(eyePosition - gl_in[gl_InvocationID].gl_Position.xyz, lookDirection), 0);

Нарешті, ми можемо записати кількість точок для стебел майбутнього куща:

gl_TessLevelOuter[1] = lod() * halfspaceCull;

Шейдер тесселяції

Зауваження щодо термінології: в англійському оригіналі цей шейдер називається Tesselation evaluation shader. У російському інтернеті можна знайти дослівні переклади кшталт «шейдер оцінки тесселяції» або «шейдер обчислення тесселяції». Вони виглядають незграбно і, на мій погляд, не відображають суть цього шейдера. Тому тут tesselation evaluation shader буде називатися просто шейдером тесселяції, на відміну від попередньої стадії, де був шейдер управління теселяцією.

Тесселяція включається тільки якщо в шейдерну програму доданий шейдер тесселяції. При цьому шейдер управління теселяцією не є обов'язковим: його відсутність рівносильно подачі вхідного патча на вихід без змін. Значення масивів gl_TessLevel* при цьому можна задати з боку CPU викликом glPatchParameterfv з параметром GL_PATCH_DEFAULT_OUTER_LEVEL або GL_PATCH_DEFAULT_INNER_LEVEL. В цьому випадку всі абстрактні патчі в шейдере тесселяції будуть однаковими. Додавання в програму тільки шейдера управління теселяцією не має сенсу і призводить до помилки компонування шейдера. Вид абстрактного патча, на відміну від його параметрів, визначається в коді шейдера тесселяції:

layout(isolines, equal_spacing) in; // У нашому випадку це ізолінії.

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

in gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_in[gl_MaxPatchVertices];

а так само вже знайомі нам gl_PatchVerticesIn, gl_PrimitiveID, gl_TessLevelOuter і gl_TessLevelInner. Дві останні змінні мають той самий тип, що і в шейдере управління теселяцією, але доступні тільки на читання. Нарешті, сама цікава вхідна змінна — це

in vec3 gl_TessCoord;

В ній знаходяться координати поточної (для даного виклику) точки абстрактного патча. Вона оголошена як vec3, однак gl_TessCoord.z має сенс тільки для трикутників. Читання цієї координати для квадратів або ізоліній не визначено.

На вихід шейдера можна подати кілька змінних. Головна з них — vec4 gl_Position, в яку потрібно записувати координати вершин, з яких будуть зібрані примітиви для наступної стадії конвеєра. У нашому випадку це послідовність відрізків, т. к. шейдер тесселяції виготовляє тільки скелет для майбутнього куща.

Отже, у нас є багато (аж до 4096) вершин абстрактного патча, організованих в лінії, які розбиті на рівні відрізки. Якщо ми отрисуем цю фігуру у вигляді ліній без змін:

gl_Position = vec4(gl_TessCoord.xy, 0.0 f, 1.0 f);

то побачимо щось схоже на картинки в документації:


Тут і далі на скріншотах вигляд трохи збоку

Як же зробити з цих ліній стебла? Для початку поставимо їх вертикально:

gl_Position = vec4(gl_TessCoord.тому 0.0 f, 1.0 f);



і навчимося розташовувати їх по колу, повертаючи навколо вертикальної осі:

vec3 position = vec3(2.0 f, gl_TessCoord.x, 0.0 f);
float alpha = gl_TessCoord.y * 2.0 f * M_PI;
float cosAlpha = cos(alpha);
float sinAlpha = sin(alpha);
mat3 circDistribution = mat3(
cosAlpha, 0.0 f, -sinAlpha,
0.0 f, 1.0 f, 0.0 f,
sinAlpha, 0.0 f, cosAlpha);
position = circDistribution * position;
gl_Position = vec4(position, 1.0 f);



Однак, такі лінії більше схожі на паркан, ніж на кущ. Щоб зробити наш кущ натуральніше, давайте изогнем лінію як кубічну криву Безьє:

Картинка з Вікіпедії, стаття про криві Безьє.

І тут дуже згодиться координата gl_TessCoord.x, про яку ми домовилися думати, що вона пробігає вздовж кожного стебла від нуля до одиниці. Вид кривої повністю залежить від опорних точок P0… P3. Низ стебла у нас завжди буде розташовуватися на землі, а верх повинен обов'язково дивитися в бік неба, тому приймемо P0 = (0; 0). А для вибору хоча б приблизного положення залишилися вільних точок чудово підійде сайт cubic-bezier.com, єдиною метою якого є побудова кривої необхідного вигляду. Тепер якщо gl_TessCoord.x підставити у формулу кривої Безьє, то вийде ламана, вершини якого лежать на кривій, а відрізки апроксимують криву:

float t = gl_TessCoord.x; // Параметр кривої.
float t1 = t - 1.0 f; // Для зручності використовуємо параметр, що проходить від кореня до вершини стебла.
// Крива Безьє:
position.xy = -p0 * (t1 * t1 * t1) + p3 * (t * t * t) + p1 * t * (t1 * t1) * 3.0 f - p2 * (t * t) * t1 * 3.0 f;
// Відсуваємо стебло від початку координат, щоб всі стебла не росли з однієї точки:
position.x += 2.0 f;
// Будуємо стебло у вертикальній площині. За поворот по колу відповідає код, наведений вище:
position.z = 0.0 f;



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

[B', [B' B"]] (1)

Для однозначного завдання площині нам потрібен ще один вектор. У нашому випадку вся крива розташована у вертикальній площині XY, а значить головна нормаль до розташована в ній же. Тому бинормаль до кривої дістається нам задарма — це всього лише постійний вектор (0; 0; 1). Тепер ми згадуємо, що з затишної площині XY стебло повертається навколо початку координат, а отже, і нормальну площину теж треба повернути. Для цього досить помножити обидва її утворюють вектора на ту ж матрицю повороту, що і точки стебла. Збираємо всі разом:

// Глобальні оголошення:
out vec3 normal;
out vec3 binormal;
// Нормаль:
normal = normalize(
circDistribution * // Матриця повороту, визначена в коді вище.
vec3( // Вектори нормалі в вершианх ламаної, обчислені за формулою (1):
p0.y * (t1 * t1) * -3.0 f + p1.y * (t1 * t1) * 3.0 f - p2.y * (t * t) * 3.0 f +
p3.y * (t * t) * 3.0 f - p2.y * t * t1 * 6.0 f + p1.y * t * t1 * 6.0 f,
p0.x * (t1 * t1) * 3.0 f - p1.x * (t1 * t1) * 3.0 f + p2.x * (t * t) * 3.0 f -
p3.x * (t * t) * 3.0 f + p2.x * t * t1 * 6.0 f - p1.x * t * t1 * 6.0 f,
0.0 f
));

// Бинормаль:
binormal = (circDistribution * vec3(0.0 f, 0.0 f, 1.0 f));

І для наочності зменшимо деталізацію стебел. Нормалі отрисованы червоним, а бинормали — синім:



Тепер коротко про анімацію. По-перше, стебла рухаються самі по собі. Це зроблено через кругове обертання опорних точок кривої навколо інших, початкових точок. При цьому положення початкових точок і початкова фаза обертання залежать від випадкової величини (пам'ятаєте випадкову одновимірну текстуру?), яка, в свою чергу, залежить від gl_TessCoord.y і gl_PrimitiveID. Таким чином, кожен стебло в кожному кущі ворушиться по своєму, що створює ілюзію хаосу. А так як ворушіння зроблено через пересування опорних точок, то нормалі і бинормали залишаються повністю коректними. По суті, у нас вийшла скелетна анімація, у якої кістки генеруються нальоту, а не займають пам'ять.

Крім власного ворушіння кущів, на них впливає «вітер». Вітер — це зміщення вершин стебла в заданому користувачем напрямку на величину, яка залежить від двох параметрів користувача і шуму Перлина. При цьому вітер не повинен зміщувати коріння стебел, тому величина зміщення множиться на функцію гнучкості:

float flexibility(const in float x) {
return x * x;
}

взяту від координати уздовж стебла t1. Користувальницькі параметри вітру називаються «швидкістю» і «турбулентністю» чисто умовно, тому що їх зміна в доступному користувачеві діапазоні схоже на зміну цих параметрів повітряного потоку. Тим не менш, цей «вітер» не має ніякого відношення до реальної фізики. Повзунок швидкості в інтерфейсі навмисно обмежений невеликою величиною, тому що вітер застосовується до скелету вже після обчислення нормалей без їх коригування. З-за цього нормалі перестають бути такими, і при сильному спотворенні скелета (великій швидкості вітру), з'являються самопересечения полігонів.

Навіщо шум Перліна, якщо є «галаслива» текстура? Справа в тому, що значення текстури не є безперервною функцією від координати, на відміну від шуму Перлина. Тому, якщо в кожному кадрі робити зсув, залежне від галасливої текстури, ми матимемо хаотичне смикання з частотою кадрів замість плавного вітру. Якісна реалізація шумів Перлина взята у Стефана Густавсона.

Що ще знадобиться для нарощування полігонів? По-перше, товщина стебла повинна зменшуватися від кореня до верхівки. Тому заведемо відповідну вихідну змінну і передамо до неї товщину, залежну від координати уздовж стебла:

out float stemThickness;
float thickness(const in float x) {
return (1.0 f - x) / 0.9 f;
}
//...
stemThickness = thickness(gl_TessCoord.x);

Саму координату уздовж стебла і номер стебла в кущі теж передамо далі по конвеєру:

out float along;
flat out float stemIdx;
// ...
along = gl_TessCoord.x;
stemIdx = gl_TessCoord.y;

Вони знадобляться нам при накладення текстури.

Геометричний шейдер
Нарешті ми підійшли до завершення геометричній частині нашого шляху. На вхід геометричного шейдера ми отримуємо примітиви цілком. Якщо на вході стадії тесселяції були довільні патчі, які могли містити пристойну кількість даних, то тут примітиви — це точки, лінії, або трикутники. Якщо шейдери тесселяції відсутні, то на вхід геометричного шейдера з'являються примітиви (як правило, трикутники) з виклику glDraw*, які, можливо, було повершинно оброблені вершинним шейдером. У нашому ж випадку тесселяція видає ізолінії, які ми приймаємо у вигляді відрізків, тобто пар вершин:

layout(lines) in;

Ці вершини записані у вбудований вхідний масив

in gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_in[];

який в нашому випадку можна індексувати лише нулем йди одиницею. Користувальницькі вхідні змінні так само визначені як масиви з тим же діапазоном індексів:

in vec3 normal[];
in vec3 binormal[];
in float stemThickness[];
in float along[];
flat in float stemIdx[];

На вихід геометричного шейдера можна подавати точки, послідовність ліній або послідовність трикутників. Ми скористаємося останнім варіантом:

layout(triangle_strip) out;

Шейдер викликається для кожного вхідного примітиву і може видати кілька примітивів. Це робиться за допомогою двох вбудованих функцій. Як і на попередніх стадіях, вихідна змінна називається gl_Position. Після її заповнення шейдер повинен викликати вбудовану функцію EmitVertex(), щоб повідомити відеокарті про закінчення формування вершини. По закінченні формування всіх вершин примітива викликається функція EndPrimitive();

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

Наприклад, ось так виглядає кущ з 5-секторних стебел з неинтерполированными (flat) координатами і нормалями фрагментів для наочності:



А ось код, що реалізує описаний підхід:

for(int i = 0; i < numSectors + 1; i++) { // Цикл по секторам
float around = i / float(numSectors); // Координата на окружності, відображеної на [0; 1]
float alpha = (around) * 2.0 f * M_PI; // Аргумент (кут) поточної точки на колі
for(int j = 0; j < 2; j++) { // Цикл по кінцях відрізка
// Радіус-вектор вершини майбутнього полігону щодо кінця відрізка:
vec3 r = cos(alpha) * normal[j] + sin(alpha) * binormal[j];
// Вершина полігону у світовій системі координат:
vec3 vertexPosition = r * stemRadius * stemThickness[j] + gl_in[j].gl_Position.xyz; 
// Передаємо координату вершини у фрагментний шейдер з інтерполяцією.
// Вона знадобиться для обчислення освітлення.
// Її потрібно передавати через власну змінну, т. к. gl_Position відповідає
// тільки за формування полігонів, але не з'являється на вході фрагментного шейдера.
fragPosition = vertexPosition;
// Аналогічно з нормаллю.
fragNormal = r;
// Координата вздовж стебла передається без змін і буде интерполирована.
fragAlong = along[j];
// Координата навколо стедля так само буде интерполирована. У сукупності
// fragAlong і fragAround утворюють систему координат на поверхні стебла, яка
// буде використана для накладання текстури.
fragAround = around;
// Фрагментний шейдер буде мати уявлення про те, до якої частини моделі
// належить оброблюваний фрагмент. Такої інформації можна придумати
// дуже різноманітне застосування.
stemIdxFrag = stemIdx[j];
// Нарешті, запишемо координату вершини з перетворенням камери і проекції.
// Порівняйте це зі "старомодними" шейдерами, де аналогічне перетворення робилося
// в вершинном шейдере для кожної вершини окремо.
gl_Position = viewProjectionMatrix * vec4 (vertexPosition, gl_in[j].gl_Position.w);
EmitVertex();
}
}
EndPrimitive();

Фрагментний шейдер
Фрагментний шейдер виглядає досить стандартно, тому розповім про нього коротко. У ньому звичайне освітлення по Фонгу підсумовується з процедурної текстурою у вигляді клітин на основі діаграми Вороного, взятої у вже знайомого нам Стефана Густавсона. Колір «текселя» залежить не тільки від текстурних координат, але так само від часу (номера кадру) і від номера стебла в кущі:

out vec4 outColor;
float sfn = float(frameNumber) / totalFrames;
float cap(const in float x) {
return -abs(fma(x, 2.0 f, -1.0 f)) + 1.0 f;
}
//...
float cell = cellular2x2(vec2(fma(sfn, 100, rand(stemIdxFrag) + fragAlong * 3.0 f),
cap(fragAround)) * f 10.0).x * 0.3 f;
outColor = ambient + diffuse + specular + vec4(0.0 f, cell, 0.0 f, 0.0 f)

Таким чином, клітини плавно «повзуть» по стеблу, виглядаючи різними на різних стеблах.

На цьому шлях даних по графічному конвеєрі закінчується, а значить, саме час підвести підсумок.

Навіщо?
Отже, що ж ми виграли в порівнянні з більш традиційними підходами? Відповідь: пам'ять, гнучкість і економію пропускної здатності. При спробі зробити анімацію аналогічної гнучкості іншим способом, нам довелося б робити щось з цього:

  • Стримить геометрію у відеопам'ять, а заодно і вантажити центральний процесор. Це самий лобової спосіб, оскільки навряд чи хто-то робить для таких обсягів даних.
  • Зберігати в пам'яті кістки і використовувати геометричний шейдер. Але тоді швидше за все знадобилися б додаткові атрибути вершин під які-небудь метадані, плюс перенесення великих обсягів даних через однорідні змінні.
  • Пожертвувати хаотичністю анімації або додати більше випадкових величин. Це вилилося б у великі обсяги текстур з випадковими даними або ж більшу кількість обчислень шуму Перлина.
Корисні посилання
Більша частина посилань вже перебуває в тексті. Тут я хотів би залишити корисні посилання, які були використані в ході роботи над демкой:

Спасибі за увагу!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.