Jun 22 at 8:34

Hello Ray, a Hello World Vulkan Ray Tracing Tutorial

*WORK IN PROGRESS*

Want to do some quick 3D graphics programming? Want the siplicity of OpenGL but with newer Vulkan features — like ray tracing? That’s a tough ask, but lets see what can be done.

I’ve made a small library, vulkan_objects, that attempts to provide vulkan more directly and big usability shortcuts for mimimal effort. It’s unique in using the vulkan vk.xml specification directly, which shortcuts a lot of code to support multiple versions and mix precompiled binaries. Lets compare and then try an example to see how quickly we can bootstrap a project to call vkCmdDraw or vkCmdTraceRaysKHR (without hiding the code in non-generic “I wrote this part for you” functions/classes).

Vulkan famously needs 1000 lines of code to draw a single triangle, and that’s probably still taking some shortcuts. Imagine ray tracing, when acceleration structures and shader binding tables are needed. Moreover the libraries and tech stack needed to just get started are complicated. For example, vulkan needs a “loader” to populate API function pointers, there may be multiple vulkan implementations to choose from (physical GPUs or software) and it needs a separate shader compiler. Vulkan by itself doesn’t do any validation/error checking. You have to set up an intermediate “layer” for that. Your dependencies are already starting to look like this: Vulkan SDK or volk, glslang/shaderc/slang and a custom validation layer install; maybe vk-bootstrap for device selection. Not to mention dealing with the verbosity of the C API. See VulkanTutorial, SaschaWillems/Vulkan, nvpro-samples, vk_raytracing_tutorial_KHR. These aren’t bad but maybe there’s opportunity to lower the barrier for entry even further.

A Quicker Start

With vulkan_objects, assuming linux with gcc and cmake, although it’s really similar for Windows, and tested with an NVIDA GPU.

mkdir hello_ray
cd hello_ray
git init
git submodule add git@github.com:pknowles/vulkan_objects.git

Add a CMakeLists.txt file:

cmake_minimum_required(VERSION 3.3)
project(hello_ray_project)
set(VULKAN_OBJECTS_FETCH_VVL ON)
set(VULKAN_OBJECTS_FETCH_VMA ON)
set(VULKAN_OBJECTS_FETCH_SLANG ON)
add_subdirectory(vulkan_objects)
FetchContent_Declare(
    glfw
    GIT_REPOSITORY https://github.com/glfw/glfw.git
    GIT_TAG 3.4
    GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(glfw)
add_executable(hello_ray main.cpp)
target_link_libraries(hello_ray PRIVATE vulkan_objects glfw)

To build and test after populating main.cpp below:

cmake -S . -B build
cmake --build build --parallel 4
./build/hello_ray

Create main.cpp. For the moment, copying debugMessageCallback, WindowInstanceCreateInfo and RayTracingDeviceCreateInfo from vulkan_objects/test/src/test.cpp, removing debugbreak.h, the debug_break() call, gtest/gtest.h. This tutorial is going to be walking through the code of its TEST(Integration, HelloTriangleRayTracing) case as a main function.

Loading Vulkan

// Find and load libvulkan.so.1 or vulkan-1.dll
// Internally, this calls dlopen() or LoadLibraryW() and loads vkGetInstanceProcAddr()
vko::VulkanLibrary library;

// Load vkCreateInstance and other top level Vulkan API functions
vko::GlobalCommands globalCommands(library.loader());

VkInstance, VkDevice, VkQueue

// Optional: use GLFW for system compositor window creation
// The VkInstance depends on glfwGetPlatform() to choose a compositor at
// runtime (e.g. Wayland or X11) and enable the appropriate extensions.
vko::glfw::PlatformSupport platformSupport(
    vko::toVector(globalCommands.vkEnumerateInstanceExtensionProperties, nullptr));
vko::glfw::ScopedInit scopedGlfwInit;

// Create the VkInstance
// This calls globalCommands.vkCreateInstance() with the VkInstanceCreateInfo of WindowInstanceCreateInfo
// It also loads instance Vulkan API functions such as vkCreateDevice using globalCommands.vkGetInstanceProcAddr
vko::Instance instance(globalCommands, WindowInstanceCreateInfo(platformSupport));

// Get a list of Vulkan capable implementations and devices. These could be
// software implementations or real GPUs from multiple vendors. For
// simplicity we'll pick the first in the list, but a real application could
// choose with std::ranges::find_if(...)
std::vector<VkPhysicalDevice> physicalDevices =
    vko::toVector(instance.vkEnumeratePhysicalDevices, instance);
VkPhysicalDevice physicalDevice = physicalDevices[0];

// Pick a single VkQueue family
// Normally, an app would choose from the a list, but again we'll pick the first
// See 
std::vector<VkQueueFamilyProperties> queueProperties =
    vko::toVector(instance.vkGetPhysicalDeviceQueueFamilyProperties, physicalDevice);
uint32_t queueFamilyIndex = 0;

// Create a VkDevice
// This calls instance.vkCreateDevice with the VkDeviceCreateInfo of RayTracingDeviceCreateInfo
// It also loads device Vulkan API functions such as vkGetDeviceQueue using instance.vkGetDeviceProcAddr
vko::Device device(instance, physicalDevice, RayTracingDeviceCreateInfo(queueFamilyIndex));

// Get a non-owning handle to the first of the device's queues in the chosen family
VkQueue queue = vko::get(device.vkGetDeviceQueue, device, queueFamilyIndex, 0);

That’s the core of vulkan context initialization done. It’s less verbose than many vulkan tutorials out there and also fairly close to the raw API, just with a little layering. We did just use two big boilerplate classes here:

  • WindowInstanceCreateInfo exists just to hold a VkInstanceCreateInfo object, which defines VkInstance requirements.
  • RayTracingDeviceCreateInfo similarly holds a VkDeviceCreateInfo object, which defines VkDevice requirements.

They are very application-specific and intentionally not part of vulkan_objects. Wrapping the CreateInfo objects doesn’t buy much IMO. They will evolve over time as you work with vulkan and pick up new features and extensions. Copy/paste them for now or write your own and expect to update them. They may even like their own file, hidden away in a vko::Instance makeInstance() or similar call.

One interesting thing is the way vko::Instance and vko::Device are designed. They are both function pointer tables and implicitly cast to VkInstance and VkDevice respectively. This makes code neater as they’re packaged together to be passed as a single argument. It also makes the objects reusable and pluggable.

// Implicit cast
VkInstance vkinst = instance;

// Also a function pointer table
PFN_vkCreateDevice vkCreateDevice = instance.vkCreateDevice;

// Example Vulkan API call
// This isn't a member function, but the actual loaded vkQueueWaitIdle pointer
device.vkQueueWaitIdle(queue);

Window, Surface and Swapchain

// The window is the interaction with the system compositor
// The surface is vulkan's interaction with that window
vko::glfw::Window window  = vko::glfw::makeWindow(800, 600, "Vulkan Window");
vko::SurfaceKHR   surface = vko::glfw::makeSurface(instance, platformSupport, window.get());

// Query sizes
VkSurfaceCapabilitiesKHR surfaceCapabilities =
    vko::get(instance.vkGetPhysicalDeviceSurfaceCapabilitiesKHR, physicalDevice, surface);
surfaceCapabilities.current/min/maxImageExtent

// The swapchain are images in that we render to and display on the surface
auto              surfaceFormats =
    vko::toVector(instance.vkGetPhysicalDeviceSurfaceFormatsKHR, physicalDevice, surface);
VkSurfaceFormatKHR surfaceFormat = ...;
auto               surfacePresentModes =
    vko::toVector(instance.vkGetPhysicalDeviceSurfacePresentModesKHR, physicalDevice, surface);
VkPresentModeKHR surfacePresentMode = ...;
std::optional<vko::simple::Swapchain> swapchain = vko::simple::Swapchain{
    device,           surface,
    surfaceFormat,    VkExtent2D{uint32_t(width), uint32_t(height)},
    queueFamilyIndex, surfacePresentMode,
    VK_NULL_HANDLE};

vko::simple::Swapchain is a container with a vko::SwapchainKHR, its image and view handles and utilities for synchronization. It aims to be general, but it’s small and easily replaceable.

struct Swapchain {
    SwapchainKHR           swapchain;
    Semaphore              acquiredImageSemaphore;
    std::vector<VkImage>   images; // non-owning
    std::vector<ImageView> imageViews;
    std::vector<Semaphore> renderFinishedSemaphores;
    std::vector<bool>      presented; // first-use flag, implying VK_IMAGE_LAYOUT_UNDEFINED
};

Now, we actually need to handle some expected vulkan errors. The call to vkAcquireNextImageKHR and vkQueuePresentKHR may return VK_ERROR_OUT_OF_DATE_KHR which is the definitive resize event from the surface. Don’t rely just on glfw’s callback. The specific error can be caught with:

try {
    swapchain->acquire(...);
    // Render...
    swapchain->present(...);
} catch (const vko::ResultException<VK_ERROR_OUT_OF_DATE_KHR>& e) {
    // Re-create the swapchain (std::optional for reset)
    swapchain.reset();
    swapchain = vko::Swapchain(...)
}

Buffer, Image, Staging

// TODO

Shaders

// TODO

Ray Tracing

// TODO
There are no comments yet.