Расшифровка чёрных ящиков из habr-статьи «Процедурная растительность на OpenGL и GLSL».

itxor - рендеринг динамически-изменяемой воды с помощью шейдеров тесселяции
Тесселяция способна на прекрасные вещи. (Картинка не имеет никакого отношения к статье, кроме тесселяции. Она просто для привлечения вашего внимания)

На хабре есть прекрасная статья от 2016 года, рассказывающая как отрендерить на сцене динамическую растительность, красиво развивающуюся под действием «ветра». Там же, в самом начале статьи, приложена ссылка на github автора, и он крайне советует читать код по мере изучения материала статьи. Я же в данной статье постараюсь дать подробные комментарии к его шейдеру управления тесселяции (далее просто шейдер тесселяции) и приведу немного ссылок по тесселяции в OpenGL, которые могут оказаться полезными.

[spoiler title=’Бесполезные рассуждения’ style=’green’ collapse_link=’true’]Забегая в конец статьи — материал прекрасный. Много полезной (реально полезной) и новой информации, а особенно хорошо то, что есть пример кода и описание его работы от человека, который в этом, судя по всему, неплохо шарит (в отличии от меня). На шейдеры тесселяции в рунете статьей — по пальцам пересчитать, а тут такой подарок. Но есть и один минус — автор по ходу повествования приводит лишь примитивные части кода из своих шейдеров, или же те части, которые были потом им доработаны перед релизом. Тем, кто параллельно смотрит в код  — крайне сложно порой понимать, что же в них происходит (например я очень много путался с константах и не понимал, нафига же они нужны и как тут оказались). Как человек, никогда до этого не работавший с шейдерами тесселяции, но немного поработавший со стандартным для OpenGL версии 3.* вершинными и фрагментыми шейдерами, я не испытал сложностей с шейдером контроля тесселяции (TCS) — в ~35 строках кода сложно запутаться, да и плюсом этот момент автор отлично прокомментировал. Поэтому под разбор попал только шейдер управления тесселяцией (TES)[/spoiler]

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

  • Перевод (как я понял) урока по тесселяции с сайта OGLDev с примитивными примерами и некоторым количеством кода. Урок внёс некоторую ясность в процесс побега вершин от вершинного шейдера по катакомбам тесселяции до фрагментного, но ясности не хватало.
  • Далее был найден туториал от steps3d по шейдерам тесселяции. Общая картина стала налаживаться.
  • Само собой некоторые аспекты объясняются в статье с habr, которую, собственно, и разбираем.
  • Но самый подробный разбор со схемой был найден в книге Computer Graphics Through OpeNG, 2-ого издания. В ней приводится 4-ый участник тесселяционной оргии, о котором до этого не было сказано. Да и в целом визуализация написанного — всегда приятна.

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

Как было сказано выше (в спойлере) шейдер контроля тесселяции проблем не вызывает. Поэтому начинаем с

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

//выходной тип патча - изолинии с равным расстоянием
layout(isolines, equal_spacing) in;
//к слову, шейдер вызывается для каждой точки из группы Control Points
out vec3 normal; //будем выводить нормаль точки
out vec3 binormal; //бинормаль точки
out float stemThickness; //толщину стебля в данной координате 
out float along; //текущую координату вдоль стебля
flat out float stemIdx; //номер стебля в кусте
//flat тут означает, что значение не будет интерполировано

Далее уже веселее:

//включён ли ветер. 
//Если не передаём переменную через uniform с CPU, тогда по-умолчанию - включён
uniform float windEnabled = 1.0f; 
//включена ли турбулентность. К слову, турбулентность и ветер тут не имеют 
// никакого отношения к физике, просто автор подогнал параметры модели таким 
// образом, чтобы поведение модели было схоже с действием ветра и турбулентности 
// в реальной жизни
uniform float shiveringEnabled = 1.0f; 

//значение того, насколько сильно будут колыхаться стебли (или псевдо-турбулентность)
uniform float windTurbulence = 0.2; 

//значение скорости движения стеблей
uniform float windSpeed = 3000.0f; 

//направление ветра (в нашем случае дует исключительно по Оси X)
uniform vec3 windDirection = vec3(1.0f, 0.0f, 0.0f);

Дальше по-порядку методы:

// Классический 2D шум Перлина
float cnoise2dab(const in vec2 x, const in float a, const in float b) {
    return fma((cnoise2d(x) + 1.0f) * 0.5f, b-a, a);
}

Как писал автор в статье на habr, шум он не реализовывал и взял готовое решение у Стефана Густавсона. Как можно видеть, в функции cnoise2dab вызывается метод cnoize2d от аргумента x — он описан в файле classicnoize2D.glsl и, собственно, вычисляет шум.

Дальше:

// Вычисление толщины стебля в зависимости от координаты абстрактного патча,
// на которой мы в текущий момент находимся
float thickness(const in float x) {
    return (1.0f - x) / 0.9f;
}

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

Дальше:

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

Функция гибкости. Реализована для того, чтобы величину смещения текущей точки патча (а смещается она от ветра, турбулентности и шума) домножить на значение функции от аргумента — позиции текущей точки по оси x — т.е. вдоль стебля. Т.к. у основания позиция этой точки равняется 0 — основание не будет смещаться от ветра и прочих источников. Согласитесь, если стебель не рос из земли а «плавал» над ней — было бы немного странно.

Переходим к функции main().

// Coordinate along the stem for point position computtations.
stemThickness = thickness(gl_TessCoord.x);

//координата точки патча, для которой вызван шейдер
along = gl_TessCoord.x;

//номер стебля в кусте
stemIdx = gl_TessCoord.y;

