*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 aVkInstanceCreateInfo
object, which definesVkInstance
requirements.RayTracingDeviceCreateInfo
similarly holds aVkDeviceCreateInfo
object, which definesVkDevice
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