Программирование игр, создание игрового движка, OpenGL, DirectX, физика, форум
GameDev.ru / Программирование / Статьи / Пишем простой рейтрейсер используя Vulkan Raytracing

Пишем простой рейтрейсер используя Vulkan Raytracing

Автор:

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

Часть 0. Приветствие
Часть 1. Кролики и чайники
Часть 2. Камера, мотор!
Часть 2. Сталь и мрамор
Часть 3. Да будет свет!
Часть 4. Зеркало
Часть 5. Зацикливаемся
Часть 6. Стекло и финал

Часть 0. Приветствие

rtxON_part2 | Пишем простой рейтрейсер используя Vulkan Raytracing

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

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

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

Часть 1. Кролики и чайники

Первым делом, нам понадобится сама сцена. Я взял на себя смелость подготовить переосмысленную версию классической иконы рейтрейсинга — сцены Тернера Уиттеда “Зеркальный и стеклянный шары над куском линолеума”. В нашем случае сцена называется — “Хромированный чайник и стеклянный кролик на мраморном полу”.

Модель кролика (Stanford Bunny) была позаимствована отсюда (https://casual-effects.com/g3d/data10/index.html#mesh3). Модель чайника используется та, что встроена в 3DS Max. Модель пола была мастерски изготовлена автором статьи собственноручно. Текстуры взяты с сайта https://texturehaven.com.

Для загрузки сцены используется библиотека tinyobjloader. Для загрузки текстур используется всеми любимая stb_image.

Также, для более удобной работы со сценой и ее ресурсами были добавлены вспомогательные структуры RTAccelerationStructure, RTMesh, RTMaterial, RTScene.

Код, по большей части, остается без изменений, только вместо одной Bottom Level Acceleration Structure (BLAS), теперь у нас их несколько, а соответственно и несколько VkGeometryNV и VkGeometryInstance. Важно также отметить, что полю instanceId каждого инстанса мы теперь присваиваем порядковый номер объекта в сцене. Это поможет нам в будущем обращаться к его атрибутам. Создав для каждого объекта сцены свой BLAS и instance, мы строим Top Level Acceleration Sctructure (TLAS). Наша сцена готова.

Часть 2. Камера, мотор!

Для нашего треугольника нам хватало ортографической проекции, где все лучи шли параллельно. Чтобы видеть нашу новую сцену во всей ее красе, нам понадобится перспективная камера. Мы не будем заниматься моделированием реальной линзы, а остановимся на простой камере-обскуре (pinhole camera). В такой модели камеры наши лучи выходят из одной точки (позиции наблюдателя) и расходятся, образуя пирамиду.

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

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

Для передачи параметров камеры в шейдер используется Uniform Buffer Object (UBO) следующего содержания:

struct UniformParams {
    vec4 camPos;
    vec4 camDir;
    vec4 camUp;
    vec4 camSide;
    vec4 camNearFarFov;
};

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

Теперь же, если запустить наше приложение, то мы увидим следующую картину.

Изображение

Теперь вместо одного цветного треугольника, у нас на экране несколько сотен тысяч цветных треугольников. Хвала рейтрейсингу!

Часть 2. Сталь и мрамор

Разноцветные треугольники это, конечно же, хорошо, но как насчет текстур? К счастью, текстурирование треугольников мало чем отличается от такового в растеризации — нам все так же нужны текстурные координаты каждой вершины треугольника, их интерполированное значение для интересующего нас пиксела, а также сама текстура и сэмплер (sampler).

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

Для передачи атрибутов в шейдер нам пригодятся Shader Storage Buffer Object (SSBO,  или StructuredBuffer в терминах DirectX). Складываем вершинные атрибуты в SSBO и передаем в шейдер, вроде ничего сложного, но как узнать какие именно вершины нам нужны? Для начала нам нужно узнать в какой именно треугольник мы попали, и поможет нам в этом gl_PrimitiveID, который содержит порядковый номер треугольника в данном объекте. Уже лучше, но чаще всего наша геометрия индексирована, чтобы избежать дублирования данных, а значит нам понадобятся также индексы, которые мы передадим через SSBO.

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

Итак, давайте запишем, какие в итоге буферы нам понадобятся:

  • MaterialsIDs (SSBO, индекс материала для каждого треугольника)
  • FacesBuffer (SSBO, индексы вершин)
  • AttribsBuffer (SSBO, вершинные атрибуты)
  • Texture (sampler2D, текстура)

Но ведь у нас несколько объектов в сцене, как быть с этим? К счастью для этого существует расширение VK_EXT_descriptor_indexing, добавляющее много вкусностей и послаблений для дескрипторов, но самое главное для нас — возможность при создании разметки набора дескрипторов (descriptor set layout) указать, что этих самых дескрипторов неопределенное количество. Таким образом мы можем в рантайме решать размерности массивов передаваемых ресурсов, что просто идеально для нашей ситуации!

Разбирать подробно я это расширение не буду, это выходит за рамки данной статьи, так что для дальнейшего ознакомления можете пройти по этой ссылочке:
https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html… ptor_indexing.

Помните, как мы при создании инстансов в качестве id указывали порядковый номер объекта? Так вот, он то нам теперь и пригодится, чтобы брать из массивов нужные буферы! Для этого нам понадобится встроенная переменная gl_InstanceCustomIndexNV, которая как раз и содержит то самое значение. Вот так будет выглядеть наш код текстурирования:

const vec3 barycentrics = vec3(1.0f - HitAttribs.x - HitAttribs.y, HitAttribs.x, HitAttribs.y);
const uint matID = MatIDsArray[nonuniformEXT(gl_InstanceCustomIndexNV)].MatIDs[gl_PrimitiveID];
const uvec4 face = FacesArray[nonuniformEXT(gl_InstanceCustomIndexNV)].Faces[gl_PrimitiveID];
VertexAttribute v0 = AttribsArray[nonuniformEXT(gl_InstanceCustomIndexNV)].VertexAttribs[int(face.x)];
VertexAttribute v1 = AttribsArray[nonuniformEXT(gl_InstanceCustomIndexNV)].VertexAttribs[int(face.y)];
VertexAttribute v2 = AttribsArray[nonuniformEXT(gl_InstanceCustomIndexNV)].VertexAttribs[int(face.z)];

const vec2 uv = BaryLerp(v0.uv.xy, v1.uv.xy, v2.uv.xy, barycentrics);
const vec3 texel = textureLod(TexturesArray[nonuniformEXT(matID)], uv, 0.0f).rgb;

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

Изображение

Уже намного лучше, но чего-то не хватает… Ах да, освещение!

Часть 3. Да будет свет!

Давайте добавим простейшее освещение, классику компьютерной графики — диффузная модель освещения Ламберта. Согласно этой модели, освещение рассеивается равномерно по полусфере, а освещенность диктуется только плотностью светового потока, которая обратно пропорциональна углу потока света к поверхности.

Или, проще говоря, всеми любимый мистер N dot L.

И тут начинают проявляться достоинства рейтрейсинга перед растеризацией — тени! Точка находится в тени, если она не “видит” напрямую источник света, и это очень легко сделать с помощью рейтрейсинга — достаточно лишь пустить луч в направлении источника света и посмотреть, не попадется ли нам чего по пути. Если нашли пересечение, значит источник света закрыт и мы “в тени”. Если же пересечения не было — значит мы можем считать освещение.

Для этого, при нахождении первичного пересечения, нам нужно построить новый луч, и вызвать traceNV еще раз, для проверки “видимости” источника света. Делать это можно и в Hit шейдере, но рекомендуется все вызовы traceNV производить в Raygen шейдере, так как это позволяет планировщику (scheduler) работать с максимальной эффективностью.

Еще одна оптимизация — использовать RayPayload как можно меньшего размера, а также специализированные Hit и Miss шейдеры. Для “теневых” лучей нам в качестве RayPayload понадобится всего одно значение: было ли пересечение, или нет. Соответственно в Hit шейдере мы будем отмечать, что пересечение было, и в Miss шейдере, что не было.

Давайте дополним наш код Raygen шейдера:

const vec3 hitColor = PrimaryRay.colorAndDist.rgb;
const float hitDistance = PrimaryRay.colorAndDist.w;
const vec3 hitNormal = PrimaryRay.normal.xyz;
float lighting = 1.0f;
// if we hit something
if (hitDistance > 0.0f) {
    const vec3 hitPos = origin + direction * hitDistance;
    const vec3 toLight = normalize(Params.sunPosAndAmbient.xyz);
     const vec3 shadowRayOrigin = hitPos + hitNormal * 0.01f;
     const uint shadowRayFlags = gl_RayFlagsOpaqueNV | gl_RayFlagsTerminateOnFirstHitNV;
    const uint shadowRecordOffset = 1;
    const uint shadowMissIndex = 1;
     traceNV(Scene,
             rayFlags,
             cullMask,
             shadowRecordOffset,
             stbRecordStride,
             shadowMissIndex,
             shadowRayOrigin,
             0.0f,
             toLight,
             tmax,
             SWS_LOC_SHADOW_RAY);
     if (ShadowRay.distance > 0.0f) {
        lighting = Params.sunPosAndAmbient.w;
    } else {
        lighting = max(Params.sunPosAndAmbient.w, dot(hitNormal, toLight));
    }
}

Заметьте, что для “теневых” лучей мы указываем флаг gl_RayFlagsTerminateOnFirstHitNV. Таким образом, мы остановим трассировку при первом же пересечении, без поиска ближайшего. Ведь нам важен сам факт наличия пересечения.

Таким образом, мы проверяем, было ли первичное пересечение, или мы ударились в “небо”. Если пересечение было, то восстанавливаем координаты точки пересечения (ведь мы знаем расстояние до точки пересечения от начальной точки луча), получаем направление на источник света, и вызываем traceNV, указывая в качестве шейдеров наши специализированные “теневые” шейдеры, а также расположение PayLoad для “теневых” лучей.

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

После этого мы проверяем было ли пересечение, и если не было, то считаем освещение по модели Ламберта. Если же пересечение было, то в качестве освещения возьмем константное значение окружающего света (ambient light).

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

Изображение

Часть 4. Зеркало

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

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

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

Для начала определимся, что отражающими свойствами у нас будет обладать чайник, ведь он легко может быть хромированным. Для этого мы в Hit шейдере будем записывать в RayPayload флаг, означающий попали ли мы в чайник или нет (так как я создавал сцену, я знаю что порядковый номер чайника = 2).

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

const float isTeapot = PrimaryRay.normal.w;
// if teapot - let's reflect!
if (isTeapot > 0.0f) {
    const vec3 hitPos = origin + direction * hitDistance + hitNormal * 0.01f;
    const vec3 reflectedDir = reflect(direction, hitNormal);
     traceNV(Scene,
             rayFlags,
             cullMask,
             primaryRecordOffset,
             stbRecordStride,
             primaryMissIndex,
             hitPos,
             0.0f,
             reflectedDir,
             tmax,
             SWS_LOC_PRIMARY_RAY);
}

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

Изображение

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

Во-первых, нам как минимум нужно рассчитать освещение в точке пересечения. А во-вторых, если мы снова попали в зеркальную поверхность, то нужно отразить луч и продолжить трассировку. Попробуйте поставить два зеркала напротив друг-друга, и вы увидите “бесконечное” отражение. Мы себе, конечно же, бесконечное отражение позволить не можем, но, как минимум несколько уровней отражений вполне можем осилить.

Часть 5. Зацикливаемся

Все мы знаем, что рекурсия это плохо. Но рекурсия на GPU еще хуже. И, хоть Vulkan Raytracing и предоставляет нам возможности для организации рекурсивной трассировки, производительности ради стоит ее, по возможности, избегать.

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

  • Если попали в “небо” — прерываем цикл
  • Если попали в чайник — строим отраженный луч и продолжаем
  • Если попали оставшуюся часть сцены — считаем освещение и прерываем цикл

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

Изменим наш шейдер в соответствии с изложенным алгоритмом:

vec3 finalColor = vec3(0.0f);
for (int i = 0; i < SWS_MAX_RECURSION; ++i) {
    traceNV(Scene,
             rayFlags,
             cullMask,
             primaryRecordOffset,
             stbRecordStride,
             primaryMissIndex,
             origin,
             tmin,
             direction,
             tmax,
             SWS_LOC_PRIMARY_RAY);

    const vec3 hitColor = PrimaryRay.colorAndDist.rgb;
    const float hitDistance = PrimaryRay.colorAndDist.w;

    // if hit background - quit
    if (hitDistance < 0.0f) {
        finalColor += hitColor;
        break;
    } else {
        const vec3 hitNormal = PrimaryRay.normal.xyz;
        const float isTeapot = PrimaryRay.normal.w;

        const vec3 hitPos = origin + direction * hitDistance;

        if (isTeapot > 0.0f) {
            // our teapot is mirror, so continue

            origin = hitPos + hitNormal * 0.01f;
            direction = reflect(direction, hitNormal);
        } else {
            // we hit diffuse primitive - simple lambertian

            const vec3 toLight = normalize(Params.sunPosAndAmbient.xyz);
            const vec3 shadowRayOrigin = hitPos + hitNormal * 0.01f;

            traceNV(Scene,
                     rayFlags,
                     cullMask,
                     shadowRecordOffset,
                     stbRecordStride,
                     shadowMissIndex,
                     shadowRayOrigin,
                     0.0f,
                     toLight,
                     tmax,
                     SWS_LOC_SHADOW_RAY);

            const float lighting = 
                (ShadowRay.distance > 0.0f) ? Params.sunPosAndAmbient.w : max(Params.sunPosAndAmbient.w, dot(hitNormal, toLight));

            finalColor += hitColor * lighting;

            break;
        }
    }
}

Результат — правдоподобные отражения:

Изображение

Часть 6. Стекло и финал

Наш рейтрейсер начинает обретать черты взрослого трассировщика. Теперь, имея на руках универсальный цикл трассировки, мы можем расширять функционал, добавляя новые материалы и более реалистичные модели освещения. Довольно небольшими изменениями можно получить полноценный трассировщик путей (path tracer) для расчета реалистичных изображений.

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

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

const float objId = float(gl_InstanceCustomIndexNV);
PrimaryRay.normalAndObjId = vec4(normal, objId);

Подберем индекс преломления для нашего кролика. Я выбрал обычное стекло, с индексом преломления равным 1.52
Так как функция refract принимает соотношение индексов преломления двух сред, а в нашем случае это воздух / стекло, потому финальное значение равно 1.0 / 1.52

const float kBunnyRefractionIndex = 1.0f / 1.52f;

Теперь добавим в наш цикл проверку на попадание в кролика:

if (objectId == OBJECT_ID_TEAPOT) {
    // our teapot is mirror, so reflect and continue

    origin = hitPos + hitNormal * 0.001f;
    direction = reflect(direction, hitNormal);
} else if (objectId == OBJECT_ID_BUNNY) {
    // our bunny is glass, so refract and continue

    const float NdotD = dot(hitNormal, direction);
    const vec3 refrNormal = (NdotD > 0.0f) ? -hitNormal : hitNormal;

    origin = hitPos + direction * 0.001f;
    direction = refract(direction, refrNormal, kBunnyRefractionIndex);
}

Так как преломленный луч заходит “внутрь” объекта, нам нужно отслеживать это и “переворачивать” нормаль, чтобы получать правильный результат преломления.

Давайте полюбуемся нашим стеклянным кроликом.

Изображение

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

В следующей статье я постараюсь рассмотреть гибридный рендеринг, совмещающий растеризацию и рейтрейсинг.

Исходный код к статье находится здесь:
https://github.com/iOrange/rtxON/tree/Version_2_2

18 ноября 2018

#графика, #raytracing, #rtx, #Vulkan, #рейтрейсинг


Обновление: 27 ноября 2018

2001—2018 © GameDev.ru — Разработка игр