//ID примитива. Вычисляется как отношение между ID патча (gl_PrimitiveID) к 
//количеству кустов на сцене (numPrimitives - передаётся в shader.glsl)
float primitiveIdx = gl_PrimitiveID / float(numPrimitives);

// Текущая координата вершины патча, для которой вызван шейдер
float t = gl_TessCoord.x;

Далее параметры, которые не описаны в статье и приходится понимать, откуда они и зачем:

//частота вращения точек патча относительно исходных 
//от эффекта турбулентности
//почему именно такая? я так думаю, что экспериментально
//поиграйтесь с ней в коде и поймёте, что это оптимальное значение
const float shiverRotFreq = 1526.0f;
// периодичность сдвига стеблей от эффекта турбулентности
// в зависимости с частотой, что мы ввели строчкой выше
const float shiverDriftFreq = shiverRotFreq / (2.0f * M_PI);

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

//берём рандомный шум из текстуры с равномерно распределёнными случайными числами
float shiverPhase = texture(urandom01, gl_TessCoord.y).r * 2.0f * M_PI;
//радиус отклонения точки патча
const float shiverRadius = 0.1f;
// текущий номер кадра (если турбулентность вообще включена) + ID примитива
// и умноженное на период колебаний для текущей точки патча
// используется затем как вторая компонента вектора, переданного 
// в аргументы функции, генерирующей шум Перлина
float pIdxSfn = fma(sfn, shiveringEnabled, primitiveIdx) * shiverDriftFreq;
// так же для шума перлина, по факту принимает либо номер текущего кадра, 
// либо 0, если турбулентность отключена
float seSfn = sfn * shiveringEnabled;
//рандомный номер стебля
float randY = rand(gl_TessCoord.y);

Далее идёт тёмный лес, который автор позаимствовал у крутого парня, написавшего классический шум Перлина. Собственно, генерируется 3 вектора с шумами:

    vec2 v1 = vec2 (
        cnoise2dab( vec2((randY + 27.0f + seSfn) * shiverDriftFreq, pIdxSfn) , 0.0f, 0.5f),
        cnoise2dab( vec2((randY + 31.0f + seSfn) * shiverDriftFreq, pIdxSfn) , 0.5f, 1.0f)
    );

    vec2 v2 = vec2(
        cnoise2dab(vec2((randY + 99.0f + seSfn) * shiverDriftFreq, pIdxSfn), 0.5f, 1.0f),
        cnoise2dab(vec2((randY + 51.0f + seSfn) * shiverDriftFreq, pIdxSfn), 0.0f, 0.6f)
    );

    vec2 v3 = vec2(
        cnoise2dab(vec2((randY + 46.0f + seSfn) * shiverDriftFreq, pIdxSfn), 0.75f, 1.3f),
        cnoise2dab(vec2((randY + 76.0f + seSfn) * shiverDriftFreq, pIdxSfn), 0.75f, 1.3f)
    );

Теперь приступаем к тому, чтобы изогнуть нашу линию как кубическую кривую Безье. Автор приводит формулу, приведу и я её здесь, чтобы не надо было бегать по вкладкам:

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

И поехали в код. Так как мы работаем со скелетом, мы можем предположить (и тем самым очень облегчить себе жизнь), что скелет располагается в 2D-плоскости (нормали потом повернём отдельно). Таким образом все вектора для каждой из опорных точек P0 — P3  кривой Безье состоят из двух координат — x и y. t, в нашем случае — это координата текущей точки на абстрактном патче.

Т.к. наш куст растёт из земли, то координата точки P0 = (0;0). Остальные рассчитываются как интерполяция(mix) между вектором шума(v*), сдвигом битов у переменных частоты, периода и радиуса вращения стебля влево на значение шума (rotate) и между этими двумя параметрами при интерполировании встаёт переменная, определяющая включена ли турбулентность.

vec2 p0 = vec2(0.0f, 0.0f);
vec2 p1 = mix(v1, rotate(shiverRotFreq, shiverPhase, shiverRadius, v1), shiveringEnabled);
vec2 p2 = mix(v2, rotate(shiverRotFreq, shiverPhase, shiverRadius, v2), shiveringEnabled);
vec2 p3 = mix(v3, rotate(shiverRotFreq, shiverPhase, shiverRadius, v3), shiveringEnabled);

Далее деформируем изолинию с помощью формулы кривой Безье и смещаем радиус на рандомное значение, взятое из зашумленной текстуры:

const float stemOffset = 0.3f;
const float stemRandAmplitude = 1.0f;
float t1 = t - 1.0f;
vec3 position;

// Deform isoline as cubic bezier curve.
position.xy = -p0 * (t1 * t1 * t1) + p3 * (t * t * t) + p1 * t * (t1 * t1) * 3.0f -
    p2 * (t * t) * t1 * 3.0f;
// Offset by noisy circle radius.
position.x +=
    fma(texture(urandom01, gl_TessCoord.y + primitiveIdx).r, stemRandAmplitude, stemOffset);
position.z = 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;

normal = normalize(circDistribution * vec3(
    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
));

 

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

binormal = (circDistribution * vec3(0.0f, 0.0f, 1.0f));

// переводим в мировые координаты
position += gl_in[0].gl_Position.xyz;

vec3 windState = position;
windState.x += sfn * windSpeed;
vec3 windDisplacement = windDirection *
    flexibility(t) * (cnoise3d(windState * windTurbulence) + 1.0f) * 0.5f;

gl_Position = vec4(position + windDisplacement * windEnabled, 1.0f);

Таким образом, более-менее код стал понятен.

Большое спасибо автору за этот пример и в целом за статью — она прекрасна.

Ещё раз продублирую на неё ссылку.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *