Что же, давайте разберемся что для этого нам нужно и как начать использовать вверенную нам технологию.
На момент написания статьи специализированное аппаратное решение для рейтрейсинга на GPU есть только у компании Nvidia — имя ей RTX. Компания AMD делает ставку на свой открытый фреймворк Radeon Rays, который, по сути, является набором готовых вычислительных шейдеров (compute shaders).
В данной статье мы далее будем рассматривать решение от компании Nvidia. Поддерживается аппаратный рейтрейсинг видеокартами на базе архитектур Volta и Turing. Со стороны графических АПИ аппаратный рейтрейсинг поддерживается DirectX 12 (DirectX Raytracing, DXR) и Vulkan (посредством расширения VK_NVX_raytracing). Важно отметить, что оба этих АПИ мало чем отличаются, т.к. оба были спроектированы компанией Nvidia. Это значит, что все рассмотренное в статье будет справедливо для обоих АПИ.
Автор статьи остановил свой выбор на Vulkan, и тому 2 причины:
- Банальная: автор знаком с Vulkan и не особо знаком с DirectX 12
- Техническая: DXR (на момент написания статьи) требует определенных сборок WIndows 10, что вносит некий дискомфорт.
Часть 1. Вводная
Для работы с рейтрейсингом были добавлены следующие элементы и понятия:
- Acceleration structures — специальный объект, инкапсулирующий в себя внутреннее представление геометрии для трассировки. Можете представить себе это как некое дерево описывающих объемов (BVH) для ускорения поиска пересечения луча и геометрии.
- Таблица привязки шейдеров (Shader Binding Table, SBT) — структура данных, позволяющая вам передать АПИ несколько шейдеров для трассировки (и/или отдельных ее этапов), и затем динамически в самых шейдерах вызывать шейдеры из этой таблицы.
- Новая команда запуска трассировки (vkCmdTraceRaysNVX), очень похожая на команду запуска вычислительных шейдеров (vkCmdDispatch).
- Новый конвейер (pipeline) для трассировки, умеющий работать с таблицей шейдеров.
Давайте коротко рассмотрим по отдельности все эти нововведения.
Acceleration structures.
Итак, первым делом посмотрим, что же такое acceleration structure (AS) и как с ними работать.
Чтобы как можно быстрее находить пересечения с геометрией нам нужна некая структура данных, имеющая пространственную информацию о геометрии, и позволяющая быстро отбросить те части сцены, которые заведомо не пересекутся с лучом.
Как простейший пример можно представить себе иерархию из ограничивающих объемов (BVH) представленных параллелепипедами. Таким образом тестируя луч на пересечение с параллелепипедами, мы можем сократить тест пересечения с геометрией до небольшого набора примитивов, не перебирая всю сцену.
Оба наши АПИ предоставляют 2 уровня AS: BLAS и TLAS.
BLAS — это Bottom Level Acceleration Structure (ускоряющая структура нижнего уровня), которая и содержит, собственно говоря, геометрию.
TLAS — Top Level Acceleration Structure (ускоряющая структура верхнего уровня) - это уже структура содержащая в себе одну или несколько структур нижнего уровня, а также информацию об их трансформации.
Размещаются BLAS внутри TLAS посредством инстанцирования (instancing), благодаря чему можно дублировать BLAS, указывая инстансу ссылку на BLAS и отдельную трансформацию.
Shader Binding Table.
Что же такое Shader Binding Table (SBT) и с чем его едят?
Для начала давайте рассмотрим какие новые шейдерные стадии (shader stages) у нас добавились:
- Raygen — шейдер отвечающий за генерацию лучей и вызов трассировки (функция traceNVX в GLSL). Обязателен.
- Intersection — шейдер реализующий проверку пересечения луча и геометрии. Позволяет реализовать рейтрейсинг пользовательских примитивов (например, геометрии заданной сплайнами). Необязателен, по умолчанию будет использована реализация пересечения луча с треугольником.
- Any hit и Closest hit — шейдеры вызывающиеся при позитивном результате проверки на пересечение с примитивом. Так как примитивы не всегда будут отсортированы вдоль луча, может быть зарегистрировано несколько пересечений. Any hit шейдер будет вызван для всех из них, в то время как Closest hit шейдер будет вызван в самом конце, когда после нахождения всех возможных пересечений будет выбрано ближайшее. Any hit не обязателен, Closest hit обязателен.
- Miss — шейдер выполняющийся если пересечение не было найдено (в пределах [tmin; tmax]). Можно использовать для возврата цвета неба, например. Необязателен.
Как видим, было добавлено целых 5 дополнительных шейдерных стадий, с помощью которых можно реализовать практически все существующие методы трассировки.
Эти шейдеры разбиваются на 3 функциональные подмножества:
- Raygen — сюда входит одноименный шейдер. Этот шейдер может быть только один на каждый старт трассировки (вызов vkCmdTraceRaysNVX)
- Intersection — сюда входят шейдеры Intersection, Any hit и Closest hit.
- Miss — в этом подмножестве только один шейдер Miss.
Теперь можем разобраться для чего нам SBT. Давайте представим себе простейший рейтрейсер, который считает только прямое освещение (direct lighting).
В простейшем случае он может выглядеть так:
for each pixel on screen {
generate ray
trace ray
if wasHit(ray) {
lighting = 0
for each light {
generate secondaryRay
trace secondaryRay
If not wasHit(secondaryRay) {
lighting += calcLighting(light)
}
}
ray.color *= lighting
}
resultImage[pixel] = ray.color
}
Здесь мы видим применение Raygen шейдера для генерации лучей, Closest hit и Miss шейдеров для получения цвета в точке пересечения (или неба в случае промаха). Closest hit шейдер может содержать относительно сложный код для чтения необходимой информации и расчета цвета в точке пересечения. Miss шейдер, к примеру, может рассчитывать цвет из карты окружения.
Это шейдеры для, так называемых, основных лучей (primary rays). В случае нахождения пересечения с геометрией, мы генерируем вторичные лучи (secondary rays) для проверки видимости источников света в точке пересечения (если источник света не виден — значит мы в тени). Для лучшей производительности мы можем использовать более простые шейдеры Closest hit и Miss, которые могут просто выставлять флаг было ли пересечение или нет.
Именно для этого функция traceNVX в шейдере принимает в качестве параметра индекс группы внутри подмножества. Сами же шейдеры, собранные в группы, лежат в таблице SBT.
Запутанно? Поначалу может быть, сейчас попробую объяснить приведя SBT для нашего примера сверху.
Итак у нас 5 шейдеров: 1 Raygen, 2 Closest hit и еще 2 Miss. Как мы уже знаем, они принадлежат к разным подмножествам. Давайте соберем группы для наших шейдеров и сформируем SBT.
Первым у нас идет Raygen шейдер, он будет в первой группе #0. Далее идут 2 Closest hit шейдера, они займут группы #1 и #2 соответственно. Оставшиеся 2 Miss шейдера займут группы #3 и #4.
Чтобы было еще нагляднее, давайте мы добавим Any hit шейдер для вторичных лучей (ведь нам не обязательно находить ближайшее пересечение, важен сам факт блокировки источника света). Этот шейдер войдет в группу #2 ибо он принадлежит к подмножеству Intersection шейдеров, как и Closest hit.
Как видно из схематического изображения — для того чтобы функция traceNVX вызвала шейдеры для первичных лучей, нам надо указать индексы соответствующих групп внутри соответствующего подмножества. Шейдеры для обработки пересечений для первичных лучей находятся в группе #1, которая, в свою очередь, лежит первой в своем подмножестве, а значит ее индекс — 0. То же справедливо и для группы #3, в которой находится шейдер промаха для первичных лучей.
Для вторичных лучей нам, соответственно, нужны группы #2 и #4, индексы которых 1 и 1.
Как вы уже могли видеть из примера, каждая Intersection группа может содержать от одного до 3 шейдеров (Intersection, Closest hit и Any hit).
Данный механизм позволяет иметь набор специализированных шейдеров для разных этапов вашего рейтрейсера, и какие из них в каком случае использовать решаете вы сами, что очень удобно.
vkCmdTraceRaysNVX и Raytracing pipeline.
Мы с вами разобрались с самой сложной и запутанной частью, осталось лишь бегло рассмотреть оставшиеся 2 элемента.
vkCmdTraceRaysNVX очень похожа по своей сути на vkCmdDispatch. Разница в том, что vkCmdTraceRaysNVX запускает исполнение по двухмерной сетке и без разбиения на группы (во всяком случае, не явно). А также в качестве параметров принимает стартовые смещения для каждой группы шейдеров в буфере содержащим SBT.
Raytracing pipeline хранит в себе соотношения шейдеров и их групп. То есть, если SBT — это буфер в котором лежат шейдеры, то Raytracing pipeline — это описание какой из шейдеров в этом буфере к какой группе относится.
Часть 2. Код
Переходим непосредственно к коду. Пора применить полученные знания на практике. Сразу хочу оговориться — данная статья посвящена только рассмотрению нового функционала рейтрейсинга, и рассчитана на людей знакомых с Vulkan, шейдерами и программированием графики в целом. Перед продолжением рекомендую пройти боевое крещение Vulkan’ом с помощью замечательных уроков https://vulkan-tutorial.com.
Начнем со своеобразного “Hello, World!” в мире графики — разноцветного треугольника!
Давайте рассмотрим основные этапы:
- Создать сцену: сгенерировать геометрию треугольника, создать для него BLAS, создать инстанс с единичной матрицей трансформации, создать TLAS на основе нашего инстанса.
- Загрузить шейдеры и создать Raytracing pipeline, указав какой шейдер в какой группе располагается.
- Создать SBT и загрузить в него информацию о шейдерах из Raytracing pipeline.
- Создать необходимый набор дескрипторов (descriptor set) для двух наших ресурсов — наш TLAS по которому мы будем трассироваться, а также результирующее изображение (result image) в которое мы будем записывать посчитанный цвет для каждого пиксела.
- После этого все что нам остается — это заполнить командный буфер (command buffer), указав pipeline, descriptor set и вызвав vkCmdTraceRaysNVX.
Что же, звучит просто. Давайте кодить!
Начнем с создания сцены с треугольником. Для этого нам понадобятся вершины и индексы.
const float vertices[9] = {
0.25f, 0.25f, 0.0f,
0.75f, 0.25f, 0.0f,
0.50f, 0.75f, 0.0f
};
const uint32_t indices[3] = { 0, 1, 2 };
Загрузим их в соответствующие вершинный и индексный буферы. Это очень удобно, ибо при указании геометрии для построения AS вы можете использовать те же буферы, что и для отрисовки растеризацией.
Дальше нам необходимо заполнить соответствующие поля новой структуры VkGeometryNVX, указав вершинный и индексный буферы, количество и размер вершин, а также формат вершин и индексов. Все эти параметры совершенно такие же, как и для отрисовки растеризацией. Временно отложим нашу геометрию до момента построения AS.
Для создания AS нам, как это принято в Vulkan, нужно выделить под нее память. Сначала создаем новый объект AS функцией vkCreateAccelerationStructureNVX. Здесь мы можем указать какой тип AS мы хотим создать — верхнего, или нижнего уровня. Чтобы узнать сколько и какой памяти нам нужно выделить, введена новая функция vkGetAccelerationStructureMemoryRequirementsNVX.
Далее мы привязываем выделенную память к AS функцией vkBindAccelerationStructureMemoryNVX.
Наша AS готова к постройке. Для построения AS драйверу понадобится временная память для работы, и ее выделить мы должны тоже сами (как и везде в Vulkan, собственно). Чтобы узнать требования к этому буферу нам понадобится функция vkGetAccelerationStructureScratchMemoryRequirementsNVX.
Так как построение AS происходит на GPU, нам понадобится командный буфер. Теперь, имея все это на руках, мы можем строить нашу AS.
vkCmdBuildAccelerationStructureNVX(commandBuffer,
VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_NVX,
0, VK_NULL_HANDLE, 0,
1, &geometry,
0, VK_FALSE,
mScene.bottomLevelAS[0].accelerationStructure, VK_NULL_HANDLE,
scratchBuffer.GetBuffer(), 0);
vkCmdPipelineBarrier(commandBuffer,
VK_PIPELINE_STAGE_RAYTRACING_BIT_NVX,
VK_PIPELINE_STAGE_RAYTRACING_BIT_NVX,
0,
1, &memoryBarrier, 0,
0, 0, 0);
Обратите внимание, что здесь мы явно указываем нашу ранее созданную геометрию, и количество. В нашем случае это всего 1 треугольник.
Теперь надо построить AS верхнего уровня (TLAS). Как мы уже знаем, TLAS, по сути, является контейнером для одной или множества BLAS, которые размещаются в нем посредством инстансинга. Для этого нам нужно заполнить структуру VkGeometryInstance для каждого экземпляра BLAS, указав соответствующий AS и матрицу трансформации. Получившийся массив инстансов нужно загрузить в буфер.
Теперь нам нужно повторить все те же самые шаги, что и для построения BLAS. Отличия будут в параметрах вызываемых функций. Так при вызове vkCreateAccelerationStructureNVX мы укажем, что хотим создать AS верхнего уровня, а при вызове vkCmdBuildAccelerationStructureNVX вместо геометрий надо указать буфер с инстансами.
Следующим шагом будет загрузка шейдеров и создание конвейера (raytracing pipeline). Для нашего примера нам понадобится 3 шейдера: ray_gen.glsl, ray_chit.glsl и ray_miss.glsl. Для каждого из них нам понадобится своя стадия:
vulkanhelpers::Shader rayGenShader, rayChitShader, rayMissShader;
rayGenShader.LoadFromFile((sShadersFolder + "ray_gen.bin").c_str());
rayChitShader.LoadFromFile((sShadersFolder + "ray_chit.bin").c_str());
rayMissShader.LoadFromFile((sShadersFolder + "ray_miss.bin").c_str());
std::vector<VkPipelineShaderStageCreateInfo> shaderStages({
rayGenShader.GetShaderStage(VK_SHADER_STAGE_RAYGEN_BIT_NVX),
rayChitShader.GetShaderStage(VK_SHADER_STAGE_CLOSEST_HIT_BIT_NVX),
rayMissShader.GetShaderStage(VK_SHADER_STAGE_MISS_BIT_NVX)
});
Далее нам нужно будет указать номера групп для каждого из них. В нашем случае все просто: всего 3 группы с номерами 0, 1 и 2 соответственно.
Также здесь мы создадим набор описателей шейдерных ресурсов (descriptor set). Этих ресурсов у нас 2: это наша TLAS, представляющая сцену для трассировки, и изображение (image) для результатов трассировки.
Теперь заполним структуру VkRaytracingPipelineCreateInfoNVX указав наши шейдерные стадии и номера их групп, а также наш descriptor set, и вызовом функции vkCreateRaytracingPipelinesNVX создадим конвейер для трассировки.
Последним шагом будет создание таблицы привязки шейдеров (SBT). Как уже говорилось ранее — SBT это всего лишь буфер в котором лежат группы шейдеров (точнее, ссылки на них). Соответственно нам нужно создать буфер размером “количество_групп * размер группы”. Размер группы можно узнать запросив устройство (device) заполнить нам структуру VkPhysicalDeviceRaytracingPropertiesNVX.
Создав буфер, заполним его группами шейдеров которые мы указали при создании конвейера. Для этого нам понадобиться функция vkGetRaytracingShaderHandlesNVX:
void* mem = mShaderBindingTable.Map(shaderBindingTableSize);
vkGetRaytracingShaderHandlesNVX(mDevice, mRTPipeline, 0, numGroups, shaderBindingTableSize, mem);
mShaderBindingTable.Unmap();
Итак, у нас все готово для запуска трассировки! Для этого существует команда vkCmdTraceRaysNVX.
В качестве параметров она принимает буфер SBT, а также смещения до начальной группы каждого из подмножеств шейдеров.
void vkCmdTraceRaysNVX(VkCommandBuffer commandBuffer,
VkBuffer raygenShaderBindingTableBuffer,
VkDeviceSize raygenShaderBindingOffset,
VkBuffer missShaderBindingTableBuffer,
VkDeviceSize missShaderBindingOffset,
VkDeviceSize missShaderBindingStride,
VkBuffer hitShaderBindingTableBuffer,
VkDeviceSize hitShaderBindingOffset,
VkDeviceSize hitShaderBindingStride,
uint32_t width,
uint32_t height);
Как видим, мы можем использовать разные буферы для разных групп. В нашем случае SBT буфер один.
Raygen шейдер у нас располагается в нулевой группе, и потому его смещение (offset) равно 0.
Miss шейдер у нас располагается во второй группе, и потому его смещение = “2 * размер_группы“.
Hit shader у нас находится в первой группе, и его смещение = размер_группы.
Последними двумя параметрами идут ширина и высота “экрана”. Raygen шейдер будет вызван один раз для каждого “пиксела”, итого width * height раз.
Нам осталось разобраться с кодом шейдеров и можем запускать наше замечательное приложение.
ray_gen.glsl
#version 460
#extension GL_NVX_raytracing : require
layout(set = 0, binding = 0) uniform accelerationStructureNVX Scene;
layout(set = 0, binding = 1, rgba8) uniform image2D ResultImage;
layout(location = 0) rayPayloadNVX vec3 ResultColor;
void main() {
const vec2 uv = vec2(gl_LaunchIDNVX.xy) / vec2(gl_LaunchSizeNVX.xy - 1);
const vec3 origin = vec3(uv.x, 1.0f - uv.y, -1.0f);
const vec3 direction = vec3(0.0f, 0.0f, 1.0f);
const uint rayFlags = gl_RayFlagsNoneNVX;
const uint cullMask = 0xFF;
const uint sbtRecordOffset = 0;
const uint sbtRecordStride = 0;
const uint missIndex = 0;
const float tmin = 0.0f;
const float tmax = 10.0f;
const int payloadLocation = 0;
traceNVX(Scene,
rayFlags,
cullMask,
sbtRecordOffset,
sbtRecordStride,
missIndex,
origin,
tmin,
direction,
tmax,
payloadLocation);
imageStore(ResultImage, ivec2(gl_LaunchIDNVX.xy), vec4(ResultColor, 1.0f));
}
Строка “#extension GL_NVX_raytracing : require” говорит компилятору что мы хотим использовать расширение для рейтрейсинга.
Также вы могли заметить несколько новых типов и глобальных переменных.
Новый тип ресурса accelerationStructureNVX позволяет передать шейдеру наш TLAS представляющий сцену, по которой будет вестись трассировка.
rayPayloadNVX является объявлением данных луча, которые будут передаваться дальше в шейдеры пересечения и промаха. В нашем случае это простой vec3 для цвета.
const vec2 uv = vec2(gl_LaunchIDNVX.xy) / vec2(gl_LaunchSizeNVX.xy - 1);
Здесь мы вычисляем нормализованные координаты пиксела на нашем “экране”. gl_LaunchIDNVX содержит в себе индекс текущего потока исполнения, подобно gl_GlobalInvocationID в compute шейдерах.
gl_LaunchSizeNVX содержит в себе те самые width и height что мы передавали в vkCmdTraceRaysNVX.
Поделив одно на другое мы получим нормализованные координаты в “экранном пространстве”. Это нужно нам, ибо именно в них мы задали координаты вершин нашего треугольника.
Мы используем эти координаты для получения отправной точки нашего луча.
const vec3 origin = vec3(uv.x, 1.0f - uv.y, -1.0f);
Мы “переворачиваем” Y, потому как в координатной системе экрана Vulkan ось Y направлена вниз. Если этого не сделать, то наше изображение будет выглядеть “вверх ногами”.
Также мы немного “отодвигаемся назад”, указывая –1 как Z координату, чтобы треугольник был перед нами.
const vec3 direction = vec3(0.0f, 0.0f, 1.0f);
Направление луча ставим прямо вперед вдоль оси Z.
const uint rayFlags = gl_RayFlagsNoneNVX;
const uint cullMask = 0xFF;
const uint sbtRecordOffset = 0;
const uint sbtRecordStride = 0;
const uint missIndex = 0;
const float tmin = 0.0f;
const float tmax = 10.0f;
const int payloadLocation = 0;
Флаги луча и маску отсечения мы пока оставим стандартными, и вернемся к ним, когда будем делать более продвинутую трассировку.
Параметры sbtRecordOffset и sbtRecordStride нужны для задания индекса и размера группы шейдеров пересечения в нашем SBT. Как определяются эти индексы мы с вами разобрали ранее.
Параметр missIndex определяет индекс группы miss шейдера.
Параметры tmin и tmax задают отрезок на луче, внутри которого будет происходить поиск пересечения. Можете представлять это себе как znear и zfar плоскости отсечения.
И, наконец, payloadLocation определяет расположение данных луча. Это позволяет иметь несколько наборов данных луча для разных шейдеров, и указывать функции трассировки какой набор в каком случае использовать.
В нашем случае у нас только один набор ResultColor, и его location равен 0.
После вызова функции traceNVX, в данных луча (в нашем случае это ResultColor) будут лежать те значения, которые туда записали шейдеры пересечения или промаха.
imageStore(ResultImage, ivec2(gl_LaunchIDNVX.xy), vec4(ResultColor, 1.0f));
В нашем случае мы просто записываем получившийся цвет в результирующее изображение, используя gl_LaunchIDNVX в качестве координат пиксела.
ray_chit.glsl
#version 460
#extension GL_NVX_raytracing : require
layout(location = 0) rayPayloadInNVX vec3 ResultColor;
layout(location = 1) hitAttributeNVX vec2 HitAttribs;
void main() {
const vec3 barycentrics = vec3(1.0f - HitAttribs.x - HitAttribs.y, HitAttribs.x, HitAttribs.y);
ResultColor = vec3(barycentrics);
}
Здесь мы видим уже новый идентификатор rayPayloadInNVX, который указывает на входные данные луча. Location их должен совпадать с таковым указанным при вызове функции traceNVX.
hitAttributeNVX позволяет шейдеру принять данные о пересечении, которые определяет шейдер поиска пересечения (intersection shader). Стандартный встроенный шейдер пересечения с треугольником передает только барицентрические координаты точки пересечения с треугольником. Используя их, можно получить любые интересующие нас данные просто интерполируя значения вершин треугольника.
В нашем случае мы же просто записываем барицентрические координаты в выходной цвет.
ray_miss.glsl
#version 460
#extension GL_NVX_raytracing : require
layout(location = 0) rayPayloadInNVX vec3 ResultColor;
void main() {
ResultColor = vec3(0.412f, 0.796f, 1.0f);
}
Данный шейдер похож на сильно упрощенную копию шейдера пересечения, в котором мы ничего не считаем, а просто возвращаем константный цвет. Таким образом мы задаем цвет фона нашего треугольника.
Скомпилировать эти шейдеры можно утилитой glslangValidator, входящей в состав Vulkan SDK.
После успешной компиляции приложения и шейдера мы наконец можем полюбоваться нашим треугольником неземной красоты.