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



Повествование будет вестись на примере небольшой демки, которая генерирует сцену как на картинке выше. Мы пройдём увлекательное путешествие от подготовки данных на 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.0f; // Шаг решётки.
// Максимальные смещения в горизонтальной плоскости:
const GLfloat xDispAmp = 5.0f; 
const GLfloat zDispAmp = 5.0f;
const GLfloat yDispAmp = 0.3f; // Максимальное смещение по вертикали.
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.0f, 1.0f);
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. Основным продуктом шейдера управления тесселяцией является запись в следующие выходные массивы:

patch out 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.0f) {
        return 48;
    }
    if(dist < 20.0f) {
        return 24;
    }
    if(dist < 80.0f) {
        return 12;
    }
    if(dist < 800.0f) {
        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.0f, 1.0f);

то увидим нечто, похожее на картинки в документации:


Здесь и далее на скриншотах вид немного сбоку

Как же сделать из этих линий стебли? Для начала поставим их вертикально:

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



и научимся располагать их по кругу, поворачивая вокруг вертикальной оси:

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



Однако, такие линии больше похожи на забор, чем на куст. Чтобы сделать наш куст натуральнее, давайте изогнём линию как кубическую кривую Безье:

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

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

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



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

[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.0f + p1.y * (t1 * t1) * 3.0f - p2.y * (t * t) * 3.0f +
	p3.y * (t * t) * 3.0f - p2.y * t * t1 * 6.0f + p1.y * t * t1 * 6.0f,
    p0.x * (t1 * t1) *  3.0f - p1.x * (t1 * t1) * 3.0f + p2.x * (t * t) * 3.0f -
	p3.x * (t * t) * 3.0f + p2.x * t * t1 * 6.0f - p1.x * t * t1 * 6.0f,
    0.0f
));

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

И для наглядности уменьшим детализацию стеблей. Нормали отрисованы красным, а бинормали — синим:



Теперь коротко об анимации. Во-первых, стебли шевелятся сами по себе. Это сделано через круговое вращение опорных точек кривой вокруг других, первоначальных точек. При этом положение первоначальных точек и начальная фаза вращения зависят от случайной величины (помните случайную одномерную текстуру?), которая, в свою очередь, зависит от 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.0f - x) / 0.9f;
}
//...
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.0f * 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.0f, -1.0f)) + 1.0f;
}
//...
float cell = cellular2x2(vec2(fma(sfn, 100, rand(stemIdxFrag) + fragAlong * 3.0f),
        cap(fragAround)) * 10.0f).x * 0.3f;
outColor = ambient + diffuse + specular + vec4(0.0f, cell, 0.0f, 0.0f)

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

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

Зачем?


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

  • Стримить геометрию в видеопамять, а заодно и грузить центральный процессор. Это самый лобовой способ, так вряд ли кто-то делает для таких объёмов данных.
  • Хранить в памяти кости и использовать геометрический шейдер. Но тогда скорее всего понадобились бы дополнительные атрибуты вершин под какие-нибудь метаданные, плюс проброс больших объёмов данных через однородные переменные.
  • Пожертвовать хаотичностью анимации или добавить больше случайных величин. Это вылилось бы в большие объемы текстур со случайными данными или же в большее количество вычислений шума Перлина.

Полезные ссылки


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



Спасибо за внимание!

UPD1
Видео результата



UPD2
Ссылка на бинарники под Windows.
Поделиться с друзьями
-->

Комментарии (17)


  1. Mingun
    06.11.2016 17:15
    +4

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


    1. SergeySib
      07.11.2016 05:13
      +1

      Добавил видео в конец статьи.


      1. vc9ufi
        07.11.2016 08:11

        Так еще страшнее!

        Делал похожее для андроида, генератор рандомных цветков. На мой взгляд минус метода — жрет проц.


  1. SHVV
    06.11.2016 19:42

    Спасибо за статью. Тоже планирую активно грузить подобными задачами видеокарту.
    Вы случайно не сравнивали на сколько падает производительность по сравнению с рендерингом такой же но готовой геометрии? Какой оверхед от генерации всего этого дела на лету?

    P.S. Кто-нибудь вообще может мне объяснить зачем вершинный шейдер сделан в конвеере рендерига самым первым? На мой взгляд — это только мешает, так как не даёт полноценно переиспользовать вершинный код для расчёта материалов. Лучше бы он был после геометрического шейдера, или хотя бы настраиваемым.


    1. SergeySib
      07.11.2016 06:45

      Нет, пока не сравнивал. Если будет время, планирую сравнить как минимум с двумя вариантами: полностью готовая геометрия и готовые кости + геометрический шейдер.

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

      Существующий порядок шейдеров мне тоже казался вывернутым наизнанку. Но наверно логика здесь примерно такая: в геометрическом шейдере можно сделать всё тоже самое, что и в вершинном, и даже ещё больше. Если нужна повершинная обработка сгенерированной геометрии, то её всегда можно написать перед EmitVertex().


    1. PkXwmpgN
      07.11.2016 13:06

      Кто-нибудь вообще может мне объяснить зачем вершинный шейдер сделан в конвеере рендерига самым первым?

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


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


      1. SergeySib
        07.11.2016 13:42

        Но как отсечь точку в вершинном шейдере? Даже если вы не запишите ничего в gl_Position, точка всё равно пойдёт дальше по конвейеру со значениями по умолчанию, то есть (0, 0, 0, 0). Вызов discard() в вершинном шейдере отсутствует, он есть только во фрагментном, экономия на котором получится «автоматически» для невидимых (и даже просто для загороженных) биллбордов. В итоге, все «отсечённые» биллборды будут генерироваться в начале координат.

        Можно конечно принять решение об отмене генерации в вершинном шейдере, и передать его в геометрический через in/out. Но то же самое можно сделать непосредственно в геометрическом шейдере без передачи переменной между стадиями.


        1. PkXwmpgN
          07.11.2016 13:59
          +1

          Но как отсечь точку в вершинном шейдере?

          Зачем? В вершином шейдере ничего не отсекается, вершинный шейдер предоставляет данные для отсечения, которое происходит во время сборки примитивов. Или миллион точек или 2 миллиона треугольниов.


          во фрагментном, экономия на котором получится «автоматически» для невидимых

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


          1. SergeySib
            07.11.2016 17:15

            В вершином шейдере ничего не отсекается, вершинный шейдер предоставляет данные для отсечения, которое происходит во время сборки примитивов.

            Но ведь ничто принципиально не мешает вычислить те же данные для отсечения в геометрическом шейдере? Позвольте проиллюстрировать кодом. Правильно ли я понимаю, что вы имеете ввдиду что-то подобное?
            // Вершинный:
            layout(location=0) in vec4 position;
            out bool visible;
            void main(void) {
                visible = point_is_visible(position);
                gl_Position = billboard_position_transformations(position);
                // или просто gl_Position = position;
            }
            
            // Геометрический:
            layout(points) in;
            layout(triangle_strip,) out;
            in bool visible[];
            void main() {
                if(visible[0]) {
                    // Генерируем два треугольника.
                }
            }
            


            Я же предлагаю такое:
            // Вершинный:
            layout(location=0) in vec4 position;
            void main(void) {
                gl_Position = position;
            }
            
            // Геометрический:
            layout(points) in;
            layout(triangle_strip,) out;
            void main() {
                 vec4 position = billboard_position_transformations(gl_in[0].gl_Position);
                // опять же, функции billboard_position_transformations может не быть.
                if(point_is_visible(position)) {
                    // Генерируем два треугольника.
                }
            }
            


            Эти два куска кода выполняют одно и то же: вычисляют point_is_visible для каждой точки (миллион точек), и генерируют два треугольника, если 999999 не видны. То есть в геометрическом шейдере делается всё то же, что может делаться в вершинном. Атрибуты и юниформы, используемые в point_is_visible можно сделать доступными на любой их этих стадий. Второй пример кода как раз похож на строки из демки:
            float halfspaceCull =
                    step(dot(eyePosition - gl_in[gl_InvocationID].gl_Position.xyz, lookDirection), 0);
            gl_TessLevelOuter[1] = lod() * halfspaceCull;
            

            Только здесь TCS вместо геометрического. Он освобождает генератор абстрактных патчей и TES от генерации геометрии, если точка позади камеры.

            Отсечение и перспективное разделение происходят до фрагментного шейдера

            Так я ровно об этом и говорил. То есть не будут обрабатываться фрагменты полигонов трёх больших групп примитивов:
            1. Если примитив вообще не был выпущен геометрическим шейдером, то есть когда point_is_visible(position) == true. Путь входной точки на этом заканчивается.
            2. Если из точки по какой-то причине были сгенерированы два треугольника, но они не попали в видимую область из-за проективного преобразования.
            3. Если примитив в области видимости загорожен другим примитивом. Проверка z-буфера тоже происходит перед фрагментным шейдером.

            Мне всего лишь хотелось проиллюстрировать, что когда мы говорим о генерации геометрии, нет смысла говорить о явном discard() во фрагментом шейдере.


            1. PkXwmpgN
              07.11.2016 17:48

              Правильно ли я понимаю, что вы имеете ввдиду что-то подобное?

              Нет, я имею ввиду стандартный этап графического конвеера под названием Primitive Assembly (сборка примитивов).
              Он выполняется до тесcеляции и геометрического шейдера. На этом этапе формируются примитивы с учетом отсечения (полупрастранствами или лицевыми гранями), могут также добавляться дополнительные вершины. Входными данными для этого этапа служат данные выпушенные вершинным шейдером.


              Если у меня миллион частиц, а я вижу только одну, в геометрический шейдер попадет только одна точка. Все остальные отбросятся на этапе сборки примитивов и я постою только 2 треугольника. Или я не прав?


              1. SergeySib
                07.11.2016 18:36
                +2

                Он выполняется до тесcеляции и геометрического шейдера.

                Primitive assembly бывает разным и в нескольких местах. После вершинного шейдера действительно есть сборка примитивов, но она собирала линии и треугольники только если за ней больше нет преобразований геометрии. Как сборка примитивов перед тесселяцией и геометрическим шейдером может объявить примитив невидимым? Ведь эти стадии могут перепахать геометрию до неузнаваемости и сделать видимым то, что было якобы невидимым после вершинного шейдера. А как интерпретировать примитив, у которого после стадий VertexShader->PrimitiveAssembly 32 вешины, а не 2 или 3? А ведь такое вполне может быть при включенной тесселяции.
                При включенной тесселяции вершинный шейдер обрабатывает абстрактные наборы атрибутов, которые называются «вершинами» только условно. То есть они совсем не обязаны отражать положение конечной геометрии. Например, программа вообще может подать в конвейер набор одномерных нулей. Эти нули нужны только для того, чтобы конвейер запустился. Тогда вершинный или какой нибудь из последующих шейдеров может достать данные о геометрии из юниформов или из текстуры. Или же вообще сгенерировать их процедурно псевдослучайной функцией.
                Ну или другой экстремальный вариант: рисуем ландшафт и подаём на конвейер по набор вершин с единственным атрибутом на каждый тайл. И этот атрибут обозначает цвет, не неся никакой информации о положении тайла в пространстве и его форме. Высоту и/или искривления тайлов шейдеры теселяции/геометрии возьмут из текстуры, а пространственное положение высчитают из встроенной переменной gl_PrimitiveID.

                Если у меня миллион частиц, а я вижу только одну, в геометрический шейдер попадет только одна точка.

                Почему? Отсечение плоскостями должно происходить после проективного преобразования. Иначе откуда конвейер узнает какова матрица проекции? Но если мы применили, например, перспективную проекцию к условному скелету до геометрического шейдера, то как нарастить на него полигональное мясо в геометрическом шейдере? Это ж будет адова некрасивая математика с вычислительным оверхедом. Куда удобнее иметь дело с геометрией в системе координат модели, и только перед самым EmitVertex() умножать её на мировую матрицу (перенос, поворот) и на проективную.
                Такой способ как раз отражает последняя диаграмма на странице 8 официального референса: отсечение и перспективное деление происходят после геометрического шейдера, последующей сборки примитивов (они на это стадии могут быть только точками, line_strip или triangle_strip) и feedback transform.


                1. PkXwmpgN
                  07.11.2016 21:20

                  Согласен, вы правы. Мне почему-то казалось что можно разгрузить немного gs.


  1. js605451
    06.11.2016 20:26
    -6

    1. 4eyes
      07.11.2016 04:19

      Вы бы еще глистов в естественной среде обитания выложили. Зачем?


    1. sidristij
      07.11.2016 10:49
      +2

      Предупреждать надо, что за ссылкой. Тут сидят честные люди, открывающие ссылки на работе, сидя монитором к коллективу )


  1. bigov
    08.11.2016 11:34
    +1

    С технической точки зрения крутая статья! Про шейдеры на русском очень мало похожих материалов. А на эстетическую составляющую картинки автор и не претендовал. Кстати, прямоугольные стебли лично мне показались более естественными. Если их сделать еще более плоскими и покороче, то очень даже похоже не траву получится.


    1. SergeySib
      08.11.2016 11:49

      С технической точки зрения крутая статья!

      Спасибо!

      Кстати, прямоугольные стебли лично мне показались более естественными.

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