vulkan_tutorial
vulkan_tutorial
®
Khronos Vulkan Tutorial
Attribution
®
The Khronos Vulkan Tutorial is based on the "Vulkan Tutorial" by Alexander Overvoorde licensed
under CC BY-SA 4.0.
Differences
Compared to the original tutorial, this version of the tutorial is teaching up-to-date concepts:
• Timeline semaphores
It also contains Vulkan usage clarifications, improved synchronization and new content.
About
This tutorial will teach you the basics of using the Vulkan graphics and compute API. Vulkan is an
API by the Khronos group that provides a much better abstraction of modern graphics cards. This
new interface allows you to better describe what your application intends to do, which can lead to
better performance and less surprising driver behavior compared to existing APIs like OpenGL and
Direct3D. The ideas behind Vulkan are similar to those of Direct3D 12 and Metal, but Vulkan has the
advantage of being fully cross-platform and allows you to develop for Windows, Linux and Android
at the same time.
However, the price you pay for these benefits is that you have to work with a significantly more
verbose and nuanced API. Every detail related to the graphics API needs to be set up by your
application, including initial frame buffer creation and memory management for objects like
buffers and texture images. The graphics driver will do a lot less hand holding, which means that
you will have to do more work in your application to ensure correct behavior.
Where possible, we do take advantage of modern tools to make this easier in this tutorial to work
with and learn Vulkan; however, Vulkan isn’t meant to be easy.
The takeaway message here is that Vulkan is not for everyone. It is targeted at programmers who
1
are enthusiastic about high performance computer graphics and are willing to put some work in. If
you are more interested in game development, rather than computer graphics, then you may wish
to stick to OpenGL or Direct3D, which will not be deprecated in favor of Vulkan anytime soon.
However, understanding the sacrifice of staying in OpenGL is that API will never get the latest
features like Ray Tracing or AI. OpenGL is in maintenance, Vulkan is where research and new
features are available. Another alternative is to use an engine like Unreal Engine or Unity, which
will be able to use Vulkan while exposing a much higher level API to you.
With that out of the way, let’s cover some prerequisites for the following this tutorial:
◦ Most GPU vendors support Vulkan in their consumer drivers or, for mobile, on their devices.
For macOS and iOS, Vulkan support is available through MoltenVK. You can look up Vulkan
support in detail at the community driven Vulkan Hardware Database.
This tutorial will not assume knowledge of OpenGL or Direct3D concepts, but it does require you to
know the basics of 3D computer graphics. It will not explain the math behind perspective
projection, for example. See this online book for a great introduction of computer graphics
concepts. Some other great computer graphics resources are:
You can use C instead of C++ if you want, but you will have to use a different linear algebra library,
and you will be on your own in terms of code structuring. We will use C++ features like classes and
RAII to organize logic and resource lifetimes.
To make it easier to learn to work with Vulkan, we’ll be using the newer Vulkan-Hpp bindings that
abstract some of the dirty work and help prevent certain classes of errors. We’ll also use Vulkan
RAII and, optionally, the Vulkan C++20 module. The attachments template has modules disabled by
default for maximum compatibility, but we recommend enabling them if your toolchain supports it.
With this combination, we show how to use Vulkan in a way that translates directly into large
projects where C++ libraries have traditionally caused long build times, while also showing one
method of making Vulkan a joy to work with.
License
The contents of this repository are licensed as CC BY-SA 4.0, unless stated otherwise. By contributing
to this repository, you agree to license your contributions to the public under that same license.
2
Tutorial structure
We’ll start with an overview of how Vulkan works and the work we’ll have to do to get the first
triangle on the screen. The purpose of all the smaller steps will make more sense after you’ve
understood their basic role in the whole picture. Next, we’ll set up the development environment
with the Vulkan SDK, the GLM library for linear algebra operations and GLFW for window creation.
The tutorial will cover how to set these up on Windows with Visual Studio, and on Ubuntu Linux
with GCC.
After that, we’ll implement all the basic components of a Vulkan program that are necessary to
render your first triangle. Each chapter will follow roughly the following structure:
• Use all the relevant API calls to integrate it into your program
Although each chapter is written as a follow-up on the previous one, it is also possible to read the
chapters as standalone articles introducing a certain Vulkan feature. That means that the site is also
useful as a reference. All the Vulkan functions and types are linked to the specification, so you can
click them to learn more. You are encouraged to submit feedback to this Khronos repository.
As mentioned before, the Vulkan API has a rather verbose API with many parameters to give you
maximum control over the graphics hardware. This causes basic operations like creating a texture
to take a lot of steps that have to be repeated every time. Therefore, we’ll be creating our own
collection of helper functions throughout the tutorial.
Every chapter will also conclude with a link to the full code listing up to that point. You can refer to
it if you have any doubts about the structure of the code, or if you’re dealing with a bug and want to
compare. All the code files have been tested on graphics cards from multiple vendors to verify
correctness.
If you have any type of question or feedback on the tutorial and site itself, then please don’t hesitate
to submit an issue or pull request to the GitHub repository. You can watch the repository to be
notified of updates to the tutorial.
After you’ve gone through the ritual of drawing your very first Vulkan powered triangle onscreen,
we’ll start expanding the program to include linear transformations, textures and 3D models.
If you’ve played with graphics APIs before, then you’ll know that there can be a lot of steps until the
first geometry shows up on the screen. There are many of these initial steps in Vulkan, but you’ll
see that each of the individual steps is easy to understand and does not feel redundant. It’s also
important to keep in mind that once you have that boring looking triangle, drawing fully textured
3D models does not take that much extra work, and each step beyond that point is much more
rewarding.
If you encounter any problems while following the tutorial, then first check the FAQ to see if your
problem and its solution is already listed there. If you are still stuck after that, then feel free to ask
for help in the GitHub repository.
3
Ready to dive into the future of high performance graphics APIs? Let’s go! = Overview
This chapter will start off with an introduction of Vulkan and the problems it addresses. After that,
we’re going to look at the ingredients that are required for the first triangle. This will give you a big
picture to place each of the subsequent chapters in. We will conclude by covering the structure of
the Vulkan API and the general usage patterns.
Origin of Vulkan
Just like the previous graphics APIs, Vulkan is designed as a cross-platform abstraction over GPUs.
The problem with most of these APIs is that the era in which they were designed featured graphics
hardware mostly limited to configurable fixed functionality. Programmers had to provide the
vertex data in a standard format and were at the mercy of the GPU manufacturers in regard to
lighting and shading options.
As graphics card architectures matured, they started offering more and more programmable
functionality. All this new functionality had to be integrated with the existing APIs somehow. This
resulted in less than ideal abstractions and a lot of guesswork on the graphics driver side to map
the programmer’s intent to the modern graphics architectures. That’s why there are so many driver
updates for improving the performance in games, sometimes by significant margins. Because of the
complexity of these drivers, application developers also need to deal with inconsistencies between
vendors, like the syntax that is accepted for shaders. Aside from these new features, the past decade
also saw an influx of mobile and embedded devices with powerful graphics hardware. These
mobile GPUs have different architectures based on their energy and space requirements. One such
example is tiled rendering, which would benefit from improved performance by offering the
programmer more control over this functionality. Another limitation originating from the age of
these APIs is limited multi-threading support, which can result in a bottleneck on the CPU side.
Vulkan solves these problems by being designed from scratch for modern graphics architectures. It
reduces the driver overhead by allowing programmers to clearly specify their intent using a more
feature-full yet verbose API, and allows multiple threads to create and submit commands in
parallel. It reduces inconsistencies in shader compilation by switching to a standardized byte code
format with a single compiler. Lastly, it acknowledges the general purpose processing capabilities
of modern graphics cards by unifying the graphics and compute functionality into a single API.
Coding conventions
All the Vulkan functions, enumerations and structs are defined in the vulkan.h header, which is
included in the Vulkan SDK developed by LunarG. We’ll look into installing this SDK in the next
chapter. In this tutorial, we’ll be using the C++ Vulkan API provided by the [Link] header,
which comes with the official Vulkan SDK. This header offers a type-safe, RAII-friendly, and slightly
more ergonomic interface over the raw C Vulkan API, while still maintaining a very close, low-level
mapping to the underlying Vulkan functions and structures.
4
program. All the concepts introduced here will be elaborated on in the next chapters. This is just to
give you a big picture to relate all the individual components to.
We need two more parts to actually render to a window: a window surface (vk::SurfaceKHR) and a
swap chain (vk::SwapchainKHR). Note the KHR postfix, which means that these objects are part of a
Vulkan extension. The Vulkan API itself is completely platform-agnostic, which is why we need to
use the standardized WSI (Window System Interface) extension to interact with the window
manager. The surface is a cross-platform abstraction over windows to render to and is generally
instantiated by providing a reference to the native window handle, for example HWND on Windows.
Luckily, the GLFW library has a built-in function to deal with the platform-specific details of this.
The swap chain is a collection of render targets. Its basic purpose is to ensure that the image that
we’re currently rendering is different from the one that is currently on the screen. This is
important to make sure that only complete images are shown. Every time we want to draw a frame,
we have to ask the swap chain to provide us with an image to render to. When we’ve finished
drawing a frame, the image is returned to the swap chain for it to be presented to the screen at
some point. The number of render targets and conditions for presenting finished images to the
screen depends on the present mode. Common present modes are double buffering (vsync) and
triple buffering. We’ll look into these in the swap chain creation chapter.
5
Some platforms allow you to render directly to a display without interacting with any window
manager through the VK_KHR_display and VK_KHR_display_swapchain extensions. These allow you to
create a surface that represents the entire screen and could be used to implement your own
window manager, for example.
However, with dynamic rendering (introduced in Vulkan 1.3), you no longer need to create a
vk::Framebuffer at all. Dynamic rendering eliminates the need for predefined render passes and
framebuffers, allowing you to specify rendering attachments directly during command recording.
This makes the API much simpler, as we can define the rendering targets on the fly without
worrying about the overhead of managing framebuffers.
In our initial triangle rendering application, we’ll use dynamic rendering to specify a single image
as a color target and instruct Vulkan to clear it to a solid color right before drawing.
One of the most distinctive features of Vulkan compared to existing APIs, is that almost all
configuration of the graphics pipeline needs to be set in advance. That means that if you want to
switch to a different shader or slightly change your vertex layout, then you need to entirely
recreate the graphics pipeline. That means that you will have to create many vk::Pipeline objects in
advance for all the different combinations you need for your rendering operations. Only some
basic configuration, like viewport size and clear color, can be changed dynamically. All the states
6
also need to be described explicitly; there is no default color blend state, for example.
The good news is that because you’re doing the equivalent of ahead-of-time compilation versus just-
in-time compilation, there are more optimization opportunities for the driver. Runtime
performance is more predictable because large state changes like switching to a different graphics
pipeline are made very explicit.
However, with dynamic rendering, things change. Instead of "beginning" and "ending" a render
pass, you directly define the rendering attachments when you start rendering with
vk::BeginRendering.
This simplifies the process by allowing you to specify the necessary attachments on the fly, making
it more adaptable to scenarios where the swap chain images are dynamically selected. Therefore,
you don’t need to record a command buffer for each image in the swap chain or repeatedly record
the same command buffer every frame. The operations become more streamlined and efficient,
allowing Vulkan to be more flexible in handling rendering scenarios.
Operations that are submitted to queues are executed asynchronously. Therefore, we have to use
synchronization objects like semaphores to ensure a correct order of execution. Execution of the
draw command buffer must be set up to wait on image acquisition to finish; otherwise it may occur
that we start rendering to an image that is still being read for presentation on the screen. The
[Link](presentInfoKHR) call in turn needs to wait for rendering to be finished,
for which we’ll use a second semaphore that is signaled after rendering completes.
7
Summary
This whirlwind tour should give you a basic understanding of the work ahead of drawing the first
triangle. A real-world program contains more steps, like allocating vertex buffers, creating uniform
buffers and uploading texture images that will be covered in later chapters. However, we’ll start
simple because Vulkan has enough of a steep learning curve as it is. Note that we’ll cheat a bit by
initially embedding the vertex coordinates in the vertex shader instead of using a vertex buffer.
That’s because managing vertex buffers requires some familiarity with command buffers first.
• Create an Instance
• Allocate and record a command buffer with the draw commands for every possible swap chain
image
• Draw frames by acquiring images, submitting the right draw command buffer and returning
the images to the swap chain
It’s a lot of steps, but the purpose of each step will be made basic and clear in the upcoming
chapters. If you’re confused about the relation of a single step compared to the whole program, you
should refer back to this chapter.
API concepts
This chapter will conclude with a short overview of how the Vulkan API is structured at a lower
level.
vk::XXXCreateInfo createInfo{};
[Link] = vk::StructureType::eXXXCreateInfo;
[Link] = nullptr;
[Link] = ...;
[Link] = ...;
vk::XXX object;
try {
object = [Link](createInfo);
8
} catch (vk::SystemError& err) {
std::cerr << "Failed to create object: " << [Link]() << std::endl;
return false;
}
Many structures in Vulkan require you to explicitly specify the type of structure in the sType
member. The pNext member can point to an extension structure and will always be nullptr in this
tutorial. Functions that create or destroy an object will have a VkAllocationCallbacks parameter
that allows you to use a custom allocator for driver memory, which will also be left nullptr in this
tutorial.
Almost all functions return a vk::Result that is either vk::result::eSuccess or an error code. The
specification describes which error codes each function can return and what they mean.
Failure of such calls is reported by C++ exceptions. The exception will respond with more
information about the error including the aforementioned vk::Result, this enables us to check
multiple commands from one call and keep the command syntax clean.
Validation layers
As mentioned earlier, Vulkan is designed for high performance and low driver overhead.
Therefore, it will include very limited error checking and debugging capabilities by default. The
driver will often crash instead of returning an error code if you do something wrong, or worse, it
will appear to work on your graphics card and completely fail on others.
Vulkan allows you to enable extensive checks through a feature known as validation layers.
Validation layers are pieces of code that can be inserted between the API and the graphics driver to
do things like running extra checks on function parameters and tracking memory management
problems. An important benefit is that you can enable them during development and then
completely disable them when releasing your application for zero overhead. Anyone can write
their own validation layers, but the Vulkan SDK by LunarG provides a standard set of validation
layers that we’ll be using in this tutorial. You also need to register a callback function to receive
debug messages from the layers.
Because Vulkan is so explicit about every operation and the validation layers are so extensive, it
can actually be a lot easier to find out why your screen is black compared to OpenGL and Direct3D!
There’s only one more step before we’ll start writing code, and that’s setting up the development
environment. = Development Environment
In this chapter, we’ll set up your environment for developing Vulkan applications and install some
useful libraries. All the tools we’ll use, except for the compiler, are compatible with Windows,
Linux and macOS, but the steps for installing them differ a bit, which is why they’re described
separately here.
9
of the git version control system.
With git installed we can locally clone the repository like this:
This will clone the repository to a new folder (inside the current one) called Vulkan-Tutorial. The
Source files for the chapters are located in the attachments folder.
Windows
For Windows, we provide a script that uses vcpkg to install all the required dependencies:
1. Make sure you have vcpkg installed. If not, follow the instructions at [Link]
microsoft/vcpkg
While we are using vcpkg to enable this install script; the entire process is outlined below in detail
and can be achieved without using the install script or needing vcpkg. That’s just a convenience to
make the setup process easier.
Linux
For Linux, we provide a script that detects your package manager and installs all the required
dependencies:
If you prefer to install the dependencies manually, or if you’re using macOS, follow the platform-
specific instructions below.
Common considerations
Vulkan SDK
The most important part you’ll need for developing Vulkan applications is the SDK. It includes
headers, standard validation layers, debugging tools and a loader for the Vulkan functions. The
10
loader looks up the functions in the driver at runtime, similarly to GLEW for OpenGL—if you’re
familiar with that.
Proceed through the installation and pay attention to the installation location of the SDK. The first
thing we’ll do is verify that your graphics card and driver properly support Vulkan. Go to the
directory where you installed the SDK, open the bin directory and run the vkcube demo.
There is another program in this directory that will be useful for development. The slangc
command line program will be used to compile shaders from the human-readable Slang Shading
Language to bytecode. We’ll cover this in depth in the shader modules chapter. The bin directory
also contains the binaries of the Vulkan loader and the validation layers, while the lib directory
contains the libraries.
Lastly, there’s the include directory that contains the Vulkan headers. Feel free to explore the other
files, but we won’t need them for this tutorial.
To automatically set the environment variables that VulkanSDK will use to simplify CMake project
configuration and other tooling, we recommend using the setup-env script on Linux. You can add
this script to your terminal’s auto-start or IDE setup to ensure these environment variables are
available in all your sessions.
If you receive an error message, then ensure that your drivers are up to date, include the Vulkan
runtime and that your graphics card is supported. See the introduction chapter for links to drivers
from the major vendors.
CMake
For all the warts of working in cross-platform projects, CMake has become an industry-wide staple.
It allows developers to create a project wide build description file which takes care of setting up
and configuring all the support tools required to create any project. Other build systems that
achieve similar capabilities exist such as bazel, however, none are as widely used and accepted as
CMake is. A full description of how to use CMake is beyond the scope of this tutorial, however,
further details can be found at CMake
Vulkan SDK has support for using find_package. To use it with your project, you can add the search
path for the *-[Link] to the HINTS portion of the find_package config calls: i.e.
In the future, [Link] might migrate to the *-[Link] standard, however at the time
of writing it is recommended to grab [Link] from VulkanSamples, as the one from
Kitware is both deprecated and has bugs in the macOS build. You will find it in the code directory
[Link].
Using [Link] is a project-specific file, you can take it and make changes as necessary to
work well in your build environment, and can craft it further to your needs. The one Khronos
11
distributes in VulkanSamples is well tested and is a good starting point.
This will allow other projects that distribute via Find*.cmake to be placed in that same folder. See
the accompanying [Link] for an example of a working project.
Vulkan has support for C++ modules which became available with c++20. A large advantage of C++
modules is they give all the benefits of C++ without the overhead of long compile times. To do this,
the .cppm file must be compiled for your target device. This tutorial demonstrates how to take
advantage of C++ modules. However, to maximize compatibility across compilers and IDEs, the
attachments template has C++20 module support disabled by default, but it is recommended to
enable it if your toolchain supports it.
To enable the Vulkan C++20 module in the attachments template, configure CMake with:
cmake -DENABLE_CPP20_MODULE=ON ..
When enabled, the [Link] contains all the instructions needed for building the module
automatically. The relevant snippet looks like this:
target_compile_definitions(VulkanCppModule PUBLIC
VULKAN_HPP_DISPATCH_LOADER_DYNAMIC=1
VULKAN_HPP_NO_STRUCT_CONSTRUCTORS=1
)
target_sources(VulkanCppModule
PUBLIC
FILE_SET cxx_modules TYPE CXX_MODULES
BASE_DIRS "${Vulkan_INCLUDE_DIR}"
FILES "${Vulkan_INCLUDE_DIR}/vulkan/[Link]"
)
12
The VulkanCppModule target only needs to be defined once, then add it to the dependencies of your
consuming project, and it will be built automatically. You won’t need to also add Vulkan::Vulkan to
your project.
If you choose to keep modules disabled (the default), you can continue to use the traditional header-
based includes (e.g., #include <vulkan/vulkan_raii.hpp>). The sample code in the attachments is
written to compile either way and will import the module only when ENABLE_CPP20_MODULE=ON
(which defines USE_CPP20_MODULES).
Window Management
As mentioned before, Vulkan by itself is a platform-agnostic API and does not include tools for
creating a window to display the rendered results. To benefit from the cross-platform advantages of
Vulkan, we’ll use the GLFW library to create a window, which supports Windows, Linux and
macOS. There are other libraries available for this purpose, like SDL, but the advantage of GLFW is
that it also abstracts away some of the other platform-specific things in Vulkan besides just window
creation.
GLM
Unlike DirectX 12, Vulkan does not include a library for linear algebra operations, so we’ll have to
download one. GLM is a nice library that is designed for use with graphics APIs and is also
commonly used with OpenGL.
Texturing library
Vulkan by itself has no support for reading various texture resources such as png, jpeg, or ktx files.
However, as this is a large topic, it is beyond the scope of this tutorial to fully dive into all the
various formats. For this tutorial, we will use stb as a dependency for loading up textures. We do
recommend investigating ktx to gain full advantage of a texture format that is designed for
graphics applications in mind.
Modeling library
Model formats are numerous and expose a lot of details everywhere. In general, with Vulkan and
other graphical APIs, the most important things to know are vertex information, texture
coordinates, and potentially diffuse color details. GLTF is an advanced feature-full model format
with easy-to-support features available in a cross-platform library. However, for this tutorial, we’re
going to use tinyobjloader for its pure simplicity. We recommend tinyobjlader library only for small
not complex projects.
13
Windows
Development in Windows is easiest with Visual Studio. CLion works well with Windows as does
Android Studio, however, Visual Studio is very popular and well-supported, so we’ll discuss getting
dependencies there. For complete C++20 support, you need to use any version greater than 2019.
The steps outlined below were written for VS 2022.
Package management
For all platforms, we recommend using a platform management tool. Windows natively doesn’t
depend upon package management, so this is a foreign concept. However, Microsoft has introduced
a fantastic package management tool which does work cross-platform. VCPkg also includes setting
up all required CMake settings. We recommend following the excellent documentation here for
details on how to use CMake in Windows projects.
This setup allows Windows developers to natively work in Visual Studio using CMake, and the
integration is rather quite good. Alternatively, CLion natively supports [Link] projects on
all platforms and works/functions exactly like Android Studio. It is also a free IDE.
GLFW
We recommend using vcpkg as mentioned before to install packages, to do that, run this from the
command line: vcpkg install glfw3
If you desire to install without vcpkg, you can find the latest release of GLFW on the official
website.
In this tutorial, we’ll be using the 64-bit binaries, but you can of course also choose to build in 32-bit
mode. In that case make sure to link with the Vulkan SDK binaries in the Lib32 directory instead of
Lib. After downloading it, extract the archive to a convenient location. We’ve chosen to create a
Libraries directory in the Visual Studio directory under documents.
GLM
As a pure graphics api, Vulkan does not include a library for linear algebra operations, so we’ll have
to download one. LM can also be installed with vcpkg like so: vcpkg install glm
Alternatively, GLM is a header-only library, so download the GLM which is designed for use with
graphics APIs and is also commonly used with OpenGL.
tinyobjloader
Tinyobjloader can be installed with vcpkg like so: vcpkg install tinyobjloader
14
Setting up Visual Studio
Setting up a CMake project
Now that you have installed all the dependencies, we can set up a basic CMake project for Vulkan
and write a little bit of code to make sure that everything works.
We will assume that you already have some basic experience with CMake, like how variables and
rules work. If not, you can get up to speed very quickly with this tutorial.
You can now use the code from any of the following chapters found in the attachment folder as a
template for your Vulkan projects. Make a copy, rename it to something like HelloTriangle and
remove all the code in [Link].
Linux
These instructions will be aimed at Ubuntu, Fedora and Arch Linux users, but you may be able to
follow along by changing the package manager-specific commands to the ones that are appropriate
for you. You should have a compiler that supports C++20 (GCC 7+ or Clang 5+). You’ll also need
cmake. Most of this can be installed via larger packages such as build-essentials.
We recommend using CLion or another IDE; however, as with most things in Linux, GUIs are
entirely optional.
Vulkan tarball
The most important parts you’ll need for developing Vulkan applications on Linux are the Vulkan
loader, validation layers, and a couple of command-line utilities to test whether your machine is
Vulkan-capable:
Download the VulkanSDK tarball from LunarG. Place the uncompressed VulkanSDK in a
convenient path, and create a symbolic link to the latest on like so:
pushd vulkansdk
tar -xf vulkansdk-linux-x86_64-[Link].[Link]
ln -s [Link] default
Then add the following to your ~/.bashrc file so Vulkan’s environment variables are enabled
everywhere:
source ~/vulkanSDK/default/[Link]
If installation was successful, you should be all set with the Vulkan portion. Remember to run
vkcube and ensure you see the following pop up in a window:
15
[cube demo nowindow] | /images/cube_demo_nowindow.png
If you receive an error message, then ensure that your drivers are up to date, include the Vulkan
runtime and that your graphics card is supported. See the introduction chapter for links to drivers
from the major vendors.
Ninja
Ninja is a rapid build system that CMake has support for in all platforms. We recommend installing
it with sudo apt install ninja
GLFW
We’ll be installing GLFW from the following command:
or
or
GLM
It is a header-only library that can be installed from the libglm-dev or glm-devel package:
or
16
or
source ~/vulkanSDK/default/[Link]
To do that, open Settings, then select "Build, Execution, Deployment" and then select CMake. At the
bottom of that window will be the environment variable, Just, add
VULKAN_SDK=<fullPathToVulkanSDK> there and Vulkan will be found during compile time. As a
convenience, for runtime at least, we recommend placing the layers system wide. To do that, from
the terminal do this:
Alternatively, you could add VK_LAYER_PATH to your system environment variables, and point it to
$VULKAN_SDK/share/vulkan/explicit_layer.d Also, you’d want to add to LD_LIBRARY_CONFIG the
$VULKAN_SDK/lib path. This is all done for you by the [Link] file when using the terminal.
We will assume that you already have some basic experience with CMake, like how variables and
rules work. If not, you can get up to speed very quickly with this tutorial.
You can now use the attachments directory in this tutorial as a template for your Vulkan projects.
Make a copy, rename it to something like HelloTriangle and remove all the code in [Link].
macOS
These instructions will assume you are using Xcode and the Homebrew package manager. Also,
keep in mind that you will need at least macOS version 10.11, and your device needs to support the
17
Metal API.
Vulkan SDK
The SDK version for macOS internally uses MoltenVK. There is no native support for Vulkan on
macOS, so what MoltenVK does is actually act as a layer that translates Vulkan API calls to Apple’s
Metal graphics framework. With this, you can take advantage of the debugging and performance
benefits of Apple’s Metal framework.
After downloading the installer for macOS, double-click the installer and follow the prompts. Keep
a note of the installation location during the "Installation Folder" step. You will need to reference it
when creating your projects in Xcode.
Note: In this tutorial, vulkansdk will refer to the path where you installed the VulkanSDK.
Within the vulkansdk/Applications folder you should have some executable files that will run a few
demos using the SDK. Run the vkcube executable and you will see the following:
GLFW
To install GLFW on MacOS we will use the Homebrew package manager to get the glfw package:
GLM
It is a header-only library that can be installed from the glm package:
Setting up Xcode
Now that all the dependencies are installed, we can set up a basic Xcode project for Vulkan. Most of
the instructions here are essentially a lot of "plumbing," so we can get all the dependencies linked
to the project. Also, keep in mind that during the following instructions whenever we mention the
folder vulkansdk we are referring to the folder where you extracted the Vulkan SDK.
We recommend using CMake everywhere, and Apple is no different. An example of how to use
CMake for Apple can be found here We also have documentation for using a cmake project in Apple
environments at the VulkanSamples project. It targets both iOS and Desktop Apple.
Once you use CMake with the XCode generator, open the resulting xcode project. If you use the code
18
directory of this tutorial, you can do this from the command line:
cd code
cmake -G XCode
The last thing you need to set up is a couple of environment variables. On Xcode toolbar go to
Product > Scheme > Edit Scheme..., and in the Arguments tab add the two following environment
variables:
• VK_ICD_FILENAMES = vulkansdk/macOS/share/vulkan/icd.d/MoltenVK_icd.json
• VK_LAYER_PATH = vulkansdk/macOS/share/vulkan/explicit_layer.d
Android
Vulkan is a first-class API on Android and widely supported. But using it differs in several key areas
from window management to build systems. So while the basic chapters focus on desktop
platforms, the tutorial also has a dedicated chapter that walks you through setting up your
development environment and getting the tutorial code up-and-running on Android. :pp: ++
Base code
Vulkan-hpp and designated initializers
We are going to use designated initializers introduced with C++ 20. By default,
NOTE Vulkan-hpp uses a different way of initializing and we need to explicitly enable this
by using the VULKAN_HPP_NO_STRUCT_CONSTRUCTORS define.
This provides a better meaning towards what each option relates to in the structures that we’re
depending upon. For this tutorial, said define is declared in the CMake build setup.
If you use a different build setup or want to write code from scratch, you need to manually define
this before including the Vulkan-hpp headers like this:
#define VULKAN_HPP_NO_STRUCT_CONSTRUCTORS
#include <vulkan/[Link]>
// or
19
#include <vulkan/vulkan_raii.hpp>
General structure
In the previous chapter, you’ve created a Vulkan project with all the proper configurations and
tested it with the sample code. In this chapter, we’re starting from scratch with the following code:
#include <iostream>
#include <stdexcept>
#include <cstdlib>
class HelloTriangleApplication {
public:
void run() {
initVulkan();
mainLoop();
cleanup();
}
private:
void initVulkan() {
void mainLoop() {
void cleanup() {
}
};
int main()
{
try
{
HelloTriangleApplication app;
[Link]();
}
catch (const std::exception& e)
{
20
std::cerr << [Link]() << std::endl;
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
We first include the Vulkan-Hpp RAII header by default, which provides the functions, structures
and enumerations. If you enable C++20 modules (-DENABLE_CPP20_MODULE=ON), the code will import
vulkan_hpp; instead via the USE_CPP20_MODULES define set by CMake. The stdexcept and iostream
headers are included for reporting and propagating errors. The cstdlib header provides the
EXIT_SUCCESS and EXIT_FAILURE macros.
The program itself is wrapped into a class where we’ll store the Vulkan objects as private class
members and add functions to initiate each of them, which will be called from the initVulkan
function. Once everything has been prepared, we enter the main loop to start rendering frames.
We’ll fill in the mainLoop function to include a loop that iterates until the window is closed in a
moment. Once the window is closed and mainLoop returns, we’ll make sure to deallocate the
resources we’ve used in the cleanup function.
If any kind of fatal error occurs during execution, then we’ll throw a std::runtime_error exception
with a descriptive message, which will propagate back to the main function and be printed to the
command prompt. To handle a variety of standard exception types, as well, we catch the more
general std::exception. One example of an error that we will deal with soon is finding out that a
certain required extension is not supported.
Roughly every chapter that follows after this one will add one new function that will be called from
initVulkan and one or more new Vulkan objects to the private class members that need to be freed
at the end in cleanup.
Resource management
Just like each chunk of memory allocated with malloc requires a call to free, every Vulkan object
that we create needs to be explicitly destroyed when we no longer need it. In c++ it is possible to
perform automatic resource management using RAII or smart pointers provided in the <memory>
header. This tutorial is an attempt to make Vulkan easier to work with, and demonstrate modern
Vulkan programming. This tutorial will not only use RAII, it will endeavor to demonstrate the latest
methods and extensions which should hopefully make Vulkan a joy to use. Just because we enjoy
working with low level graphics APIs, we shouldn’t make the bar too high to learn how to do so.
Where appropriate, we will discuss concerns for resource management for freeing resources.
However, for this tutorial, we’ll demonstrate that we can get pretty far with a basic destructor to
clean up after our work.
Vulkan objects are either created directly with functions like vkCreateXXX, or allocated through
another object with functions like vkAllocateXXX. After making sure that an object is no longer used
anywhere, you need to destroy it with the counterparts vkDestroyXXX and vkFreeXXX. The parameters
for these functions generally vary for different types of objects, but there is one parameter that they
all share: pAllocator. This is an optional parameter that allows you to specify callbacks for a custom
21
memory allocator. We will ignore this parameter in the tutorial and always pass nullptr as
argument.
Using the Vulkan_hpp RAII module, we can rely upon the library to take care of vkCreateXXX
vkAllocateXXX vkDestroyXXX and vkFreeXXX so a block of code that looks like this:
VkInstance instance;
VkApplicationInfo appInfo{};
[Link] = VK_STRUCTURE_TYPE_APPLICATION_INFO;
[Link] = "Hello Triangle";
[Link] = VK_MAKE_VERSION(1, 0, 0);
[Link] = "No Engine";
[Link] = VK_MAKE_VERSION(1, 0, 0);
[Link] = VK_API_VERSION_1_0;
VkInstanceCreateInfo createInfo{};
[Link] = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
[Link] = &appInfo;
[Link] = 0;
[Link] = nullptr;
[Link] = 0;
[Link] = nullptr;
vkDestroyInstance(instance, nullptr);
vk::InstanceCreateInfo createInfo{
.pApplicationInfo = &appInfo
};
22
Integrating GLFW
Vulkan works perfectly fine without creating a window if you want to use it for off-screen
rendering, but it’s a lot more exciting to actually show something! First, let’s add GLFW: Note: we
will continue to use the GLFW_INCLUDE_VULKAN as GLFW is designed to get a Vulkan Surface, but
it uses the C surface directly. Other than that task, we can use GLFW_INCLUDE_NONE or not make
that specification, and everything else works perfectly fine.
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
That way, GLFW will include its own definitions and automatically load the Vulkan C header with it.
Add a initWindow function and add a call to it from the run function before the other calls. We’ll use
that function to initialize GLFW and create a window.
void run() {
initWindow();
initVulkan();
mainLoop();
cleanup();
}
private:
void initWindow() {
}
The very first call in initWindow should be glfwInit(), which initializes the GLFW library. Because
GLFW was originally designed to create an OpenGL context, we need to tell it to not create an
OpenGL context with a later call:
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
Because handling resized windows takes special care that we’ll look into later, disable it for now
with another window hint call:
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
All that’s left now is creating the actual window. Add a GLFWwindow* window; private class member to
store a reference to it and initialize the window with:
The first three parameters specify the width, height and title of the window. The fourth parameter
23
allows you to optionally specify a monitor to open the window on, and the last parameter is only
relevant to OpenGL.
It’s a good idea to use constants instead of hardcoded width and height numbers because we’ll be
referring to these values a couple of times in the future. I’ve added the following lines above the
HelloTriangleApplication class definition:
You should now have a initWindow function that looks like this:
void initWindow() {
glfwInit();
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
To keep the application running until either an error occurs or the window is closed, we need to
add an event loop to the mainLoop function as follows:
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
}
}
This code should be fairly self-explanatory. It loops and checks for events like pressing the X button
until the user has closed the window. This is also the loop where we’ll later call a function to render
a single frame.
Once the window is closed, we need to clean up resources by destroying it and terminating GLFW
itself. This will be our first cleanup code:
void cleanup() {
glfwDestroyWindow(window);
glfwTerminate();
24
}
Note that in this tutorial, this is the last time we’ll have to do anything in the cleanup() function.
This code will never need to change again.
When you run the program now, you should see a window titled Vulkan show up until the
application is terminated by closing the window. Now that we have the skeleton for the Vulkan
application, let’s create the first Vulkan object!
Instance
Creating an instance
The very first thing you need to do is initialize the Vulkan library by creating an instance. The
instance is the connection between your application and the Vulkan library, and creating it involves
specifying some details about your application to the driver.
void initVulkan() {
createInstance();
}
Additionally, add a data member to hold the handle to the instance and the raii context:
private:
vk::raii::Context context;
vk::raii::Instance instance = nullptr;
Now, to create an instance, we’ll first have to fill in a struct with some information about our
application. This data is technically optional, but it may provide some useful information to the
driver to optimize our specific application, (e.g., because it uses a well-known graphics engine with
certain special behavior). This struct is called vk::ApplicationInfo:
void createInstance()
{
constexpr vk::ApplicationInfo appInfo{.pApplicationName = "Hello Triangle",
.applicationVersion = VK_MAKE_VERSION( 1, 0,
0 ),
.pEngineName = "No Engine",
.engineVersion = VK_MAKE_VERSION( 1, 0,
0 ),
.apiVersion = vk::ApiVersion14};
25
}
While vk::ApiVersion10 or Vulkan 1.0 does exist, some functionality is older and doesn’t work well
with RAII, or as we’ll talk about later, the Slang language, so we’re showing 1.4 as our baseline. If
we were in the C api, we’d need to specify the stype and the pnext and be overly verbose. You will
likely see that in other Vulkan projects that use C api. This is handled for you by using the modern
c++ module.
A lot of information in Vulkan is passed through structs instead of function parameters, and we’ll
have to fill in one more struct to provide sufficient information for creating an instance. This next
struct is not optional and tells the Vulkan driver which global extensions and validation layers we
want to use. Global here means that they apply to the entire program and not a specific device,
which will become clear in the next few chapters.
vk::InstanceCreateInfo createInfo{
.pApplicationInfo = &appInfo
};
This structure has a member named flags, which we will handle later in this chapter. The member
pApplicationInfo points to the appInfo that we just created. The next is an array of layers being
requested, and the final is an array of the desired global extensions. As mentioned in the overview
chapter, Vulkan is a platform-agnostic API, which means that you need an extension to interface
with the window system. GLFW has a handy built-in function that returns the extension(s) it needs
to do that which we can pass to the struct:
// Check if the required GLFW extensions are supported by the Vulkan implementation.
auto extensionProperties = [Link]();
for (uint32_t i = 0; i < glfwExtensionCount; ++i)
{
if (std::ranges::none_of(extensionProperties,
[glfwExtension = glfwExtensions[i]](auto const&
extensionProperty)
{ return strcmp([Link],
glfwExtension) == 0; }))
{
throw std::runtime_error("Required GLFW extension not supported: " +
std::string(glfwExtensions[i]));
}
}
vk::InstanceCreateInfo createInfo{
.pApplicationInfo = &appInfo,
.enabledExtensionCount = glfwExtensionCount,
26
.ppEnabledExtensionNames = glfwExtensions};
The other missing piece is the Layers to enable. Here is where we’ll talk about how to enable
validation layers, which is one of the most useful and important layers to enable for any project.
We’ll talk about this more in-depth in the next chapter, so leave this empty for now.
We’ve now specified everything Vulkan needs to create an instance, and we can finally create the
vk::raii::Instance:
As you’ll see, the general pattern that object creation function parameters in Vulkan follow is:
• Pointer to custom allocator callbacks, always ignored in this tutorial as it is optional if you’re
using the default.
If everything went well, then the handle to the instance was returned. We can check that
everything worked by use of c++ exceptions. If you can’t use c++ exceptions, you can turn them off
by defining VULKAN_HPP_NO_EXCEPTIONS. Then the calls will return a std::tuple with a VKResult
and the returned object. Here’s an example of checking for errors in Vulkan calls:
try
{
vk::raii::Context context;
vk::raii::Instance instance(context, vk::InstanceCreateInfo{});
vk::raii::PhysicalDevice physicalDevice =
[Link]().front();
vk::raii::Device device(physicalDevice, vk::DeviceCreateInfo{});
27
Or use the tuple:
vk::raii::Context context;
...
auto instanceRV = [Link](...);
if (!instanceRV.has_value())
{
std::cerr << "Error: Instance creation failed with " <<
vk::to_string([Link]) << std::endl;
std::exit(EXIT_FAILURE);
}
vk::raii::Instance instance = std::move([Link]);
...
auto physicalDevicesRV = [Link]();
if (![Link]())
{
std::cerr << "Error: Enumerating PhysicalDevices failed with " <<
vk::to_string(physicalDevicesRV) << std::endl;
std::exit(EXIT_FAILURE);
}
vk::raii::PhysicalDevice physicalDevice =
std::move([Link]());
...
This example is from later parts of our tutorial, it is just an example of how to check for errors in all
of your calls.
Encountered
vk::Result::eErrorIncompatibleDriver:
If using macOS with the latest MoltenVK sdk, you may get vk::Result::eErrorIncompatibleDriver,
thrown from vk::raii::createInstance or the vk::raii::Instance constructor. According to the
Getting Start Notes. Beginning with the 1.3.216 Vulkan SDK, the VK_KHR_PORTABILITY_subset
extension is mandatory.
28
.apiVersion = vk::ApiVersion14};
vk::InstanceCreateInfo createInfo{
.flags = vk::InstanceCreateFlagBits::eEnumeratePortabilityKHR,
.pApplicationInfo = &appInfo,
.ppEnabledExtensionNames = { vk::KHRPortabilityEnumerationExtensionName }
};
instance = vk::raii::Instance(m_context, createInfo);
Each vk::ExtensionProperties struct contains the name and version of an extension. We can list
them with a simple for loop (\t is a tab for indentation):
You can add this code to the createInstance function if you’d like to provide some details about the
Vulkan support.
Before continuing with the more complex steps after instance creation, it’s time to evaluate our
debugging options by checking out validation layers.
Validation layers
What are validation layers?
The Vulkan API is designed around the idea of minimal driver overhead, and one of the
29
manifestations of that goal is that there is very limited error checking in the API by default. Even
mistakes as simple as setting enumerations to incorrect values or passing null pointers to required
parameters are generally not explicitly handled beyond c++ type checking, and will simply result in
crashes or undefined behavior. Because Vulkan requires you to be very explicit about everything
you’re doing, it’s easy to make many small mistakes like using a new GPU feature and forgetting to
request it at logical device creation time.
However, that doesn’t mean that these checks can’t be added to the API. Vulkan introduces an
elegant system for this known as validation layers. Validation layers are optional components that
hook into Vulkan function calls to apply additional operations. Common operations in validation
layers are:
• Checking thread safety by tracking the threads that calls originate from
Here’s an example of what the implementation of a function in a diagnostics validation layer could
look like:
VkResult vkCreateInstance(
const VkInstanceCreateInfo* pCreateInfo,
const VkAllocationCallbacks* pAllocator,
VkInstance* instance) {
These validation layers can be freely stacked to include all the debugging functionality that you’re
interested in. You can enable validation layers for debug builds and completely disable them for
release builds, which gives you the best of both worlds!
Vulkan does not come with any validation layers built-in, but the LunarG Vulkan SDK provides a
nice set of layers that check for common errors. They’re also completely open source, so you can
check which kind of mistakes they check for and contribute. Using the validation layers is the best
way to avoid your application breaking on different drivers by accidentally relying on undefined
behavior.
Validation layers can only be used if they have been installed onto the system. For example, the
LunarG validation layers are only available on PCs with the Vulkan SDK installed.
30
Other layers exist, and we recommend making use of them. We’ll leave it as an exercise to the
reader to discover and try out the other layers. However, we recommend looking at BestPractices
for more help and suggestions about how to use Vulkan in an updated way.
There were formerly two different types of validation layers in Vulkan: instance and device
specific. The idea was that instance layers would only check calls related to global Vulkan objects
like instances, and device-specific layers would only check calls related to a specific GPU. Device-
specific layers have now been deprecated, which means that instance validation layers apply to all
Vulkan calls.
Let’s first add two configuration variables to the program to specify the layers to enable and
whether to enable them or not. We’ve chosen to base that value on whether the program is being
compiled in debug mode or not. The NDEBUG macro is part of the c++ standard and means "not
debug".
#ifdef NDEBUG
constexpr bool enableValidationLayers = false;
#else
constexpr bool enableValidationLayers = true;
#endif
We’ll check if all the requested layers are available. We need to iterate through the requested layers
and validate that all the required layers are supported by the Vulkan implementation. This check is
performed directly in the createInstance function:
void createInstance()
{
...
31
[Link]([Link](), [Link]());
}
...
}
Using extensions
As already mentioned above, extensions also need to be enabled by their name. But here, you have
to distinguish between instance- and device-extensions.
We’ll first create a getRequiredInstanceExtensions function that will return a list of the required
instance extensions.
return extensions;
}
The extensions specified by GLFW are always required, as we’re working with the GLFW
dependency for windowing.
We’ll check if all the required extensions are available. We first get a list of all supported instance
extensions by using the vk::raii::Context::enumerateInstanceLayerProperties function and check
that all required layers are listed in that list. This check is also performed directly in the
createInstance function:
32
void createInstance()
{
...
...
}
Now run the program in debug mode and ensure that the error does not occur. If it does, then have
a look at the FAQ.
Finally, modify the vk::InstanceCreateInfo struct instantiation to include the validation layer names
and the extension names:
void createInstance()
{
...
33
}
If the check was successful then the vk::raii::Instance constructor should not ever throw a
vk::Result::eErrorLayerNotPresent error, but you should run the program to make sure.
Message callback
The validation layers will print debug messages to the standard output by default, but we can also
handle them ourselves by providing an explicit callback in our program. This will also allow you to
decide which kind of messages you would like to see, because not all are necessarily (fatal) errors.
If you don’t want to do that right now, then you may skip to the last section in this chapter.
To set up a callback in the program to handle messages and the associated details, we have to set up
a debug messenger with a callback using the VK_EXT_debug_utils extension.
We’ll first extent the getRequiredInstanceExtensions function based on whether validation layers
are enabled or not:
return extensions;
}
The debug messenger extension is conditionally added. Note that we’ve used the
vk::EXTDebugUtilsExtensionName macro here which is equal to the literal string
"VK_EXT_debug_utils". Using this macro lets you avoid typos.
Now let’s see what a debug callback function looks like. Add a new static member function called
debugCallback with the PFN_vkDebugUtilsMessengerCallbackEXT prototype. The VKAPI_ATTR and
VKAPI_CALL ensure that the function has the right signature for Vulkan to call it.
vk::DebugUtilsMessageTypeFlagsEXT type,
const
vk::DebugUtilsMessengerCallbackDataEXT * pCallbackData,
void *
34
pUserData)
{
std::cerr << "validation layer: type " << to_string(type) << " msg: " <<
pCallbackData->pMessage << std::endl;
return vk::False;
}
The first parameter specifies the severity of the message, which is one of the following flags:
The values of this enumeration are set up in such a way that you can use a comparison operation to
check if a message is equal or worse compared to some level of severity, for example:
Finally, the pUserData parameter contains a pointer specified during the setup of the callback and
allows you to pass your own data to it.
The callback returns a boolean that indicates if the Vulkan call that triggered the validation layer
message should be aborted. If the callback returns true, then the call is aborted with the
vk::Result::eErrorValidationFailedEXT error. This is normally only used to test the validation layers
35
themselves, so you should always return vk::False.
All that remains now is telling Vulkan about the callback function. Such a callback is part of a
debug messenger, and you can have as many of them as you want. Add a class member for this
handle right under instance:
Now add a function setupDebugMessenger to be called from initVulkan right after createInstance:
void initVulkan()
{
createInstance();
setupDebugMessenger();
}
void setupDebugMessenger()
{
if (!enableValidationLayers) return;
We’ll need to fill in a structure with details about the messenger and its callback:
void setupDebugMessenger()
{
...
vk::DebugUtilsMessageSeverityFlagsEXT
severityFlags(vk::DebugUtilsMessageSeverityFlagBitsEXT::eVerbose |
vk::DebugUtilsMessageSeverityFlagBitsEXT::eWarning |
vk::DebugUtilsMessageSeverityFlagBitsEXT::eError);
vk::DebugUtilsMessageTypeFlagsEXT messageTypeFlags(
vk::DebugUtilsMessageTypeFlagBitsEXT::eGeneral |
vk::DebugUtilsMessageTypeFlagBitsEXT::ePerformance |
vk::DebugUtilsMessageTypeFlagBitsEXT::eValidation);
vk::DebugUtilsMessengerCreateInfoEXT
debugUtilsMessengerCreateInfoEXT{.messageSeverity = severityFlags,
.messageType
= messageTypeFlags,
.pfnUserCallback = &debugCallback};
debugMessenger = [Link](
debugUtilsMessengerCreateInfoEXT );
}
36
The messageSeverity field allows you to specify all the types of severities you would like your
callback to be called for. We’ve specified all types except for
vk::DebugUtilsMessageSeverityFlagBitsEXT::eInfo here to receive notifications about possible
problems while leaving out verbose general debug info.
Similarly, the messageType field lets you filter which types of messages your callback is notified
about. We’ve simply enabled all types here. You can always disable some if they’re not useful to
you.
Finally, the pfnUserCallback field specifies the pointer to the callback function. You can optionally
pass a pointer to the pUserData field which will be passed along to the callback function via the
pUserData parameter. You could use this to pass a pointer to the HelloTriangleApplication class, for
example.
Note that there are many more ways to configure validation layer messages and debug callbacks,
but this is a good setup to get started with for this tutorial. See the extension specification for more
info about the possibilities.
void createInstance()
{
constexpr vk::ApplicationInfo appInfo{ .pApplicationName = "Hello Triangle",
.applicationVersion = VK_MAKE_VERSION( 1, 0, 0 ),
.pEngineName = "No Engine",
.engineVersion = VK_MAKE_VERSION( 1, 0, 0 ),
.apiVersion = vk::ApiVersion14 };
37
// Check if the required extensions are supported by the Vulkan implementation.
auto extensionProperties = [Link]();
auto unsupportedPropertyIt =
std::ranges::find_if(requiredExtensions,
[&extensionProperties](auto const &requiredExtension)
{
return std::ranges::none_of(extensionProperties,
vk::InstanceCreateInfo createInfo{
.pApplicationInfo = &appInfo,
.enabledLayerCount = static_cast<uint32_t>([Link]()),
.ppEnabledLayerNames = [Link](),
.enabledExtensionCount = static_cast<uint32_t>([Link]()),
.ppEnabledExtensionNames = [Link]() };
instance = vk::raii::Instance(context, createInfo);
}
Configuration
There are a lot more settings for the behavior of validation layers than just the flags specified in the
vk::DebugUtilsMessengerCreateInfoEXT struct. Browse to the Vulkan SDK and go to the Config
directory. There you will find a vk_layer_settings.txt file that explains how to configure the layers.
To configure the layer settings for your own application, copy the file to the Debug and Release
directories of your project and follow the instructions to set the desired behavior. However, for the
remainder of this tutorial, We will assume that you’re using the default settings.
Throughout this tutorial, we will be making a couple of intentional mistakes to show you how
helpful the validation layers are with catching them and to teach you how important it is to know
exactly what you’re doing with Vulkan. Now it’s time to look at Vulkan devices in the system.
38
Selecting a physical device
After initializing the Vulkan library through a vk::raii::Instance we need to look for and select a
graphics card in the system that supports the features we need. In fact, we can select any number of
graphics cards and use them simultaneously, but in this tutorial we’ll stick to the first graphics card
that suits our needs.
We’ll add a function pickPhysicalDevice and add a call to it in the initVulkan function.
void initVulkan()
{
createInstance();
setupDebugMessenger();
pickPhysicalDevice();
}
void pickPhysicalDevice()
{
}
The graphics card that we’ll end up selecting will be stored in a vk::raii::PhysicalDevice added as a
new class member.
Listing the graphics cards is very similar to listing extensions and starts with querying just the
number.
If there are no devices with Vulkan support, then there is no point going further.
if ([Link]())
{
throw std::runtime_error("failed to find GPUs with Vulkan support!");
}
Now we need to evaluate each of them and check if they are suitable for the operations we want to
perform, because not all graphics cards are created equal. We’ll check if any of the physical devices
meet the requirements that we’ll add to that function.
39
}
The support for optional features like texture compression, 64-bit floats and multi viewport
rendering (useful for VR) can be queried using vk::raii::PhysicalDevice::getFeatures:
There are more details that can be queried from devices that we’ll discuss later concerning device
memory and queue families (see the next section).
As an example, let’s say we consider our application only usable for dedicated graphics cards that
support geometry shaders. Then the isDeviceSuitable function would look like this:
return false;
}
Instead of just checking if a device is suitable or not and going with the first one, you could also give
each device a score and pick the highest one. That way you could favor a dedicated graphics card by
giving it a higher score, but fall back to an integrated GPU if that’s the only available one. You could
implement something like that as follows:
#include <map>
...
void pickPhysicalDevice()
{
40
auto physicalDevices = vk::raii::PhysicalDevices( instance );
if ([Link]())
{
throw std::runtime_error( "failed to find GPUs with Vulkan support!" );
}
You don’t need to implement all that for this tutorial, but it’s to give you an idea of how you could
design your device selection process. Of course, you can also display the names of the choices and
allow the user to select.
For this tutorial, we will use four criteria that must all be met by a physical device in order to be
selected: - support of Vulkan 1.3, - a queue family that supports graphics operations, - support of all
required extensions (here we only need vk::KHRSwapchainExtensionName), and - support of all
required features.
41
API version check
To check for Vulkan 1.3 support, you can check the apiVersion of the physical device properties:
We need to check which queue families are supported by the device and which one of these
supports the commands that we want to use. Right now we are only going to look for a queue that
supports graphics commands, so the code could look like this:
42
Required feature check
Finally, we need to check that all optionally supported required features are actually supported:
// Check if the physicalDevice supports the required features (dynamic rendering and
extended dynamic state)
auto features =
physicalDevice
43
.template getFeatures2<vk::PhysicalDeviceFeatures2,
vk::PhysicalDeviceVulkan13Features,
vk::PhysicalDeviceExtendedDynamicStateFeaturesEXT>();
bool supportsRequiredFeatures = [Link]
get<vk::PhysicalDeviceVulkan13Features>().dynamicRendering &&
[Link]
get<vk::PhysicalDeviceExtendedDynamicStateFeaturesEXT>().extendedDynamicState;
void pickPhysicalDevice()
{
std::vector<vk::raii::PhysicalDevice> physicalDevices =
[Link]();
auto const devIter = std::ranges::find_if( physicalDevices, [&]( auto const &
physicalDevice ) { return isDeviceSuitable( physicalDevice ); } );
if ( devIter == [Link]() )
{
throw std::runtime_error( "failed to find a suitable GPU!" );
}
physicalDevice = *devIter;
}
Great, that’s all we need for now to find the right physical device! The next step is to create a logical
device to interface with it.
Start by adding a new class member to store the logical device handle in.
44
void initVulkan() {
createInstance();
setupDebugMessenger();
pickPhysicalDevice();
createLogicalDevice();
}
void createLogicalDevice() {
std::vector<vk::QueueFamilyProperties> queueFamilyProperties =
[Link]();
auto graphicsQueueFamilyProperty = std::ranges::find_if(queueFamilyProperties, [](auto
const &qfp) { return ([Link] & vk::QueueFlagBits::eGraphics) !=
static_cast<vk::QueueFlags>(0); });
auto graphicsIndex =
static_cast<uint32_t>(std::distance([Link](),
graphicsQueueFamilyProperty));
vk::DeviceQueueCreateInfo deviceQueueCreateInfo { .queueFamilyIndex = graphicsIndex };
The currently available drivers will only allow you to create a small number of queues for each
queue family, and you don’t really need more than one. That’s because you can create all the
command buffers on multiple threads and then submit them all at once on the main thread with a
single low-overhead call.
Vulkan lets you assign priorities to queues to influence the scheduling of command buffer
execution using floating point numbers between 0.0 and 1.0. This is required even if there is only a
single queue:
45
chapter, like geometry shaders. Right now we don’t need anything special, so we can simply define
it and leave everything to vk::False. We’ll come back to this structure once we’re about to start
doing more interesting things with Vulkan.
vk::PhysicalDeviceFeatures deviceFeatures;
In Vulkan, features are organized into different structures based on when they were introduced or
what functionality they relate to. For example: - Basic features are in vk::PhysicalDeviceFeatures -
Vulkan 1.3 features are in vk::PhysicalDeviceVulkan13Features - Extension-specific features are in
their own structures (like vk::PhysicalDeviceExtendedDynamicStateFeaturesEXT)
To enable multiple sets of features, Vulkan uses a concept called "structure chaining." Each feature
structure has a pNext field that can point to another structure, creating a chain of feature requests.
The C++ Vulkan API provides a helper template called vk::StructureChain that makes this process
easier. Let’s see how to use it:
◦ In the second structure, we enable the dynamicRendering feature from Vulkan 1.3
The vk::StructureChain template automatically connects these structures together by setting up the
pNext pointers between them. This saves us from having to manually link each structure to the next
one.
When we create the logical device later, we’ll pass a pointer to the first structure in this chain,
46
which will allow Vulkan to see all the features we want to enable.
The VK_KHR_swapchain extension is required for presenting rendered images to the window. Other
extensions provide additional functionality that we’ll use in later parts of the tutorial.
vk::DeviceCreateInfo deviceCreateInfo{
.pNext = &[Link]<vk::PhysicalDeviceFeatures2>(),
.queueCreateInfoCount = 1,
.pQueueCreateInfos = &deviceQueueCreateInfo,
.enabledExtensionCount = static_cast<uint32_t>([Link]()),
.ppEnabledExtensionNames = [Link]()
};
Reviewing how we connect our feature chain to the device creation process:
3. Since all the structures in our feature chain are already connected (thanks to
vk::StructureChain), Vulkan will be able to see all the features we want to enable by following
the chain of pNext pointers.
This approach allows us to request multiple sets of features in a clean and organized way. Vulkan
will process each structure in the chain and enable the requested features during device creation.
The remainder of the information bears a resemblance to the vk::InstanceCreateInfo struct and
requires you to specify extensions. The difference is that these are device-specific this time.
47
Previous implementations of Vulkan made a distinction between instance and device-specific
validation layers, but this is no longer the case. That means that the enabledLayerCount and
ppEnabledLayerNames fields of vk::DeviceCreateInfo are ignored by up-to-date implementations.
As mentioned earlier, we need several device-specific extensions for our application to work
properly.
The parameters are the physical device to interface with, and the usage info we just specified.
Similarly to the instance creation function, this call can throw errors based on enabling non-
existent extensions or specifying the desired usage of unsupported features.
Logical devices don’t interact directly with instances, which is why it’s not included as a parameter.
vk::raii::Queue graphicsQueue;
Device queues are implicitly cleaned up when the device is destroyed, so we don’t need to do
anything in cleanup.
We can use the vk::raii::Queue constructor to retrieve a queue handle for one queue family. The
parameters are the logical device, queue family index, and queue index. Because we’re only
creating a single queue from this family, we’ll simply use queue index 0.
With the logical device and queue handles, we can now actually start using the graphics card to do
things! In the next few chapters, we’ll set up the resources to present results to the window system.
Window surface
Since Vulkan is a platform-agnostic API, it is not designed to interface directly with the window
system on its own. To establish the connection between Vulkan and the window system to present
results to the screen, we need to use the WSI (Window System Integration) extensions. In this
chapter we’ll discuss the first one, which is VK_KHR_surface. It exposes a VkSurfaceKHR object that
represents an abstract type of surface to present rendered images to. The surface in our program
will be backed by the window that we’ve already opened with GLFW.
48
The VK_KHR_surface extension is an instance level extension, and we’ve actually already enabled it,
because it’s included in the list returned by glfwGetRequiredInstanceExtensions. The list also
includes some other WSI extensions that we’ll use in the next couple of chapters.
The window surface needs to be created right after the instance creation, because it can actually
influence the physical device selection. The reason we postponed this is that window surfaces are
part of the larger topic of render targets and presentation for which the explanation would have
cluttered the basic setup. It should also be noted that window surfaces are an entirely optional
component in Vulkan if you just need off-screen rendering. Vulkan allows you to do that without
hacks like creating an invisible window (necessary for OpenGL). Vulkan also allows you to remotely
render from a non-presenting GPU or remotely over the internet, or run compute acceleration for
AI without a render or presentation target.
While GLFW will be demonstrated here, the concept of Vulkan rendering to a surface is repeated as
a target for any other Windowing API. This concept applies to mobile and to work with direct
access to the windowing manager. That said, let’s for now, concentrate on GLFW and see how it
concretely works in this tutorial.
Although the VkSurfaceKHR object and its usage is platform-agnostic, its creation isn’t because it
depends on window system details. For example, it needs the HWND and HMODULE handles on
Windows. Therefore, there is a platform-specific addition to the extension, which on Windows is
called VK_KHR_win32_surface and is also automatically included in the list from
glfwGetRequiredInstanceExtensions.
I will demonstrate how this platform-specific extension can be used to create a surface on
Windows, but we won’t use it in this tutorial. It doesn’t make any sense to use a library like GLFW
and then proceed to use platform-specific code anyway. GLFW actually has glfwCreateWindowSurface
that handles the platform differences for us. Still, it’s good to see what it does behind the scenes
before we start relying on it.
To access native platform functions, you need to update the includes at the top:
#define VK_USE_PLATFORM_WIN32_KHR
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
#define GLFW_EXPOSE_NATIVE_WIN32
#include <GLFW/glfw3native.h>
49
handles to the window and the process.
The glfwGetWin32Window function is used to get the raw HWND from the GLFW window object. The
GetModuleHandle call returns the HINSTANCE handle of the current process.
surface = instance.createWin32SurfaceKHR(createInfo);
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
}
void createSurface() {
The GLFW call takes simple parameters instead of a struct which makes the implementation of the
function very straightforward:
void createSurface() {
VkSurfaceKHR _surface;
if (glfwCreateWindowSurface(*instance, window, nullptr, &_surface) != 0) {
throw std::runtime_error("failed to create window surface!");
}
surface = vk::raii::SurfaceKHR(instance, _surface);
}
50
However, as you see in the above, GLFW only deals with the Vulkan C API. The VkSurfaceKHR
object is a C API object. Thankfully, it can natively be promoted to the C++ wrapper, and that’s what
we do here.
The parameters are the VkInstance, GLFW window pointer, custom allocators and pointer to a
VkSurfaceKHR variable. It simply passes through the VkResult from the relevant platform call. GLFW
doesn’t offer a special function for destroying a surface, but wrapping it in our raii SurfaceKHR
object will let Vulkan RAII take care of that for us.
It’s actually possible but very unlikely that the queue families supporting graphics commands and
the queue families supporting presentation do not overlap. But for simplicity we assume, there is
such a queue family, and bail out in case there isn’t.
That is, next, we’ll look for a queue family that has the capability of both, supporting graphics
operations, and presenting to our window surface. The function to check for present support is
vk::raii::PhysicalDevice::getSurfaceSupportKHR, which takes the queue family index and the
surface as parameters:
51
families to use as we detect them. Here’s how we do it in one function at the device creation
functions:
void createLogicalDevice() {
// find the index of the first queue family that supports graphics
std::vector<vk::QueueFamilyProperties> queueFamilyProperties =
[Link]();
// get the first index into queueFamilyProperties which supports both graphics and
present
uint32_t queueIndex = ~0;
for (uint32_t qfpIndex = 0; qfpIndex < [Link](); qfpIndex++)
{
if ((queueFamilyProperties[qfpIndex].queueFlags & vk::QueueFlagBits::eGraphics)
&&
[Link](qfpIndex, *surface))
{
// found a queue family that supports both graphics and present
queueIndex = qfpIndex;
break;
}
}
if (queueIndex == ~0)
{
throw std::runtime_error("Could not find a queue for graphics and present ->
terminating");
}
// create a Device
float queuePriority = 0.5f;
vk::DeviceQueueCreateInfo deviceQueueCreateInfo{.queueFamilyIndex = queueIndex,
.queueCount = 1, .pQueuePriorities = &queuePriority};
vk::DeviceCreateInfo deviceCreateInfo{.pNext =
&[Link]<vk::PhysicalDeviceFeatures2>(),
.queueCreateInfoCount = 1,
.pQueueCreateInfos =
&deviceQueueCreateInfo,
.enabledExtensionCount =
static_cast<uint32_t>([Link]()),
.ppEnabledExtensionNames =
52
[Link]()};
In the next chapter, we’re going to look at swap chains and how they allow us to present images to
the surface.
Swap chain
Vulkan does not have the concept of a "default framebuffer," hence it requires an infrastructure
that will own the buffers we will render to before we visualize them on the screen. This
infrastructure is known as the swap chain and must be created explicitly in Vulkan. The swap
chain is essentially a queue of images that are waiting to be presented to the screen. Our
application will acquire such an image to draw to it, and then return it to the queue. How exactly
the queue works and the conditions for presenting an image from the queue depend on how the
swap chain is set up. However, the general purpose of the swap chain is to synchronize the
presentation of images with the refresh rate of the screen.
For that purpose we’ll first extend the createLogicalDevice function to check if this extension is
supported. We’ve previously seen how to list the extensions that are supported by a
vk::raii::PhysicalDevice, so doing that should be fairly straightforward. Note that the Vulkan
header file provides a nice macro vk::KHRSwapchainExtensionName that is defined as
VK_KHR_swapchain. The advantage of using this macro is that the compiler will catch misspellings.
First declare a list of required device extensions, similar to the list of validation layers to enable.
It should be noted that the availability of a presentation queue, as we checked in the previous
chapter, implies that the swap chain extension must be supported. However, the extension does
have to be explicitly enabled.
53
Enabling device extensions
Using a swapchain requires enabling the VK_KHR_swapchain extension first. Enabling the extension
just requires a small change to the logical device creation structure:
[Link] = [Link]();
[Link] = [Link]();
Alternatively, we can do this at the construction and keep this very succinct:
• Basic surface capabilities (min/max number of images in swap chain, min/max width and
height of images)
This section covers how to query the structs that include this information. The meaning of these
structs and exactly which data they contain is discussed in the next section.
Let’s start with the basic surface capabilities. These properties are straightforward to query and are
returned into a single vk::SurfaceCapabilitiesKHR struct.
54
auto surfaceCapabilities = [Link]( *surface );
This function takes the specified vk::SurfaceKHR window surface into account when determining
the supported capabilities. All the support querying functions have that as the first parameter
because it is the core component of the swap chain.
std::vector<vk::SurfaceFormatKHR> availableFormats =
[Link]( surface );
Finally, querying the supported presentation modes works exactly the same way with
vk::raii::PhysicalDevice::getSurfacePresentModesKHR:
std::vector<vk::PresentModeKHR> availablePresentModes =
[Link]( surface );
All the details are available now. Swap chain support is enough for this tutorial if there is at least
one supported image format and one supported presentation mode given the window surface we
have. It is important that we only try to query for swap chain support after verifying that the
extension is available.
For each of these settings, we’ll have an ideal value in mind that we’ll go with if it’s available, and
otherwise we’ll create some logic to find the next best thing.
Surface format
The function for this setting starts out like this. We’ll later pass the formats member of the
SwapChainSupportDetails struct as argument.
55
{
assert(![Link]());
return availableFormats[0];
}
Each vk::SurfaceFormatKHR entry contains a format and a colorSpace member. The format member
specifies the color channels and types. For example, vk::Format::eB8G8R8A8Srgb means that we store
the B, G, R and alpha channels in that order with an 8-bit unsigned integer for a total of 32 bits per
pixel. The colorSpace member indicates if the SRGB color space is supported or not using the
vk::ColorSpaceKHR::eSrgbNonlinear flag.
For the color space we’ll use SRGB if it is available, because it results in more accurate perceived
colors. It is also pretty much the standard color space for images, like the textures we’ll use later on.
Because of that we should also use an SRGB color format, of which one of the most common ones is
vk::Format::eB8G8R8A8Srgb.
Let’s go through the list and see if the preferred combination is available:
If that fails, then we could start ranking the available formats based on how "good" they are, but in
most cases it’s okay to just settle with the first format that is specified.
Presentation mode
The presentation mode is arguably the most important setting for the swap chain, because it
represents the actual conditions for showing images to the screen. There are four possible modes
available in Vulkan:
• vk::PresentModeKHR::eFifo: The swap chain is a queue where the display takes an image from
the front of the queue when the display is refreshed, and the program inserts rendered images
56
at the back of the queue. If the queue is full, then the program has to wait. This is most similar
to vertical sync as found in modern games. The moment that the display is refreshed is known
as "vertical blank".
• vk::PresentModeKHR::eFifoRelaxed: This mode only differs from the previous one if the
application is late and the queue was empty at the last vertical blank. Instead of waiting for the
next vertical blank, the image is transferred right away when it finally arrives. This may result
in visible tearing.
Only the vk::PresentModeKHR::eFifo mode is guaranteed to be available, so we’ll again have to write
a function that looks for the best mode that is available:
I think that vk::PresentModeKHR::eMailbox is a very nice trade-off if energy usage is not a concern. It
allows us to avoid tearing while still maintaining fairly low latency by rendering new images that
are as up to date as possible right until the vertical blank. On mobile devices, where energy usage is
more important, you will probably want to use vk::PresentModeKHR::eFifo instead. Now, let’s look
through the list to see if vk::PresentModeKHR::eMailbox is available:
Swap extent
That leaves only one major property, for which we’ll add one last function:
57
vk::Extent2D chooseSwapExtent(vk::SurfaceCapabilitiesKHR const &capabilities)
{}
The swap extent is the resolution of the swap chain images, and it’s almost always exactly equal to
the resolution of the window that we’re drawing to in pixels (more on that in a moment). The range
of the possible resolutions is defined in the vk::SurfaceCapabilitiesKHR structure. Vulkan tells us to
match the resolution of the window by setting the width and height in the currentExtent member.
However, some window managers do allow us to differ here, and this is indicated by setting the
width and height in currentExtent to a special value: the maximum value of uint32_t. In that case
we’ll pick the resolution that best matches the window within the minImageExtent and
maxImageExtent bounds. But we must specify the resolution in the correct unit.
GLFW uses two units when measuring sizes: pixels and screen coordinates. For example, the
resolution {WIDTH, HEIGHT} that we specified earlier when creating the window is measured in
screen coordinates. But Vulkan works with pixels, so the swap chain extent must be specified in
pixels as well. Unfortunately, if you are using a high DPI display (like Apple’s Retina display), screen
coordinates don’t correspond to pixels. Instead, due to the higher pixel density, the resolution of the
window in pixel will be larger than the resolution in screen coordinates. So if Vulkan doesn’t fix the
swap extent for us, we can’t just use the original {WIDTH, HEIGHT}. Instead, we must use
glfwGetFramebufferSize to query the resolution of the window in pixel before matching it against
the minimum and maximum image extent.
...
return {
std::clamp<uint32_t>(width, [Link],
[Link]),
std::clamp<uint32_t>(height, [Link],
[Link])
};
}
The clamp function is used here to bound the values of width and height between the allowed
minimum and maximum extents that are supported by the implementation.
58
Creating the swap chain
Now that we have all of these helper functions helping us with the choices we have to make at
runtime, we finally have all the information necessary to create a working swap chain.
Create a createSwapChain function that starts out with the results of these calls and make sure to call
it from initVulkan after logical device creation.
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
}
void createSwapChain() {
vk::SurfaceCapabilitiesKHR surfaceCapabilities =
[Link]( *surface );
swapChainExtent =
chooseSwapExtent(surfaceCapabilities);
uint32_t minImageCount =
chooseSwapMinImageCount(surfaceCapabilities);
std::vector<vk::SurfaceFormatKHR> availableFormats =
[Link](*surface);
swapChainSurfaceFormat =
chooseSwapSurfaceFormat(availableFormats);
}
Aside from these properties, we also have to decide how many images we would like to have in the
swap chain. The implementation specifies the minimum number that it requires to function:
However, simply sticking to this minimum means that we may sometimes have to wait on the
driver to complete internal operations before we can acquire another image to render to.
Therefore, it is recommended to request at least one more image than the minimum:
We should also make sure to not exceed the maximum number of images while doing this, where 0
is a special value that means that there is no maximum, resulting in this helper function
59
&surfaceCapabilities)
{
auto minImageCount = std::max(3u, [Link]);
if ((0 < [Link]) && ([Link]
< minImageCount))
{
minImageCount = [Link];
}
return minImageCount;
}
As is tradition with Vulkan objects, creating the swap chain object requires filling in a large
structure, to be fair, the swapchain is a fairly complex object so it is among the larger createInfo
structures in Vulkan:
.imageArrayLayers = 1,
The imageArrayLayers specifies the number of layers each image consists of. This is always 1 unless
you are developing a stereoscopic 3D application.
.imageUsage = vk::ImageUsageFlagBits::eColorAttachment,
The imageUsage bit field specifies what kind of operations we’ll use the images in the swap chain for.
In this tutorial, we’re going to render directly to them, which means that they’re used as color
attachment. It is also possible that you’ll render images to a separate image first to perform
60
operations like post-processing. In that case you may use a value like
vk::ImageUsageFlagBits::eTransferDst instead and use a memory operation to transfer the rendered
image to a swap chain image.
.imageSharingMode = vk::SharingMode::eExclusive,
The imageSharingMode specifies how to handle swap chain images that might be used across multiple
queue families. There are two ways to handle images that are accessed from multiple queues:
If the queue families differ, then you could use the concurrent mode to avoid having to do the
ownership chapters, because these involve some concepts that are better explained at a later time.
Concurrent mode requires you to specify in advance between which queue families ownership will
be shared using the queueFamilyIndexCount and pQueueFamilyIndices parameters. Concurrent mode
requires you to specify at least two distinct queue families. If the graphics queue family and
presentation queue family are the same, which will be the case on most hardware, then we should
stick to exclusive mode.
.preTransform = [Link],
We can specify that a certain transform should be applied to images in the swap chain if it is
supported (supportedTransforms in capabilities), like a 90-degree clockwise rotation or horizontal
flip. To specify that you do not want any transformation, simply specify the current transformation.
.compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque,
The compositeAlpha field specifies if the alpha channel should be used for blending with other
windows in the window system. You’ll almost always want to simply ignore the alpha channel,
hence vk::CompositeAlphaFlagBitsKHR::eOpaque.
.presentMode = chooseSwapPresentMode(availablePresentModes),
.clipped = true};
The presentMode member speaks for itself. If the clipped member is set to vk::True then that means
that we don’t care about the color of pixels that are obscured, for example, because another
window is in front of them. Unless you really need to be able to read these pixels back and get
predictable results, you’ll get the best performance by enabling clipping.
61
[Link] = nullptr;
That leaves one last field, oldSwapChain. With Vulkan, it’s possible that your swap chain becomes
invalid or unoptimized while your application is running, for example, because the window was
resized. In that case, the swap chain actually needs to be recreated from scratch, and a reference to
the old one must be specified in this field. This is a complex topic that we’ll learn more about in a
future chapter. For now, we’ll assume that we’ll only ever create one swap chain and can leave this
member to its default nullptr.
Now add class members to store the vk::SwapchainKHR object and its images:
vk::raii::SwapchainKHR swapChain;
std::vector<vk::Image> swapChainImages;
Creating the swap chain is now as simple as calling the constructor of vk::raii::SwapchainKHR:
The parameters are the logical device and a swap chain creation info.
Now run the application to ensure that the swap chain is created successfully! If at this point you
get an access violation error in vkCreateSwapchainKHR or see a message like Failed to find
'vkGetInstanceProcAddress' in layer [Link], then see the FAQ entry about
the Steam overlay layer.
Try removing the [Link] = extent; line with validation layers enabled.
You’ll see that one of the validation layers immediately catches the mistake and a helpful message is
printed:
One last thing, store the format and extent we’ve chosen for the swap chain images in member
variables. We’ll need them in future chapters.
62
vk::SurfaceFormatKHR swapChainSurfaceFormat;
vk::Extent2D swapChainExtent;
We now have a set of images that can be drawn onto and can be presented to the window. The next
chapter will begin to cover how we can set up the images as render targets, and then we start
looking into the actual graphics pipeline and drawing commands!
Image views
To use any VkImage, including those in the swap chain, in the render pipeline we have to create a
VkImageView object. An image view is quite literally a view into an image. It describes how to access
the image and which part of the image to access, for example, if it should be treated as a 2D texture
depth texture without any mipmapping levels.
In this chapter we’ll write a createImageViews function that creates a basic image view for every
image in the swap chain so that we can use them as color targets later on.
std::vector<vk::raii::ImageView> swapChainImageViews;
Create the createImageViews function and call it right after swap chain creation.
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
}
void createImageViews() {
The parameters for image view creation are specified in a VkImageViewCreateInfo structure. The first
few parameters are the flags, this isn’t needed in our case, we’ll add the images in the upcoming for
loop. Next, we specify that we’re rendering to a 2d screen. If we were wanting to render to a 3d
screen or cube map, those are also options as is a 1d screen. As you can probably guess, we’d want a
2d render target in most cases when we’re rendering to a screen.
Next, we specify the image format; this is how the colorspace components are configured so you get
63
the right color format in your renders. Next, components aren’t needed for our swap chain; we’ll
talk about them in a bit though. The last variable is the SubResource range, which is necessary, and
we’ll talk about shortly.
void createImageViews() {
[Link]();
The components field allows you to swizzle the color channels around. For example, you can map all
the channels to the red channel for a monochrome texture. You can also map constant values of 0
and 1 to a channel. In our case, we’ll stick to the default mapping by accepting the constructed
defaults, but here’s how to explicitly do it:
[Link].r = VK_COMPONENT_SWIZZLE_IDENTITY;
[Link].g = VK_COMPONENT_SWIZZLE_IDENTITY;
[Link].b = VK_COMPONENT_SWIZZLE_IDENTITY;
[Link].a = VK_COMPONENT_SWIZZLE_IDENTITY;
The subresourceRange field describes what the image’s purpose is and which part of the image
should be accessed. Our images will be used as color targets without any mipmapping levels or
multiple layers.
[Link] = VK_IMAGE_ASPECT_COLOR_BIT;
[Link] = 0;
[Link] = 1;
[Link] = 0;
[Link] = 1;
If you were working on a stereographic 3D application, then you would create a swap chain with
multiple layers. You could then create multiple image views for each image representing the views
for the left and right eyes by accessing different layers.
The maximum number of multiple image views you should expect graphics cards to handle is 16.
This configuration covers most standard use cases, VR/AR headsets typically require no more than
four simultaneous views. However, lightfield displays and CAVE displays might require a different
solution as their view requirements can number in the thousands for simultaneous views. Those
64
exotic requirements for rendering are beyond the scope of this tutorial, but even those use cases
can be rendered to with these same structures and techniques we describe here.
Next, set up the loop that iterates over all the swap chain images and add them to our structure.
An image view is sufficient to start using an image as a texture, but it’s not quite ready to be used as
a render target just yet. That requires one more step, known as a framebuffer. In the next chapters,
we’ll have to set up the graphics pipeline.
Introduction
Over the course of the next few chapters, we’ll be setting up a graphics pipeline configured to draw
our first triangle. The graphics pipeline is the sequence of operations that take the vertices and
textures of your meshes all the way to the pixels in the render targets. A simplified overview is
displayed below:
The input assembler collects the raw vertex data from the buffers you specify and may also use an
index buffer to repeat certain elements without having to duplicate the vertex data itself.
The vertex shader is run for every vertex and generally applies transformations to turn vertex
positions from model space to screen space. It also passes per-vertex data down the pipeline.
The tessellation shaders allow you to subdivide geometry based on certain rules to increase the
mesh quality. This is often used to make surfaces like brick walls and staircases look less flat when
they are nearby.
The geometry shader is run on every primitive (triangle, line, point) and can discard it or output
more primitives than came in. This is similar to the tessellation shader but much more flexible.
However, it is used little in today’s applications because the performance is not that good on most
graphics cards except for Intel’s integrated GPUs. Also, almost all geometry shader use cases can be
fixed with a more modern Mesh shader pipeline, which like ray tracing is a wholly new pipeline
solution, so it exists outside the standard graphics pipeline setup. We’ll discuss that pipeline in a
65
future tutorial section.
The rasterization stage breaks the primitives into fragments. These are the pixel elements that
they fill on the framebuffer. Any fragments that fall outside the screen are discarded, and the
attributes outputted by the vertex shader are interpolated across the fragments, as shown in the
figure. Usually, the fragments that are behind other primitive fragments are also discarded here
because of depth testing.
The fragment shader is invoked for every fragment that survives and determines which
framebuffer(s) the fragments are written to and with which color and depth values. It can do this
using the interpolated data from the vertex shader, which can include things like texture
coordinates and normals for lighting.
The color blending stage applies operations to mix different fragments that map to the same pixel
in the framebuffer. Fragments can simply overwrite each other, add up or be mixed based upon
transparency.
Stages with a green color are known as fixed-function stages. These stages allow you to tweak their
operations using parameters, but the way they work is predefined.
Stages with an orange color on the other hand are programmable, which means that you can upload
your own code to the graphics card to apply exactly the operations you want. This allows you to use
fragment shaders, for example, to implement anything from texturing and lighting to ray tracers.
These programs run on many GPU cores simultaneously to process many objects, like vertices and
fragments in parallel.
If you’ve used older APIs like OpenGL and Direct3D before, then you’ll be used to being able to
change any pipeline settings at will with calls like glBlendFunc and OMSetBlendState. The graphics
pipeline in Vulkan is almost completely immutable, so you must recreate the pipeline from scratch
if you want to change shaders, bind different framebuffers or change the blend function. The
disadvantage is that you’ll have to create a number of pipelines that represent all the different
combinations of states you want to use in your rendering operations. However, because all the
operations you’ll be doing in the pipeline are known in advance, the driver can optimize for it
much better.
Some of the programmable stages are optional based on what you intend to do. For example, the
tessellation and geometry stages can be disabled if you are just drawing simple geometry. If you are
only interested in depth values, then you can disable the fragment shader stage, which is useful for
shadow map generation.
In the next chapter, we’ll first create the two programmable stages required to put a triangle onto
the screen: the vertex shader and fragment shader. The fixed-function configuration like blending
mode, viewport, rasterization will be set up in the chapter after that. The final part of setting up the
graphics pipeline in Vulkan involves the specification of input and output framebuffers.
void initVulkan() {
66
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
}
...
void createGraphicsPipeline() {
The next chapter will talk about shader modules required to actually put something on the screen.
Shader modules
Unlike earlier APIs, shader code in Vulkan has to be specified in a bytecode format as opposed to
human-readable syntax like GLSL Slang, and HLSL. This bytecode format is called SPIR-V and is
designed to be used with Vulkan (a Khronos API). It is a format that can be used to write graphics
and compute shaders, but we will focus on shaders used in Vulkan’s graphics pipelines in this
tutorial.
The advantage of using a bytecode format is that the compilers written by GPU vendors to turn
shader code into native code are significantly less complex. The past has shown that with human-
readable syntax like GLSL, some GPU vendors were rather flexible with their interpretation of the
standard. If you happen to write non-trivial shaders with a GPU from one of these vendors, then
you’d risk another vendor’s drivers rejecting your code due to syntax errors, or worse, your shader
running differently because of compiler bugs. With a straightforward bytecode format like SPIR-V
that will hopefully be avoided.
However, that does not mean that we need to write this bytecode by hand. Khronos has released
their own vendor-independent compiler that compiles Slang to SPIR-V. This compiler is designed to
verify that your shader code is fully standards compliant and produces one SPIR-V binary that you
can ship with your program. You can also include this compiler as a library to produce SPIR-V at
runtime, but we won’t be doing that in this tutorial, until we get into reflection sometime in a
future chapter. Although we can use this compiler directly via slangc, we will be using slangc in our
cmake build process instead.
Slang is a shading language with a C-style syntax. Programs written in it have a main entry point
which is invoked for every object. Like HLSL, Slang uses parameters and return values for input
and output with annotations to help describe what those variables relate to. The language includes
many features to aid in graphics programming, like built-in vector and matrix primitives. Functions
67
for operations like cross-products, matrix-vector products, auto differentiation for AI, and
reflections around a vector are included. The vector type is called float with a number indicating
the number of elements. For example, a 3D position would be stored in a float3.
It is possible to access single components through members like .x called the swizzle operator, but
it’s also possible to create a new vector from multiple components at the same time. For example,
the expression float3(1.0,2.0, 3.0).xy would result in float2. The constructors of vectors can also
take combinations of vector objects and scalar values. For example, a float3 can be constructed
with float3(float2(1.0, 2.0), 3.0).
As the previous chapter mentioned, we need to write a vertex shader and a fragment shader to get
a triangle on the screen. The next two sections will cover the Slang code those, and after that I’ll
show you how to produce one SPIR-V binaries and load it into the program.
Vertex shader
The vertex shader processes each incoming vertex. It takes its attributes, like world position, color,
normal and texture coordinates as input. The output is the final position in clip coordinates and the
attributes that need to be passed on to the fragment shader, like color and texture coordinates.
These values will then be interpolated over the fragments by the rasterizer to produce a smooth
gradient.
A clip coordinate is a four-dimensional vector from the vertex shader that is subsequently turned
into a normalized device coordinate by dividing the whole vector by its last component. These
normalized device coordinates are homogeneous coordinates that map the framebuffer to a [-1, 1]
by [-1, 1] coordinate system that looks like the following:
You should already be familiar with these if you have dabbled in computer graphics before. If you
have used OpenGL before, then you’ll notice that the sign of the Y coordinates is now flipped. The Z
coordinate now uses the same range as it does in Direct3D, from 0 to 1.
For our first triangle we won’t be applying any transformations, we’ll just specify the positions of
the three vertices directly as normalized device coordinates to create the following shape:
We can directly output normalized device coordinates by outputting them as clip coordinates from
the vertex shader with the last component set to 1. That way, the division to transform clip
coordinates to normalized device coordinates will not change anything.
Normally these coordinates would be stored in a vertex buffer, but creating a vertex buffer in
Vulkan and filling it with data is not trivial. Therefore, I’ve decided to postpone that until after
we’ve had the satisfaction of seeing a triangle pop up on the screen. We’re going to do something a
little unorthodox in the meanwhile: include the coordinates directly inside the vertex shader. The
code looks like this:
68
float2(0.0, -0.5),
float2(0.5, 0.5),
float2(-0.5, 0.5)
);
struct VertexOutput {
float4 sv_position : SV_Position;
};
[shader("vertex")]
VertexOutput vertMain(uint vid : SV_VertexID) {
VertexOutput output;
output.sv_position = float4(positions[vid], 0.0, 1.0);
return output;
}
The vertMain function is invoked for every vertex. The built-in SV_VertexID annotated variable in
the parameters contains the index of the current vertex. This is usually an index into the vertex
buffer, but in our case, it will be an index into a hardcoded array of vertex data. The position of
each vertex is accessed from the constant array in the shader and combined with dummy z and w
components to produce a position in clip coordinates. The built-in annotation SV_Position functions
as the output. Within the VertexOutput struct. Something worth mentioning if you’re familiar with
other shading languages like GLSL or HLSL, there are no instructions for bindings. This is a feature
of Slang. Slang is designed to automatically infer the bindings by the order of declaration. The
struct for positions is a static to inform the compiler that we don’t need any bindings in our shader.
Studious observers will notice that we’re calling our main function vertMain instead of main, this is
because Slang and SPIR-V both support having multiple entry points in one file. This is important
when you’re dealing with pipelines which have more than just a single vert/frag shader combo.
Ray-tracing for instance, for even simple demos would have four or more shaders all small yet
needing their own file which can become cumbersome. Another major feature of Slang is the
ability to create shader libraries or modules; an exercise left to the reader is to explore more about
this feature rich shading language.
In this tutorial, we’re going to demonstrate best practices by keeping the shaders to a single file. If
you know GLSL, there’s GLSL versions of the shaders in the attachments folder which are direct
translations.
Fragment shader
The triangle formed by the positions from the vertex shader fills an area on the screen with
fragments. The fragment shader is invoked on these fragments to produce a color and depth for the
framebuffer (or framebuffers). A simple fragment shader that outputs the color red for the entire
triangle looks like this:
[shader("fragment")]
float4 fragMain() : SV_Target
{
return float4(1.0, 0.0, 0.0, 1.0);
69
}
The fragMain entry point function is called for every fragment just like the vertex shader vertMain
function is called for every vertex. Colors in Slang are 4-component vectors with the R, G, B and
alpha channels within the [0, 1] ranges. Unlike SV_Position in the vertex shader, there is no built-in
variable to output a color for the current fragment. You have to specify your own output variable
for each framebuffer where the SV_TARGET annotation specifies the index of the framebuffer. The
color red is written to this outColor variable that is linked to the first (and only) framebuffer at
index 0.
Per-vertex colors
Making the entire triangle red is not very interesting, wouldn’t something like the following look a
lot nicer?
We have to make a couple of changes to both shaders to achieve this. First off, we need to specify a
distinct color for each of the three vertices. The vertex shader should now include an array with
colors just like it does for positions:
Now we just need to pass these per-vertex colors to the fragment shader so it can output their
interpolated values to the framebuffer. Add an output for color to the vertex shader and write to it
in the vertMain function:
struct VertexOutput {
float3 color;
float4 sv_position : SV_Position;
};
[shader("vertex")]
VertexOutput vertMain(uint vid : SV_VertexID) {
VertexOutput output;
output.sv_position = float4(positions[vid], 0.0, 1.0);
[Link] = colors[vid];
return output;
}
70
[shader("fragment")]
float4 fragMain(VertexOutput inVert) : SV_Target
{
float3 color = [Link];
return float4(color, 1.0);
}
The input variable does not necessarily have to use the same name, however, if they are in the
same file, it really is convenient to not repeat ourselves. But either way, they will be linked together
using the indexes specified by the location directives. The fragMain function has been modified to
output the color along with an alpha value. As shown in the image above, the values for fragColor
will be automatically interpolated for the fragments between the three vertices, resulting in a
smooth gradient.
struct VertexOutput {
float3 color;
float4 sv_position : SV_Position;
};
[shader("vertex")]
VertexOutput vertMain(uint vid : SV_VertexID) {
VertexOutput output;
output.sv_position = float4(positions[vid], 0.0, 1.0);
[Link] = colors[vid];
return output;
}
[shader("fragment")]
float4 fragMain(VertexOutput inVert) : SV_Target
71
{
float3 color = [Link];
return float4(color, 1.0);
}
We’re now going to compile these into SPIR-V bytecode using the slangc program.
Windows
Replace the path to [Link] with the path to where you installed the Vulkan SDK. Double-click
the file to run it.
Linux
Replace the path to slangc with the path to where you installed the Vulkan SDK. Make the script
executable with chmod +x [Link] and run it.
These two commands tell the compiler to read the Slang source file and output a SPIR-V 1.4
bytecode file directly using the -o (output) flag.
Note: At the time of writing SlangC will natively support SPIR-V 1.3 and above without needing to go
through emitting GLSL to get to SPIR-V. While everything in this tutorial could work in SPIR-V 1.0, it
would require us to break the Slang shaders up into multiple files which begs the question, what’s
the point? Plus, SPIR-V 1.4 starting from 1.4 means you’ll be familiar with the latest the standard
has to offer rather than starting from an older version.
If your shader contains a syntax error, then the compiler will tell you the line number and
problem, as you would expect. Try leaving out a semicolon, for example, and run the compiler
script again. Also try running the compiler without any arguments to see what kinds of flags it
supports. It can, for example, also output the bytecode into a human-readable format, so you can
see exactly what your shader is doing and any optimizations that have been applied at this stage.
Compiling shaders on the commandline is one of the most straightforward options, yet the best
path and one we use in this tutorial is to create a CMake function:
72
function (add_slang_shader_target TARGET)
cmake_parse_arguments ("SHADER" "" "" "SOURCES" ${ARGN})
set (SHADERS_DIR ${CMAKE_CURRENT_LIST_DIR}/shaders)
set (ENTRY_POINTS -entry vertMain -entry fragMain)
add_custom_command (
OUTPUT ${SHADERS_DIR}
COMMAND ${CMAKE_COMMAND} -E make_directory ${SHADERS_DIR}
)
add_custom_command (
OUTPUT ${SHADERS_DIR}/[Link]
COMMAND ${SLANGC_EXECUTABLE} ${SHADER_SOURCES} -target spirv -profile
spirv_1_4 -emit-spirv-directly -fvk-use-entrypoint-name ${ENTRY_POINTS} -o [Link]
WORKING_DIRECTORY ${SHADERS_DIR}
DEPENDS ${SHADERS_DIR} ${SHADER_SOURCES}
COMMENT "Compiling Slang Shaders"
VERBATIM
)
add_custom_target (${TARGET} DEPENDS ${SHADERS_DIR}/[Link])
endfunction()
Then you can add the Slang build step to your target like this:
Loading a shader
Now that we have a way of producing SPIR-V shaders, it’s time to load them into our program to
plug them into the graphics pipeline at some point. We’ll first write a simple helper function to load
the binary data from the files.
#include <fstream>
...
if (!file.is_open()) {
throw std::runtime_error("failed to open file!");
}
}
The readFile function will read all the bytes from the specified file and return them in a byte array
managed by std::vector. We start by opening the file with two flags:
73
• ate: Start reading at the end of the file
The advantage of starting to read at the end of the file is that we can use the read position to
determine the size of the file and allocate a buffer:
std::vector<char> buffer([Link]());
After that, we can seek back to the beginning of the file and read all the bytes at once:
[Link](0, std::ios::beg);
[Link]([Link](), static_cast<std::streamsize>([Link]()));
[Link]();
return buffer;
We’ll now call this function from createGraphicsPipeline to load the bytecode of the two shaders:
void createGraphicsPipeline() {
auto shaderCode = readFile("shaders/[Link]");
}
Make sure that the shaders are loaded correctly by printing the size of the buffers and checking if
they match the actual file size in bytes. Note that the code doesn’t need to be null terminated since
it’s binary code, and we will later be explicit about its size.
The function will take a buffer with the bytecode as parameter and create a VkShaderModule from it.
Creating a shader module is straightforward, we only need to specify a pointer to the buffer with
the bytecode and the length of it. This information is specified in a VkShaderModuleCreateInfo
74
structure. The one catch is that the size of the bytecode is specified in bytes, but the bytecode
pointer is a uint32_t pointer rather than a char pointer. Therefore, we will need to cast the pointer
with reinterpret_cast as shown below. When you perform a cast like this, you also need to ensure
that the data satisfies the alignment requirements of uint32_t. Lucky for us, the data is stored in an
std::vector where the default allocator already ensures that the data satisfies the worst case
alignment requirements.
The parameters are the same as those in previous object creation functions: the logical device,
pointer to create info structure, optional pointer to custom allocators and handle output variable.
The buffer with the code can be freed immediately after creating the shader module. Remember to
return the created shader module:
return shaderModule;
Shader modules are just a thin wrapper around the shader bytecode that we’ve previously loaded
from a file and the functions defined in it. The compilation and linking of the SPIR-V bytecode to
machine code for execution by the GPU doesn’t happen until the graphics pipeline is created. That
means that we’re allowed to destroy the shader modules again as soon as pipeline creation is
finished, which is why we’ll make them local variables in the createGraphicsPipeline function
instead of class members:
void createGraphicsPipeline() {
vk::raii::ShaderModule shaderModule =
createShaderModule(readFile("shaders/[Link]"));
We’ll start by filling in the structure for the vertex shader, again in the createGraphicsPipeline
function.
75
The first two parameters are the flags and the stage that we’re operating in. The next two
parameters specify the shader module containing the code, and the function to invoke, known as
the entrypoint. That means that it’s possible to combine multiple fragment shaders into a single
shader module and use different entry points to differentiate between their behaviors.
There is one more (optional) member, pSpecializationInfo, which we won’t be using here, but is
worth discussing. It allows you to specify values for shader constants. You can use a single shader
module where its behavior can be configured in pipeline creation by specifying different values for
the constants used in it. This is more efficient than configuring the shader using variables at render
time, because the compiler can do optimizations like eliminating if statements that depend on
these values. If you don’t have any constants like that, then you can set the member to nullptr,
which our struct initialization does automatically.
Finish by defining an array that contains these two structs, which we’ll later use to reference them
in the actual pipeline creation step.
That’s all there is describing the programmable stages of the pipeline. In the next chapter, we’ll look
at the fixed-function stages.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Fixed functions
The older graphics APIs provided the default state for most of the stages of the graphics pipeline. In
Vulkan, you have to be explicit about most pipeline states as it’ll be baked into an immutable
pipeline state object. In this chapter, we’ll fill in all the structures to configure these fixed-function
operations.
Dynamic state
While most of the pipeline state needs to be baked into the pipeline state, a limited amount of the
state can actually be changed without recreating the pipeline at draw time. Examples are the size of
the viewport, line width and blend constants. If you want to use dynamic state and keep these
properties out, then you’ll have to fill in a VkPipelineDynamicStateCreateInfo structure like this:
std::vector dynamicStates = {
vk::DynamicState::eViewport,
76
vk::DynamicState::eScissor
};
This will cause the configuration of these values to be ignored, and you will be able (and required)
to specify the data at drawing time. This results in a more flexible setup and is widespread for
things like viewport and scissor state, which would result in a more complex setup when being
baked into the pipeline state.
Vertex input
The VkPipelineVertexInputStateCreateInfo structure describes the format of the vertex data that
will be passed to the vertex shader. It describes this in roughly two ways:
• Bindings: spacing between data and whether the data is per-vertex or per-instance (see
instancing)
• Attribute descriptions: type of the attributes passed to the vertex shader, which binding to load
them from and at which offset
Because we’re hard coding the vertex data directly in the vertex shader, we’ll fill in this structure to
specify that there is no vertex data to load for now. We’ll get back to it in the vertex buffer chapter.
vk::PipelineVertexInputStateCreateInfo vertexInputInfo;
Input assembly
The VkPipelineInputAssemblyStateCreateInfo struct describes two things: what kind of geometry will
be drawn from the vertices and if primitive restart should be enabled. The former is specified in
the topology member and can have values like:
• VK_PRIMITIVE_TOPOLOGY_LINE_STRIP: the end vertex of every line is used as start vertex for the
next line
77
Normally, the vertices are loaded from the vertex buffer by index in sequential order, but with an
element buffer you can specify the indices to use yourself. This allows you to perform optimizations
like reusing vertices. If you set the primitiveRestartEnable member to VK_TRUE, then it’s possible to
break up lines and triangles in the _STRIP topology modes by using a special index of 0xFFFF or
0xFFFFFFFF.
We intend to draw triangles throughout this tutorial, so we’ll stick to the following data for the
structure:
Remember that the size of the swap chain and its images may differ from the WIDTH and HEIGHT of
the window. The swap chain images will be used as framebuffers later on, so we should stick to
their size.
The minDepth and maxDepth values specify the range of depth values to use for the framebuffer.
These values must be within the [0.0f, 1.0f] range, but minDepth may be higher than maxDepth. If
you aren’t doing anything special, then you should stick to the standard values of 0.0f and 1.0f.
While viewports define the transformation from the image to the framebuffer, scissor rectangles
define in which region pixels will actually be stored. The rasterizer will discard any pixels outside
the scissored rectangles. They function like a filter rather than a transformation. The difference is
illustrated below. Note that the left scissored rectangle is just one of the many possibilities that
would result in that image, as long as it’s larger than the viewport.
So if we wanted to draw to the entire framebuffer, we would specify a scissor rectangle that covers
it entirely:
Viewport(s) and scissor rectangle(s) can either be specified as a static part of the pipeline or as a
dynamic state set in the command buffer. While the former is more in line with the other states, it’s
often convenient to make viewport and scissor state dynamic as it gives you a lot more flexibility.
This is widespread and all implementations can handle this dynamic state without a performance
78
penalty.
When opting for dynamic viewport(s) and scissor rectangle(s), you need to enable the respective
dynamic states for the pipeline:
std::vector dynamicStates = {
vk::DynamicState::eViewport,
vk::DynamicState::eScissor
};
vk::PipelineDynamicStateCreateInfo dynamicState({}, [Link](),
[Link]());
And then you only need to specify their count at pipeline creation time:
The actual viewport(s) and scissor rectangle(s) will then later be set up at drawing time.
With dynamic state, it’s even possible to specify different viewports and or scissor rectangles within
a single command buffer.
Without dynamic state, the viewport and scissor rectangle need to be set in the pipeline using the
VkPipelineViewportStateCreateInfo struct. This makes the viewport and scissor rectangle for this
pipeline immutable. Any changes required to these values would require a new pipeline to be
created with the new values.
Independent of how you set them, it’s possible to use multiple viewports and scissor rectangles on
some graphics cards, so the structure members reference an array of them. Using multiple requires
enabling a GPU feature (see logical device creation).
Rasterizer
The rasterizer takes the geometry shaped by the vertices from the vertex shader and turns it into
fragments to be colored by the fragment shader. It also performs depth testing, face culling and the
scissor test, and it can be configured to output fragments that fill entire polygons or just the edges
(wireframe rendering). All this is configured using the VkPipelineRasterizationStateCreateInfo
structure.
79
.depthBiasSlopeFactor = 1.0f, .lineWidth = 1.0f };
If depthClampEnable is set to VK_TRUE, then fragments that are beyond the near and far planes are
clamped to them as opposed to discarding them. This is useful in some special cases like shadow
maps. Using this requires enabling a GPU feature.
If rasterizerDiscardEnable is set to VK_TRUE, then geometry never passes through the rasterizer
stage. This basically disables any output to the framebuffer.
The polygonMode determines how fragments are generated for geometry. The following modes are
available:
Using any mode other than fill requires enabling a GPU feature.
The lineWidth member is straightforward, it describes the thickness of lines in terms of number of
fragments. The maximum line width that is supported depends on the hardware and any line
thicker than 1.0f requires you to enable the wideLines GPU feature.
The cullMode variable determines the type of face culling to use. You can disable culling, cull the
front faces, cull the back faces or both. The frontFace variable specifies the vertex order for the
faces to be considered front-facing and can be clockwise or counterclockwise.
The rasterizer can alter the depth values by adding a constant value or biasing them based on a
fragment’s slope. This is sometimes used for shadow mapping, but we won’t be using it. Just set
depthBiasEnable to VK_FALSE.
Multisampling
The VkPipelineMultisampleStateCreateInfo struct configures multisampling, which is one of the
ways to perform antialiasing. It works by combining the fragment shader results of multiple
polygons that rasterize to the same pixel. This mainly occurs along edges, which is also where the
most noticeable aliasing artifacts occur. Because it doesn’t need to run the fragment shader
multiple times if only one polygon maps to a pixel, it is significantly less expensive than simply
rendering to a higher resolution and then downscaling. Enabling it requires enabling a GPU
feature.
vk::PipelineMultisampleStateCreateInfo multisampling{.rasterizationSamples =
vk::SampleCountFlagBits::e1, .sampleShadingEnable = vk::False};
We’ll revisit multisampling in later chapter, for now let’s keep it disabled.
80
Depth and stencil testing
If you are using a depth and/or stencil buffer, then you also need to configure the depth and stencil
tests using VkPipelineDepthStencilStateCreateInfo. We don’t have one right now, so we can simply
pass a nullptr instead of a pointer to such a struct. We’ll get back to it in the depth buffering
chapter.
Color blending
After a fragment shader has returned a color, it needs to be combined with the color that is already
in the framebuffer. This transformation is known as color blending, and there are two ways to do it:
There are two types of structs to configure color blending. The first struct,
VkPipelineColorBlendAttachmentState contains the configuration per attached framebuffer and the
second struct, VkPipelineColorBlendStateCreateInfo contains the global color blending settings. In
our case, we only have one framebuffer:
vk::PipelineColorBlendAttachmentState colorBlendAttachment{
.blendEnable = vk::False,
.colorWriteMask = vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG
| vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA};
This per-framebuffer struct allows you to configure the first way of color blending. The operations
that will be performed are best demonstrated using the following pseudocode:
if (blendEnable) {
[Link] = (srcColorBlendFactor * [Link]) <colorBlendOp>
(dstColorBlendFactor * [Link]);
finalColor.a = (srcAlphaBlendFactor * newColor.a) <alphaBlendOp>
(dstAlphaBlendFactor * oldColor.a);
} else {
finalColor = newColor;
}
If blendEnable is set to VK_FALSE, then the new color from the fragment shader is passed through
unmodified. Otherwise, the two mixing operations are performed to compute a new color. The
resulting color is AND’d with the colorWriteMask to determine which channels are actually passed
through.
The most common way to use color blending is to implement alpha blending, where we want the
new color to be blended with the old color based on its opacity. The finalColor should then be
81
computed as follows:
[Link] = vk::True;
[Link] = vk::BlendFactor::eSrcAlpha;
[Link] = vk::BlendFactor::eOneMinusSrcAlpha;
[Link] = vk::BlendOp::eAdd;
[Link] = vk::BlendFactor::eOne;
[Link] = vk::BlendFactor::eZero;
[Link] = vk::BlendOp::eAdd;
You can find all the possible operations in the VkBlendFactor and VkBlendOp enumerations in the
specification.
The second structure references the array of structures for all the framebuffers and allows you to
set blend constants that you can use as blend factors in the aforementioned calculations.
If you want to use the second method of blending (a bitwise combination), then you should set
logicOpEnable to VK_TRUE. The bitwise operation can then be specified in the logicOp field. Note that
this will automatically disable the first method, as if you had set blendEnable to VK_FALSE for every
attached framebuffer! The colorWriteMask will also be used in this mode to determine which
channels in the framebuffer will actually be affected. It is also possible to disable both modes, as
we’ve done here, in which case the fragment colors will be written to the framebuffer unmodified.
Pipeline layout
You can use uniform values in shaders, which are globals similar to dynamic state variables that can
be changed at drawing time to alter the behavior of your shaders without having to recreate them.
They are commonly used to pass the transformation matrix to the vertex shader, or to create
texture samplers in the fragment shader.
These uniform values need to be specified during pipeline creation by creating a VkPipelineLayout
object. Even though we won’t be using them until a future chapter, we are still required to create
an empty pipeline layout.
Create a class member to hold this object because we’ll refer to it from other functions at a later
point in time:
82
vk::raii::PipelineLayout pipelineLayout = nullptr;
The structure also specifies push constants, which are another way of passing dynamic values to
shaders that we may get into in a future chapter.
Conclusion
That’s it for all the fixed-function state! It’s a lot of work to set all of this up from scratch, but the
advantage is that we’re now nearly fully aware of everything that is going on in the graphics
pipeline! This reduces the chance of running into unexpected behavior because the default state of
certain components is not what you expect.
There is, however, one more object to create before we can finally create the graphics pipeline, and
that is a render pass.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Dynamic Rendering
Introduction
In previous versions of Vulkan, before we could finish creating the pipeline, we needed to tell
Vulkan about the framebuffer attachments that would be used while rendering through a render
pass object. However, with the introduction of dynamic rendering in Vulkan 1.3, we can now
specify this information directly when creating the graphics pipeline and when recording
command buffers.
Dynamic rendering simplifies the rendering process by eliminating the need for render pass and
framebuffer objects. Instead, we can specify the color, depth, and stencil attachments directly when
we begin rendering.
83
vk::PipelineRenderingCreateInfo pipelineRenderingCreateInfo{ .colorAttachmentCount =
1, .pColorAttachmentFormats = &swapChainImageFormat };
This structure specifies that we’ll be using one color attachment with the format of our swap chain
images. We then include this structure in the pNext chain of the vk::GraphicsPipelineCreateInfo
structure:
Note that the renderPass parameter is set to nullptr because we’re using dynamic rendering instead
of a traditional render pass.
The advantage of dynamic rendering is that it simplifies the rendering process by eliminating the
need for render pass and framebuffer objects. It also provides more flexibility by allowing us to
change the attachments we’re rendering to without creating new render pass objects.
In the next chapter, we’ll put everything together to finally create the graphics pipeline object!
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Conclusion
We can now combine all the structures and objects from the previous chapters to create the
graphics pipeline! Here are the types of objects we have now, as a quick recap:
• Shader stages: the shader modules that define the functionality of the programmable stages of
the graphics pipeline
• Fixed-function state: all the structures that define the fixed-function stages of the pipeline, like
input assembly, rasterizer, viewport and color blending
• Pipeline layout: the uniform and push values referenced by the shader that can be updated at
draw time
• Dynamic rendering: the formats of the attachments that will be used during rendering
All of these combined fully define the functionality of the graphics pipeline, so we can now begin
84
filling in the VkGraphicsPipelineCreateInfo structure at the end of the createGraphicsPipeline
function.
After that comes the pipeline layout, which is a Vulkan handle rather than a struct pointer.
Note that we’re using dynamic rendering instead of a traditional render pass, so we set the
renderPass parameter to nullptr and include a vk::PipelineRenderingCreateInfo structure in the
pNext chain. This structure specifies the formats of the attachments that will be used during
rendering.
There are actually two more parameters: basePipelineHandle and basePipelineIndex. Vulkan allows
you to create a new graphics pipeline by deriving from an existing pipeline. The idea of pipeline
derivatives is that it is less expensive to set up pipelines when they have much functionality in
common with an existing pipeline and switching between pipelines from the same parent can also
be done quicker. You can either specify the handle of an existing pipeline with basePipelineHandle
or reference another pipeline that is about to be created by index with basePipelineIndex. Right
now there is only a single pipeline, so we’ll simply specify a null handle and an invalid index. These
values are only used if the VK_PIPELINE_CREATE_DERIVATIVE_BIT flag is also specified in the flags field
85
of VkGraphicsPipelineCreateInfo.
Now prepare for the final step by creating a class member to hold the VkPipeline object:
The vkCreateGraphicsPipelines function actually has more parameters than the usual object
creation functions in Vulkan. It is designed to take multiple VkGraphicsPipelineCreateInfo objects
and create multiple VkPipeline objects in a single call.
The second parameter, for which we’ve passed the VK_NULL_HANDLE argument, references an optional
VkPipelineCache object. A pipeline cache can be used to store and reuse data relevant to pipeline
creation across multiple calls to vkCreateGraphicsPipelines and even across program executions if
the cache is stored to a file. This makes it possible to significantly speed up pipeline creation at a
later time. We’ll get into this in the pipeline cache chapter.
Now run your program to confirm that all this hard work has resulted in a successful pipeline
creation! We are already getting quite close to seeing something pop up on the screen. In the next
couple of chapters, we’ll set up the actual framebuffers from the swap chain images and prepare
the drawing commands.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Dynamic Rendering
In previous versions of Vulkan, we would need to create framebuffers to bind our image views to a
render pass. However, with the introduction of dynamic rendering in Vulkan 1.3, we can now
render directly to image views without creating framebuffers or render passes.
This approach offers several advantages: - Simplified code with fewer objects to manage - More
flexibility in changing attachments during rendering - Better compatibility with modern rendering
techniques
Let’s see how this works in practice. We’ll be using the vk::RenderingAttachmentInfo and
86
vk::RenderingInfo structures to specify our attachments and rendering parameters.
// Begin rendering
[Link](renderingInfo);
// End rendering
[Link]();
87
// Transition the image layout for presentation
transition_image_layout(
imageIndex,
vk::ImageLayout::eColorAttachmentOptimal,
vk::ImageLayout::ePresentSrcKHR,
vk::AccessFlagBits2::eColorAttachmentWrite,
{},
vk::PipelineStageFlagBits2::eColorAttachmentOutput,
vk::PipelineStageFlagBits2::eBottomOfPipe
);
[Link]();
}
As you can see, we directly specify the image view to render to in the vk::RenderingAttachmentInfo
structure. We also specify the load and store operations, similar to what we would do in a render
pass. The vk::RenderingInfo structure then combines this with other rendering parameters.
With this approach, we don’t need to create framebuffers or render passes, which simplifies our
code and gives us more flexibility.
In the next chapter, we’ll create command buffers and write the first actual drawing commands
using dynamic rendering.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Command Buffers
Commands in Vulkan, like drawing operations and memory transfers, are not executed directly
using function calls. You have to record all the operations you want to perform in command buffer
objects. The advantage of this is that when we are ready to tell Vulkan what we want to do, all the
commands are submitted together. Vulkan can more efficiently process the commands since all of
them are available together. In addition, this allows command recording to happen in multiple
threads if so desired.
Command pools
We have to create a command pool before we can create command buffers. Command pools
manage the memory that is used to store the buffers and command buffers are allocated from
them. Add a new class member to store a VkCommandPool:
Then create a new function createCommandPool and call it from initVulkan after the graphics pipeline
was created.
88
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
}
...
void createCommandPool() {
We will be recording a command buffer every frame, so we want to be able to reset and rerecord
over it. Thus, we need to set the VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT flag bit for our
command pool.
Command buffers are executed by submitting them on one of the device queues, like the graphics
and presentation queues we retrieved. Each command pool can only allocate command buffers that
are submitted on a single type of queue. We’re going to record commands for drawing, which is
why we’ve chosen the graphics queue family.
Finish creating the command pool using the vkCreateCommandPool function. It doesn’t have any
special parameters. Commands will be used throughout the program to draw things on the screen.
89
Command buffer allocation
We can now start allocating command buffers.
Create a VkCommandBuffer object as a class member. Command buffers will be automatically freed
when their command pool is destroyed, so we don’t need explicit cleanup.
We’ll now start working on a createCommandBuffer function to allocate a single command buffer
from the command pool.
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
createCommandBuffer();
}
...
void createCommandBuffer() {
Command buffers are allocated with the vkAllocateCommandBuffers function, which takes a
VkCommandBufferAllocateInfo struct as parameter that specifies the command pool and number of
buffers to allocate:
The level parameter specifies if the allocated command buffers are primary or secondary
command buffers.
90
primary command buffers.
We won’t make use of the secondary command buffer functionality here, but you can imagine that
it’s helpful to reuse common operations from primary command buffers.
Since we are only allocating one command buffer, the commandBufferCount parameter is just one.
commandBuffer->begin( {} );
The flags parameter specifies how we’re going to use the command buffer. The following values
are available:
The pInheritanceInfo parameter is only relevant for secondary command buffers. It specifies which
state to inherit from the calling primary command buffers.
If the command buffer was already recorded once, then a call to vkBeginCommandBuffer will
implicitly reset it. It’s not possible to append commands to a buffer at a later time.
91
For example, an image can be in a layout that is optimal for presenting to the screen, or in a layout
that is optimal for being used as a color attachment.
We’ll use a pipeline barrier to transition the image layout from VK_IMAGE_LAYOUT_UNDEFINED to
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:
void transition_image_layout(
uint32_t imageIndex,
vk::ImageLayout oldLayout,
vk::ImageLayout newLayout,
vk::AccessFlags2 srcAccessMask,
vk::AccessFlags2 dstAccessMask,
vk::PipelineStageFlags2 srcStageMask,
vk::PipelineStageFlags2 dstStageMask
) {
vk::ImageMemoryBarrier2 barrier = {
.srcStageMask = srcStageMask,
.srcAccessMask = srcAccessMask,
.dstStageMask = dstStageMask,
.dstAccessMask = dstAccessMask,
.oldLayout = oldLayout,
.newLayout = newLayout,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = swapChainImages[imageIndex],
.subresourceRange = {
.aspectMask = vk::ImageAspectFlagBits::eColor,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1
}
};
vk::DependencyInfo dependencyInfo = {
.dependencyFlags = {},
.imageMemoryBarrierCount = 1,
.pImageMemoryBarriers = &barrier
};
commandBuffer.pipelineBarrier2(dependencyInfo);
}
This function will be used to transition the image layout before and after rendering.
92
// Before starting rendering, transition the swapchain image to
COLOR_ATTACHMENT_OPTIMAL
transition_image_layout(
imageIndex,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eColorAttachmentOptimal,
{}, // srcAccessMask (no
need to wait for previous operations)
vk::AccessFlagBits2::eColorAttachmentWrite, // dstAccessMask
vk::PipelineStageFlagBits2::eColorAttachmentOutput, // srcStage
vk::PipelineStageFlagBits2::eColorAttachmentOutput // dstStage
);
The imageView parameter specifies which image view to render to. The imageLayout parameter
specifies the layout the image will be in during rendering. The loadOp parameter specifies what to
do with the image before rendering, and the storeOp parameter specifies what to do with the image
after rendering. We’re using VK_ATTACHMENT_LOAD_OP_CLEAR to clear the image to black before
rendering, and VK_ATTACHMENT_STORE_OP_STORE to store the rendered image for later use.
vk::RenderingInfo renderingInfo = {
.renderArea = { .offset = { 0, 0 }, .extent = swapChainExtent },
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &attachmentInfo
};
The renderArea parameter defines the size of the render area, similar to the render area in a render
pass. The layerCount parameter specifies the number of layers to render to, which is 1 for a non-
layered image. The colorAttachmentCount and pColorAttachments parameters specify the color
attachments to render to.
93
[Link](renderingInfo);
All the functions that record commands can be recognized by their vkCmd prefix. They all return
void, so there will be no error handling until we’ve finished recording.
The parameter for the beginRendering command is the rendering info we just set up, which specifies
the attachments to render to and the render area.
[Link](vk::PipelineBindPoint::eGraphics, graphicsPipeline);
The first parameter specifies if the pipeline object is a graphics or compute pipeline. We’ve now
told Vulkan which operations to execute in the graphics pipeline and which attachment to use in
the fragment shader.
As noted in the fixed functions chapter, we did specify viewport and scissor state for this pipeline to
be dynamic. So we need to set them in the command buffer before issuing our draw command:
Now we are ready to issue the draw command for the triangle:
[Link](3, 1, 0, 0);
The actual vkCmdDraw function is a bit anticlimactic, but it’s so simple because of all the information
we specified in advance. It has the following parameters, aside from the command buffer:
• vertexCount: Even though we don’t have a vertex buffer, we technically still have 3 vertices to
draw.
• instanceCount: Used for instanced rendering, use 1 if you’re not doing that.
• firstVertex: Used as an offset into the vertex buffer, defines the lowest value of SV_VertexId.
• firstInstance: Used as an offset for instanced rendering, defines the lowest value of
SV_InstanceID.
94
Finishing up
The rendering can now be ended:
[Link]();
[Link]();
In the next chapter we’ll write the code for the main loop, which will acquire an image from the
swap chain, record and execute a command buffer, then return the finished image to the swap
chain.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
}
...
95
void drawFrame() {
Outline of a frame
At a high level, rendering a frame in Vulkan consists of a common set of steps:
• Record a command buffer which draws the scene onto that image
While we will expand the drawing function in later chapters, for now this is the core of our render
loop.
Synchronization
A core design philosophy in Vulkan is that synchronization of execution on the GPU is explicit. The
order of operations is up to us to define using various synchronization primitives which tell the
driver the order we want things to run in. This means that many Vulkan API calls which start
executing work on the GPU are asynchronous, the functions will return before the operation has
finished.
In this chapter, there are a number of events that we need to order explicitly because they happen
on the GPU, such as:
• Present that image to the screen for presentation, returning it to the swapchain
Each of these events is set in motion using a single function call, but are all executed
asynchronously. The function calls will return before the operations are actually finished and the
order of execution is also undefined. That is unfortunate, because each of the operations depends
on the previous one finishing. Thus, we need to explore which primitives we can use to achieve the
desired ordering.
Semaphores
A binary semaphore is used to add order between queue operations. Queue operations refer to the
work we submit to a queue, either in a command buffer or from within a function as we will see
later. Examples of queues are the graphics queue and the presentation queue. Semaphores are used
96
both to order work inside the same queue and between different queues.
There happens to be two kinds of semaphores in Vulkan, binary and timeline. Because only binary
semaphores will be used in this tutorial, we will not discuss timeline semaphores. Further mention
of the term semaphore exclusively refers to binary semaphores.
A binary semaphore is either unsignaled or signaled. It begins life as unsignaled. The way we use a
binary semaphore to order queue operations is by providing the same semaphore as a 'signal'
semaphore in one queue operation and as a 'wait' semaphore in another queue operation. For
example, let’s say we have semaphore S and queue operations A and B that we want to execute in
order. What we tell Vulkan is that operation A will 'signal' semaphore S when it finishes executing,
and operation B will 'wait' on semaphore S before it begins executing. When operation A finishes,
semaphore S will be signaled, while operation B wont start until S is signaled. After operation B
begins executing, semaphore S is automatically reset back to being unsignaled, allowing it to be
used again.
Note that in this code snippet, both calls to vkQueueSubmit() return immediately - the waiting only
happens on the GPU. The CPU continues running without blocking. To make the CPU wait, we need
a different synchronization primitive, which we will now describe.
Fences
A fence has a similar purpose, in that it is used to synchronize execution, but it is for ordering the
execution on the CPU, otherwise known as the host. Concretely, if the host needs to know when the
GPU has finished something, we use a fence.
Similar to semaphores, fences are either in a signaled or unsignaled state. Whenever we submit
work to execute, we can attach a fence to that work. When the work is finished, the fence will be
signaled. Then we can make the host wait for the fence to be signaled, guaranteeing that the work
has finished before the host continues.
A concrete example is taking a screenshot. Say we have already done the necessary work on the
GPU. Now need to transfer the image from the GPU over to the host and then save the memory to a
file. We have command buffer A which executes the transfer and fence F. We submit command
buffer A with fence F, then immediately tell the host to wait for F to signal. This causes the host to
block until command buffer A finishes execution. Thus, we are safe to let the host save the file to
disk, as the memory transfer has completed.
97
Pseudocode for what was described:
Unlike the semaphore example, this example does block host execution. This means the host won’t
do anything except wait until the execution has finished. For this case, we had to make sure the
transfer was complete before we could save the screenshot to disk.
In general, it is preferable to not block the host unless necessary. We want to feed the GPU and the
host with useful work to do. Waiting on fences to signal is not useful work. Thus, we prefer
semaphores, or other synchronization primitives not yet covered, to synchronize our work.
Fences must be reset manually to put them back into the unsignaled state. This is because fences
are used to control the execution of the host, and so the host gets to decide when to reset the fence.
Contrast this to semaphores which are used to order work on the GPU without the host being
involved.
In summary, semaphores are used to specify the execution order of operations on the GPU while
fences are used to keep the CPU and GPU in sync with each-other.
What to choose?
We have two synchronization primitives to use and conveniently two places to apply
synchronization: Swapchain operations and waiting for the previous frame to finish. We want to
use semaphores for swapchain operations because they happen on the GPU, thus we don’t want to
make the host wait around if we can help it. For waiting on the previous frame to finish, we want to
use fences for the opposite reason, because we need the host to wait. This is so we don’t draw more
than one frame at a time. Because we re-record the command buffer every frame, we cannot record
the next frame’s work to the command buffer until the current frame has finished executing. We
don’t want to overwrite the current contents of the command buffer while the GPU is using it.
Create three class members to store these semaphore objects and fence object:
98
vk::raii::Semaphore presentCompleteSemaphore = nullptr;
vk::raii::Semaphore renderFinishedSemaphore = nullptr;
vk::raii::Fence drawFence = nullptr;
To create the semaphores, we’ll add the last create function for this part of the tutorial:
createSyncObjects:
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
createCommandBuffer();
createSyncObjects();
}
...
void createSyncObjects() {
Creating semaphores requires filling in the vk::SemaphoreCreateInfo, but in the current version of
the API it doesn’t actually have any fields relevant to the tutorial:
void createSyncObjects() {
presentCompleteSemaphore = vk::raii::Semaphore(device, vk::SemaphoreCreateInfo());
renderFinishedSemaphore = vk::raii::Semaphore(device, vk::SemaphoreCreateInfo());
drawFence = vk::raii::Fence(device, {.flags =
vk::FenceCreateFlagBits::eSignaled});
}
Future versions of the Vulkan API or extensions may add functionality for the flags and pNext
parameters like it does for the other structures.
99
void drawFrame() {
auto fenceResult = [Link](*drawFence, vk::True, UINT64_MAX);
}
The waitForFences function takes an array of fences and waits on the host for either any or all of the
fences to be signaled before returning. The vk::True we pass here indicates that we want to wait for
all fences, but in the case of a single one it doesn’t matter. This function also has a timeout
parameter that we set to the maximum value of a 64 bit unsigned integer, UINT64_MAX, which
effectively disables the timeout.
Next, let’s grab an image from the framebuffer after the previous frame has finished:
void drawFrame() {
...
auto [result, imageIndex] = [Link](UINT64_MAX,
*presentCompleteSemaphores[frameIndex], nullptr);
}
The first parameter specifies a timeout in nanoseconds for an image to become available. Using the
maximum value of a 64-bit unsigned integer means we effectively disable the timeout.
The next two parameters specify synchronization objects that are to be signaled when the
presentation engine is finished using the image. That’s the point in time where we can start
drawing to it. It is possible to specify a semaphore, fence or both. We’re going to use our
presentCompleteSemaphores for that purpose here.
The last parameter specifies a variable to output the index of the swap chain image that has
become available. The index refers to the VkImage in our swapChainImages array. We’re going to use
that index to pick the VkFrameBuffer. Then we’ll record into that framebuffer.
recordCommandBuffer(imageIndex);
We need to make sure that the fence is reset if the previous frame has already happened, so we
know to wait on it later.
[Link](*drawFence);
100
Submitting the command buffer
Queue submission and synchronization is configured through parameters in the vk::SubmitInfo
structure.
vk::PipelineStageFlags waitDestinationStageMask(
vk::PipelineStageFlagBits::eColorAttachmentOutput );
const vk::SubmitInfo submitInfo{
.waitSemaphoreCount = 1,
.pWaitSemaphores = &*presentCompleteSemaphore,
.pWaitDstStageMask = &waitDestinationStageMask,
.commandBufferCount = 1,
.pCommandBuffers = &*commandBuffer,
.signalSemaphoreCount = 1,
.pSignalSemaphores = &*renderFinishedSemaphore};
The first three parameters specify which semaphores to wait on before execution begins and in
which stage(s) of the pipeline to wait. We want to wait for writing colors to the image until it’s
available, so we’re specifying the stage of the graphics pipeline that writes to the color attachment.
That means that theoretically, the implementation can already start executing our vertex shader
and such while the image is not yet available. Each entry in the waitStages array corresponds to the
semaphore with the same index in pWaitSemaphores.
The next parameter specifies which command buffers to actually submit for execution. We simply
submit the single command buffer we have.
The pSignalSemaphores parameter specifies which semaphores to signal once the command buffer(s)
have finished execution. In our case we’re using the renderFinishedSemaphore for that purpose.
[Link](submitInfo, *drawFence);
We can now submit the command buffer to the graphics queue using submit. The function takes an
array of vk::SubmitInfo structures as argument for efficiency when the workload is much larger.
The last parameter references an optional fence that will be signaled when the command buffers
finish execution. This allows us to know when it is safe for the command buffer to be reused, thus
we want to give it drawFence, which is waited on in the next frame.
Subpass dependencies
This section is optional and far more explicit than is necessary.
Remember that the subpasses in a render pass automatically take care of image layout transitions.
These transitions are controlled by subpass dependencies, which specify memory and execution
dependencies between subpasses. We have only a single subpass right now, but the operations right
before and right after this subpass also count as implicit "subpasses".
101
There are two built-in dependencies that take care of the transition at the start of the render pass
and at the end of the render pass, but the former does not occur at the right time. It assumes that
the transition occurs at the start of the pipeline, but we haven’t acquired the image yet at that
point! There are two ways to deal with this problem. We could change the waitStages for the
imageAvailableSemaphore to VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT to ensure that the render passes
don’t begin until the image is available, or we can make the render pass wait for the
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT stage. I’ve decided to go with the second option
here, because it’s a good excuse to have a look at subpass dependencies and how they work.
The first two fields specify the indices of the dependency and the dependent subpass. The special
value VK_SUBPASS_EXTERNAL refers to the implicit subpass before or after the render pass depending
on whether it is specified in srcSubpass or dstSubpass. The index 0 refers to our subpass, which is
the first and only one. The dstSubpass must always be higher than srcSubpass to prevent cycles in
the dependency graph (unless one of the subpasses is VK_SUBPASS_EXTERNAL).
The next two fields specify the operations to wait on and the operations that should wait on this are
in the color attachment stage. The last two fields specify the stages in which these operations occur
and invovles the writing of the color attachment. We need to wait for the swap chain to finish
reading from the image before we can access it. This can be accomplished by waiting on the color
attachment output stage itself.
These settings will prevent the transition from happening until it’s actually necessary (and
allowed): when we want to start writing colors to it.
[Link] = 1;
[Link] = &dependency;
The above is completely optional and not reproduced in the demo code.
Presentation
The last step of drawing a frame is submitting the result back to the swap chain to have it
eventually show up on the screen. Presentation is configured through a vk::PresentInfoKHR
102
structure at the end of the drawFrame function.
The first two parameters specify which semaphores to wait on before presentation can happen, just
like vk::SubmitInfo. Since we want to wait on the command buffer to finish execution, thus our
triangle being drawn, we take the semaphores which will be signaled and wait on them, thus we
use signalSemaphores.
The next two parameters specify the swap chains to present images to and the index of the image
for each swap chain. This will almost always be single.
There is one last optional parameter called pResults. It allows you to specify an array of vk::Result
values to check for every swap chain if presentation was successful. It’s not necessary if you’re only
using a single swap chain, because you can use the return value of the present function.
result = [Link](presentInfoKHR);
The presentKHR function submits the request to present an image to the swap chain. We’ll add error
handling for both [Link] and [Link] in the next chapter, because
their failure does not necessarily mean that the program should terminate, unlike the functions
we’ve seen so far.
If you did everything correctly up to this point, then you should now see something resembling the
following when you run your program:
[triangle] | /images/[Link]
This colored triangle may look a bit different from the one you’re used to
seeing in graphics tutorials. That’s because this tutorial lets the shader
interpolate in linear color space and converts to sRGB color space
afterward.
Yay! Unfortunately, you’ll see that when validation layers are enabled, the program crashes as soon
as you close it. The messages printed to the terminal from debugCallback tell us why:
Remember that all the operations in drawFrame are asynchronous. That means that when we exit the
103
loop in mainLoop, drawing and presentation operations may still be going on. Cleaning up resources
while that is happening is a bad idea.
To fix that problem, we should wait for the logical device to finish operations before exiting
mainLoop and destroying the window:
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
[Link]();
}
You can also wait for operations in a specific command queue to be finished with vkQueueWaitIdle.
These functions can be used as a very rudimentary way to perform synchronization. You’ll see that
the program now exits without problems when closing the window.
Conclusion
A little over 500 lines of code later, we’ve finally gotten to the stage of seeing something pop up on
the screen! Bootstrapping a Vulkan program is definitely a lot of work, but the take-away message
is that Vulkan gives you an immense amount of control through its explicitness. I recommend you
to take some time now to reread the code and build a mental model of the purpose of all the Vulkan
objects in the program and how they relate to each other. We’ll be building on top of that
knowledge to extend the functionality of the program from this point on.
Also, in a future chapter, we’ll talk about timeline semaphores and memory barriers and further
refine our understanding of synchronization in Vulkan. Synchronization is one of the biggest areas
to take advantage of the true power of Vulkan, so it is quite complex. This, while complicated to
understand the first few times, is really the foundation for what comes next. It really gets easier
from here when there’s more tools in your toolbox to do things that are more nuanced.
The next chapter will expand the render loop to handle multiple frames in flight.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Frames in flight
Right now our render loop has one glaring flaw. We are required to wait on the previous frame to
finish before we can start rendering the next which results in unnecessary idling of the host.
The way to fix this is to allow multiple frames to be in-flight at once, that is to say, allow the
rendering of one frame to not interfere with the recording of the next. How do we do this? Any
resource that is accessed and modified during rendering must be duplicated. Thus, we need
multiple command buffers, semaphores, and fences. In later chapters, we will also add multiple
104
instances of other resources, so we will see this concept reappear.
Start by adding a constant at the top of the program that defines how many frames should be
processed concurrently:
We choose the number 2 because we don’t want the CPU to get too far ahead of the GPU. With two
frames in flight, the CPU and the GPU can be working on their own tasks at the same time. If the
CPU finishes early, it will wait till the GPU finishes rendering before submitting more work. With
three or more frames in flight, the CPU could get ahead of the GPU, adding frames of latency.
Generally, extra latency isn’t desired. But giving the application control over the number of frames
in flight is another example of Vulkan being explicit.
Each frame should have its own command buffer, set of semaphores, and fence. Rename and then
change them to be std::vectors of the objects:
std::vector<vk::raii::CommandBuffer> commandBuffers;
...
std::vector<vk::raii::Semaphore> presentCompleteSemaphores;
std::vector<vk::raii::Semaphore> renderFinishedSemaphores;
std::vector<vk::raii::Fence> inFlightFences;
void createCommandBuffers() {
[Link]();
vk::CommandBufferAllocateInfo allocInfo{ .commandPool = commandPool, .level =
vk::CommandBufferLevel::ePrimary,
.commandBufferCount = MAX_FRAMES_IN_FLIGHT
};
commandBuffers = vk::raii::CommandBuffers( device, allocInfo );
}
void createSyncObjects() {
assert([Link]() && [Link]()
&& [Link]());
105
{
renderFinishedSemaphores.emplace_back(device, vk::SemaphoreCreateInfo());
}
To use the right objects every frame, we need to keep track of the current frame. We will use a
frame index for that purpose:
uint32_t frameIndex 0;
The drawFrame function can now be modified to use the right objects:
void drawFrame() {
auto fenceResult = [Link](*inFlightFences[frameIndex], vk::True,
UINT64_MAX);
if (fenceResult != vk::Result::eSuccess)
{
throw std::runtime_error("failed to wait for fence!");
}
[Link](*inFlightFences[frameIndex]);
commandBuffers[frameIndex].reset();
recordCommandBuffer(imageIndex);
vk::PipelineStageFlags
waitDestinationStageMask(vk::PipelineStageFlagBits::eColorAttachmentOutput);
const vk::SubmitInfo submitInfo{.waitSemaphoreCount = 1,
.pWaitSemaphores =
&*presentCompleteSemaphores[frameIndex],
.pWaitDstStageMask =
&waitDestinationStageMask,
.commandBufferCount = 1,
.pCommandBuffers =
&*commandBuffers[frameIndex],
.signalSemaphoreCount = 1,
.pSignalSemaphores =
&*renderFinishedSemaphores[imageIndex]};
[Link](submitInfo, *inFlightFences[frameIndex]);
106
}
void drawFrame() {
...
By using the modulo (%) operator, we ensure that the frame index loops around after every
MAX_FRAMES_IN_FLIGHT enqueued frames.
We’ve now implemented all the necessary synchronization to ensure that there are no more than
MAX_FRAMES_IN_FLIGHT frames of work enqueued and that these frames are not stepping over each
other. Note that it is fine for other parts of the code, like the final cleanup, to rely on more rough
synchronization like vkDeviceWaitIdle. You should decide on which approach to use based on
performance requirements.
Additionally, we could use timeline semaphores instead of the binary semaphores presented here.
To see an example of how to use timeline semaphores, look at compute shader chapter . Note that
timeline semaphores are especially useful for dealing with a compute and a graphics queue as in
that example. This method of simple binary semaphores could be thought of as the more traditional
approach to synchronization.
To learn more about synchronization through examples, have a look at this extensive overview by
Khronos.
In the next chapter we’ll deal with one more small thing required for a well-behaved Vulkan
program.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
107
the objects that depend on the swap chain or the window size.
void recreateSwapChain() {
[Link]();
createSwapChain();
createImageViews();
}
We first call vkDeviceWaitIdle, because just like in the last chapter, we shouldn’t touch resources
that may still be in use. Obviously, we’ll have to recreate the swap chain itself. The image views
need to be recreated because they are based directly on the swap chain images.
To make sure that the old versions of these objects are cleaned up before recreating them, we
should move some of the cleanup code to a separate function that we can call from the
recreateSwapChain function. Let’s call it cleanupSwapChain:
void cleanupSwapChain() {
void recreateSwapChain() {
[Link]();
cleanupSwapChain();
createSwapChain();
createImageViews();
}
Note that we don’t recreate the renderpass here for simplicity. In theory, it can be possible for the
swap chain image format to change during an applications' lifetime, e.g., when moving a window
from a standard range to a high dynamic range monitor. This may require the application to
recreate the renderpass to make sure the change between dynamic ranges is properly reflected.
We’ll move the cleanup code of all objects that are recreated as part of a swap chain refresh from
cleanup to cleanupSwapChain:
void cleanupSwapChain() {
[Link]();
swapChain = nullptr;
}
void cleanup() {
cleanupSwapChain();
glfwDestroyWindow(window);
glfwTerminate();
108
}
Note that in chooseSwapExtent we already query the new window resolution to make sure that the
swap chain images have the (new) right size, so there’s no need to modify chooseSwapExtent
(remember that we already had to use glfwGetFramebufferSize to get the resolution of the surface in
pixels when creating the swap chain).
That’s all it takes to recreate the swap chain! However, the disadvantage of this approach is that we
need to stop all renderings before creating the new swap chain. It is possible to create a new swap
chain while drawing commands on an image from the old swap chain are still in-flight. You need to
pass the previous swap chain to the oldSwapchain field in the VkSwapchainCreateInfoKHR struct and
destroy the old swap chain as soon as you’ve finished using it.
• vk::Result::eErrorOutOfDateKHR: The swap chain has become incompatible with the surface and
can no longer be used for rendering. Usually happens after a window resize. Note that
“vk::Result::eErrorOutOfDateKHR” is actually an error code and would trigger an exception by
default. By defining “VULKAN_HPP_HANDLE_ERROR_OUT_OF_DATE_AS_SUCCESS,” this is
treated as a success code and can therefore be returned by
“vk::raii::SwapchainKHR::acquireNextImage” and “vk::raii::Queue::presentKHR.”
• vk::Result::eSuboptimalKHR: The swap chain can still be used to successfully present to the
surface, but the surface properties are no longer matched exactly.
if (result == vk::Result::eErrorOutOfDateKHR)
{
recreateSwapChain();
return;
}
if (result != vk::Result::eSuccess && result != vk::Result::eSuboptimalKHR)
{
assert(result == vk::Result::eTimeout || result == vk::Result::eNotReady);
throw std::runtime_error("failed to acquire swap chain image!");
}
If the swap chain turns out to be out of date when attempting to acquire an image, then it is no
longer possible to present to it. Therefore, we should immediately recreate the swap chain and try
again in the next drawFrame call.
109
You could also decide to do that if the swap chain is suboptimal, but I’ve chosen to proceed anyway
in that case because we’ve already acquired an image. Both vk::Result::eSuccess and
vk::Result::eSuboptimalKHR are considered "success" return codes.
result = [Link](presentInfoKHR);
if ((result == vk::Result::eSuboptimalKHR) || (result ==
vk::Result::eErrorOutOfDateKHR))
{
recreateSwapChain();
}
else
{
// There are no other success codes than eSuccess; on any error code, presentKHR
already threw an exception.
assert(result == vk::Result::eSuccess);
}
The vk::raii::Queue::presentKHR function returns the same values with the same meaning. In this
case, we will also recreate the swap chain if it is suboptimal, because we want the best possible
result.
Fixing a deadlock
If we try to run the code now, it is possible to encounter a deadlock. Debugging the code, we find
that the application reaches vk::raii::Device::waitForFences but never continues past it. This is
110
because when vk::raii::SwapchainKHR::acquireNextImage returns vk::Result::eErrorOutOfDateKHR,
we recreate the swapchain and then return from drawFrame. But before that happens, the current
frame’s fence was waited upon and reset. Since we return immediately, no work is submitted for
execution and the fence will never be signaled, causing vk::raii::Device::waitForFences to halt
forever.
There is a simple fix thankfully. Delay resetting the fence until after we know for sure, we will be
submitting work with it. Thus, if we return early, the fence is still signaled and
vk::raii::Device::waitForFences wont deadlock the next time we use the same fence object.
if (result == vk::Result::eErrorOutOfDateKHR)
{
recreateSwapChain();
return;
}
else if (result != vk::Result::eSuccess && result != vk::Result::eSuboptimalKHR)
{
assert(result == vk::Result::eTimeout || result == vk::Result::eNotReady);
throw std::runtime_error("failed to acquire swap chain image!");
}
std::vector<vk::raii::Fence> inFlightFences;
The drawFrame function should then be modified to also check for this flag:
111
if (result == vk::Result::eErrorOutOfDateKHR || result == vk::Result::eSuboptimalKHR
|| framebufferResized) {
framebufferResized = false;
recreateSwapChain();
}
else
{
...
}
void initWindow() {
glfwInit();
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
The reason that we’re creating a static function as a callback is because GLFW does not know how
to properly call a member function with the right this pointer to our HelloTriangleApplication
instance.
However, we do get a reference to the GLFWwindow in the callback and there is another GLFW
function that allows you to store an arbitrary pointer inside it: glfwSetWindowUserPointer:
This value can now be retrieved from within the callback with glfwGetWindowUserPointer to
properly set the flag:
112
}
Now try to run the program and resize the window to see if the framebuffer is indeed resized
properly with the window.
Handling minimization
There is another case where a swap chain may become out of date and that is a special kind of
window resizing: window minimization. This case is special because it will result in a frame buffer
size of 0. In this tutorial we will handle that by pausing until the window is in the foreground again
by extending the recreateSwapChain function:
void recreateSwapChain() {
int width = 0, height = 0;
glfwGetFramebufferSize(window, &width, &height);
while (width == 0 || height == 0) {
glfwGetFramebufferSize(window, &width, &height);
glfwWaitEvents();
}
[Link]();
...
}
The initial call to glfwGetFramebufferSize handles the case where the size is already correct and
glfwWaitEvents would have nothing to wait on.
Congratulations, you’ve now finished your very first well-behaved Vulkan program! In the next
chapter we’re going to get rid of the hardcoded vertices in the vertex shader and actually use a
vertex buffer.
C++ code / Slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
113
Vertex shader
First, change the vertex shader to no longer include the vertex data in the shader code itself. The
vertex shader takes input from a vertex buffer by being declared in a struct in the proper order.
struct VSInput {
float2 inPosition;
float3 inColor;
};
struct VSOutput
{
float4 pos : SV_Position;
float3 color;
};
[shader("vertex")]
VSOutput vertMain(VSInput input) {
VSOutput output;
[Link] = float4([Link], 0.0, 1.0);
[Link] = [Link];
return output;
}
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return float4([Link], 1.0);
}
The inPosition and inColor variables are vertex attributes. They’re properties that are specified per-
vertex in the vertex buffer just like we manually specified a position and color per vertex using the
two arrays.
Vertex data
We’re moving the vertex data from the shader code to an array in the code of our program. Start by
including the GLM library, which provides us with linear algebra related types like vectors and
matrices. We’re going to use these types to specify the position and color vectors.
#include <glm/[Link]>
Create a new structure called Vertex with the two attributes that we’re going to use in the vertex
shader inside it:
struct Vertex
{
114
glm::vec2 pos;
glm::vec3 color;
};
GLM conveniently provides us with C++ types that exactly match the vector types used in the
shader language.
Now use the Vertex structure to specify an array of vertex data. We’re using exactly the same
position and color values as before, but now they’re combined into one array of vertices. This is
known as interleaving vertex attributes.
Binding descriptions
The next step is to tell Vulkan how to pass this data format to the vertex shader once it’s been
uploaded into GPU memory. There are two types of structures needed to convey this information.
The first structure is vk::VertexInputBindingDescription and we’ll add a member function to the
Vertex struct to populate it with the right data.
struct Vertex {
glm::vec2 pos;
glm::vec3 color;
A vertex binding describes at which rate to load data from memory throughout the vertices. It
specifies the number of bytes between data entries and whether to move to the next data entry
after each vertex or after each instance.
All of our per-vertex data is packed together in one array, so we’re only going to have one binding.
The binding parameter specifies the index of the binding in the array of bindings. The stride
parameter specifies the number of bytes from one entry to the next, and the inputRate parameter
can have one of the following values:
115
We’re not going to use instanced rendering, so we’ll stick to per-vertex data.
Attribute descriptions
The second structure that describes how to handle vertex input is
vk::VertexInputAttributeDescription. We’re going to add another helper function to Vertex to fill in
these structs.
#include <array>
...
As the function prototype indicates, there are going to be two of these structures. An attribute
description struct describes how to extract a vertex attribute from a chunk of vertex data
originating from a binding description. We have two attributes, position and color, so we need two
attribute description structs.
The binding parameter tells Vulkan from which binding the per-vertex data comes. The location
parameter references the location directive of the input in the vertex shader. The input in the
vertex shader with location 0 is the position, which has two 32-bit float components.
The format parameter describes the type of data for the attribute. A bit confusingly, the formats are
specified using the same enumeration as color formats. The following shader types and formats are
commonly used together:
• float : vk::Format::eR32Sfloat
• float2: vk::Format::eR32G32Sfloat
• float3: vk::Format::eR32G32B32Sfloat
• float4: vk::Format::eR32G32B32A32Sfloat
As you can see, you should use the format where the amount of color channels matches the number
of components in the shader data type. It is allowed to use more channels than the number of
components in the shader, but they will be silently discarded. If the number of channels is lower
than the number of components, then the BGA components will use default values of (0, 0, 1). The
color type (Sfloat, Uint, Sint) and bit width should also match the type of the shader input. See the
following examples:
116
• uint4 : vk::Format::eR32G32B32A32Uint, a 4-component vector of 32-bit unsigned integers
The format parameter implicitly defines the byte size of attribute data and the offset parameter has
specified the number of bytes since the start of the per-vertex data to read from. The binding is
loading one Vertex at a time and the position attribute (pos) is at an offset of 0 bytes from the
beginning of this struct. This is automatically calculated using the offsetof macro.
auto bindingDescription =
Vertex::getBindingDescription();
auto attributeDescriptions =
Vertex::getAttributeDescriptions();
vk::PipelineVertexInputStateCreateInfo vertexInputInfo{ .vertexBindingDescriptionCount
= 1,
.pVertexBindingDescriptions
= &bindingDescription,
The pipeline is now ready to accept vertex data in the format of the vertices container and pass it
on to our vertex shader. The next step is to create a vertex buffer and move the vertex data to it so
the GPU is able to access it.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
117
programmer in control of almost everything, and memory management is one of those things.
Buffer creation
Create a new function createVertexBuffer and call it from initVulkan right before
createCommandBuffers.
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
createVertexBuffer();
createCommandBuffers();
createSyncObjects();
}
...
void createVertexBuffer() {
The size field specifies the size of the buffer in bytes. Calculating the byte size of the vertex data is
straightforward with sizeof.
The usage field indicates for which purposes the data in the buffer is going to be used. It is possible
to specify multiple purposes using a bitwise or. Our use case will be a vertex buffer, we’ll look at
other types of usage in future chapters.
Just like the images in the swap chain, buffers can also be owned by a specific queue family or be
shared between multiple at the same time. The buffer will only be used from the graphics queue, so
we can stick to exclusive access.
There is also a flags field, which is used to configure sparse buffer memory. It’s not relevant right
now, so we’ll leave it at the default value of 0.
118
We can now create the buffer with vkCreateBuffer. Define a class member to hold the buffer handle
and call it vertexBuffer.
...
void createVertexBuffer() {
vk::BufferCreateInfo bufferInfo{ .size = sizeof(vertices[0]) * [Link](),
.usage = vk::BufferUsageFlagBits::eVertexBuffer, .sharingMode =
vk::SharingMode::eExclusive };
vertexBuffer = vk::raii::Buffer(device, bufferInfo);
}
The buffer should be available for use in rendering commands until the end of the program, and it
does not depend on the swap chain.
Memory requirements
The buffer has been created, but it doesn’t have any memory assigned to it yet. The first step of
allocating memory for the buffer is to query its memory requirements using the aptly named
vkGetBufferMemoryRequirements function.
• size: The size of the required memory in bytes may differ from [Link].
• alignment: The offset in bytes where the buffer begins in the allocated region of memory,
depends on [Link] and [Link].
• memoryTypeBits: Bit field of the memory types that are suitable for the buffer.
Graphics cards can offer different types of memory to allocate from. Each type of memory varies in
terms of allowed operations and performance characteristics. We need to combine the
requirements of the buffer and our own application requirements to find the right type of memory
to use. Let’s create a new function findMemoryType for this purpose.
First we need to query info about the available types of memory using
vkGetPhysicalDeviceMemoryProperties.
119
vk::PhysicalDeviceMemoryProperties memProperties =
[Link]();
Let’s first find a memory type that is suitable for the buffer itself:
The typeFilter parameter will be used to specify the bit field of memory types that are suitable.
That means that we can find the index of a suitable memory type by simply iterating over them and
checking if the corresponding bit is set to 1.
However, we’re not just interested in a memory type that is suitable for the vertex buffer. We also
need to be able to write our vertex data to that memory. The memoryTypes array consists of
VkMemoryType structs that specify the heap and properties of each memory type. The properties
define special features of the memory, like being able to map it so we can write to it from the CPU.
This property is indicated with VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, but we also need to use the
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT property. We’ll see why when we map the memory.
We can now modify the loop to also check for the support of this property:
We may have more than one desirable property, so we should check if the result of the bitwise AND
is not just non-zero, but equal to the desired properties bit field. If there is a memory type suitable
for the buffer that also has all the properties we need, then we return its index, otherwise we throw
an exception.
120
Memory allocation
We now have a way to determine the right memory type, so we can actually allocate the memory by
filling in the VkMemoryAllocateInfo structure.
Memory allocation is now as simple as specifying the size and type, both of which are derived from
the memory requirements of the vertex buffer and the desired property. Create a class member to
store the handle to the memory and allocate it with vkAllocateMemory.
...
If memory allocation was successful, then we can now associate this memory with the buffer using
vkBindBufferMemory:
[Link]( *vertexBufferMemory, 0 );
The first parameter is self-explanatory, and the second parameter is the offset within the region of
memory. Since this memory is allocated specifically for this the vertex buffer, the offset is simply 0.
If the offset is non-zero, then it is required to be divisible by [Link].
This function allows us to access a region of the specified memory resource defined by an offset
and size. The offset and size here are 0 and [Link], respectively.
121
You can now simply memcpy the vertex data to the mapped memory and unmap it again using
vkUnmapMemory. Unfortunately, the driver may not immediately copy the data into the buffer
memory, for example, because of caching. It is also possible that writes to the buffer are not visible
in the mapped memory yet. There are two ways to deal with that problem:
We went for the first approach, which ensures that the mapped memory always matches the
contents of the allocated memory. Do keep in mind that this may lead to slightly worse performance
than explicit flushing, but we’ll see why that doesn’t matter in the next chapter.
Flushing memory ranges or using a coherent memory heap means that the driver will be aware of
our writings to the buffer, but it doesn’t mean that they are actually visible on the GPU yet. The
transfer of data to the GPU is an operation that happens in the background, and the specification
simply tells us that it is guaranteed to be complete as of the next call to vkQueueSubmit.
commandBuffers[frameIndex].bindPipeline(vk::PipelineBindPoint::eGraphics,
*graphicsPipeline);
commandBuffers[frameIndex].draw(3, 1, 0, 0);
The vkCmdBindVertexBuffers function is used to bind vertex buffers to bindings, like the one we set
up in the previous chapter. The first two parameters, besides the command buffer, specify the
offset and number of bindings we’re going to specify vertex buffers for. The last two parameters
specify the array of vertex buffers to bind and the byte offsets to start reading vertex data from. You
should also change the call to vkCmdDraw to pass the number of vertices in the buffer as opposed to
the hardcoded number 3.
Now run the program and you should see the familiar triangle again:
[triangle] | /images/[Link]
Try changing the color of the top vertex to white by modifying the vertices array:
122
};
Run the program again, and you should see the following:
In the next chapter, we’ll look at a different way to copy vertex data to a vertex buffer that results
in better performance, but takes some more work.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Staging buffer
Introduction
The vertex buffer we have right now works correctly, but the memory type that allows us to access
it from the CPU may not be the most optimal memory type for the graphics card itself to read from.
The most optimal memory has the VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT flag and is usually not
accessible by the CPU on dedicated graphics cards. In this chapter, we’re going to create two vertex
buffers. One staging buffer in CPU accessible memory to upload the data from the vertex array to,
and the final vertex buffer in device local memory. We’ll then use a buffer copy command to move
the data from the staging buffer to the actual vertex buffer.
Transfer queue
The buffer copy command requires a queue family that supports transfer operations, which is
indicated using VK_QUEUE_TRANSFER_BIT. The good news is that any queue family with
VK_QUEUE_GRAPHICS_BIT or VK_QUEUE_COMPUTE_BIT capabilities already implicitly support
VK_QUEUE_TRANSFER_BIT operations. The implementation is not required to explicitly list it in
queueFlags in those cases.
If you like a challenge, then you can still try to use a different queue family specifically for transfer
operations. It will require you to make the following modifications to your program:
• Modify QueueFamilyIndices and findQueueFamilies to explicitly look for a queue family with the
VK_QUEUE_TRANSFER_BIT bit, but not the VK_QUEUE_GRAPHICS_BIT.
• Create a second command pool for command buffers that are submitted on the transfer queue
family
• Submit any transfer commands like vkCmdCopyBuffer (which we’ll be using in this chapter) to the
transfer queue instead of the graphics queue
123
It’s a bit of work, but it’ll teach you a lot about how resources are shared between queue families.
Make sure to add parameters for the buffer size, memory properties and usage so that we can use
this function to create many different types of buffers. The last two parameters are output variables
to write the handles to.
You can now remove the buffer creation and memory allocation code from createVertexBuffer and
just call createBuffer instead:
void createVertexBuffer() {
vk::DeviceSize bufferSize = sizeof(vertices[0]) * [Link]();
createBuffer(bufferSize, vk::BufferUsageFlagBits::eVertexBuffer,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
vertexBuffer, vertexBufferMemory);
void* data = [Link](0, bufferSize);
memcpy(data, [Link](), (size_t) bufferSize);
[Link]();
}
Run your program to make sure that the vertex buffer still works properly.
void createVertexBuffer() {
124
vk::DeviceSize bufferSize = sizeof(vertices[0]) * [Link]();
[Link](stagingBufferMemory, 0);
void* dataStaging = [Link](0, [Link]);
memcpy(dataStaging, [Link](), [Link]);
[Link]();
[Link]( *vertexBufferMemory, 0 );
We’re now using a new stagingBuffer with stagingBufferMemory for mapping and copying the vertex
data. In this chapter, we’re going to use two new buffer usage flags: Note, we have to create a
temporary pointer to a new vk::raii::Buffer object because the vk::raii::Buffer has the constructor
deleted and thus doesn’t play well with std::make_unique, this is just a trick to get it to work.
The vertexBuffer is now allocated from a memory type that is device local, which generally means
that we’re not able to use vkMapMemory. However, we can copy data from the stagingBuffer to the
vertexBuffer. We have to indicate that we intend to do that by specifying the transfer source flag for
the stagingBuffer and the transfer destination flag for the vertexBuffer, along with the vertex
buffer usage flag.
125
We’re now going to write a function to copy the contents from one buffer to another, called
copyBuffer.
Memory transfer operations are executed using command buffers, just like drawing commands.
Therefore we must first allocate a temporary command buffer. You may wish to create a separate
command pool for these kinds of short-lived buffers, because the implementation may be able to
apply memory allocation optimizations. You should use the VK_COMMAND_POOL_CREATE_TRANSIENT_BIT
flag during command pool generation in that case.
[Link](vk::CommandBufferBeginInfo { .flags =
vk::CommandBufferUsageFlagBits::eOneTimeSubmit });
We’re only going to use the command buffer once and wait with returning from the function until
the copy operation has finished executing. It’s good practice to tell the driver about our intent using
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT.
Contents of buffers are transferred using the vkCmdCopyBuffer command. It takes the source and
destination buffers as arguments, and an array of regions to copy. The regions are defined in
VkBufferCopy structs and consist of a source buffer offset, destination buffer offset and size. It is not
possible to specify VK_WHOLE_SIZE here, unlike the vkMapMemory command.
[Link]();
This command buffer only contains the copy command, so we can stop recording right after that.
Now execute the command buffer to complete the transfer:
126
&*commandCopyBuffer }, nullptr);
[Link]();
Unlike the draw commands, there are no events we need to wait on this time. We just want to
execute the transfer on the buffers immediately. There are again two possible ways to wait on this
transfer to complete. We could use a fence and wait with vkWaitForFences, or simply wait for the
transfer queue to become idle with vkQueueWaitIdle. A fence would allow you to schedule multiple
transfers simultaneously and wait for all of them complete, instead of executing one at a time. That
may give the driver more opportunities to optimize.
We can now call copyBuffer from the createVertexBuffer function to move the vertex data to the
device local buffer:
[Link]( *vertexBufferMemory, 0 );
After copying the data from the staging buffer to the device buffer, the RAII buffer object will clean
itself up and free the memory.
Run your program to verify that you’re seeing the familiar triangle again. The improvement may
not be visible right now, but its vertex data is now being loaded from high performance memory.
This will matter when we’re going to start rendering more complex geometry.
Conclusion
It should be noted that in a real world application, you’re not supposed to actually call
vkAllocateMemory for every individual buffer. The maximum number of simultaneous memory
allocations is limited by the maxMemoryAllocationCount physical device limit, which may be as low as
4096 even on high end hardware like an NVIDIA GTX 1080. The right way to allocate memory for a
large number of objects at the same time is to create a custom allocator that splits up a single
allocation among many different objects by using the offset parameters that we’ve seen in many
functions.
You can either implement such an allocator yourself, or use the VulkanMemoryAllocator library
provided by the GPUOpen initiative. However, for this tutorial, it’s okay to use a separate allocation
127
for every resource, because we won’t come close to hitting any of these limits for now.
In the next chapter, we’ll learn about index buffers for vertex reuse.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Index buffer
Introduction
The 3D meshes you’ll be rendering in a real world application will often share vertices between
multiple triangles. This already happens even with something simple like drawing a rectangle:
Drawing a rectangle takes two triangles, which means that we need a vertex buffer with six
vertices. The problem is that the data of two vertices needs to be duplicated, resulting in 50%
redundancy. It only gets worse with more complex meshes, where vertices are reused in an average
number of three triangles. The solution to this problem is to use an index buffer.
An index buffer is essentially an array of pointers into the vertex buffer. It allows you to reorder
the vertex data, and reuse existing data for multiple vertices. The illustration above demonstrates
what the index buffer would look like for the rectangle if we have a vertex buffer containing each
of the four unique vertices. The first three indices define the upper-right triangle, and the last three
indices define the vertices for the bottom-left triangle.
The top-left corner is red, top-right is green, bottom-right is blue and the bottom-left is white. We’ll
add a new array indices to represent the contents of the index buffer. It should match the indices in
the illustration to draw the upper-right triangle and bottom-left triangle.
128
It is possible to use either uint16_t or uint32_t for your index buffer depending on the number of
entries in vertices. We can stick to uint16_t for now because we’re using less than 65535 unique
vertices.
Just like the vertex data, the indices need to be uploaded into a VkBuffer for the GPU to be able to
access them. Define two new class members to hold the resources for the index buffer:
The createIndexBuffer function that we’ll add now is almost identical to createVertexBuffer:
void initVulkan() {
...
createVertexBuffer();
createIndexBuffer();
...
}
void createIndexBuffer() {
vk::DeviceSize bufferSize = sizeof(indices[0]) * [Link]();
vk::raii::Buffer stagingBuffer({});
vk::raii::DeviceMemory stagingBufferMemory({});
createBuffer(bufferSize, vk::BufferUsageFlagBits::eTransferSrc,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
stagingBuffer, stagingBufferMemory);
createBuffer(bufferSize, vk::BufferUsageFlagBits::eTransferDst |
vk::BufferUsageFlagBits::eIndexBuffer, vk::MemoryPropertyFlagBits::eDeviceLocal,
indexBuffer, indexBufferMemory);
There are only two notable differences. The bufferSize is now equal to the number of indices times
the size of the index type, either uint16_t or uint32_t. The usage of the indexBuffer should be
VK_BUFFER_USAGE_INDEX_BUFFER_BIT instead of VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, which makes
sense. Other than that, the process is exactly the same. We create a staging buffer to copy the
contents of indices to and then copy it to the final device local index buffer.
129
Using an index buffer
Using an index buffer for drawing involves two changes to recordCommandBuffer. We first need to
bind the index buffer, just like we did for the vertex buffer. The difference is that you can only have
a single index buffer. It’s unfortunately not possible to use different indices for each vertex
attribute, so we do still have to completely duplicate vertex data even if just one attribute varies.
An index buffer is bound with vkCmdBindIndexBuffer which has the index buffer, a byte offset into it,
and the type of index data as parameters. As mentioned before, the possible types are
VK_INDEX_TYPE_UINT16 and VK_INDEX_TYPE_UINT32.
Just binding an index buffer doesn’t change anything yet, we also need to change the drawing
command to tell Vulkan to use the index buffer. Remove the vkCmdDraw line and replace it with
vkCmdDrawIndexed:
commandBuffers[frameIndex].drawIndexed([Link](), 1, 0, 0, 0);
A call to this function is very similar to vkCmdDraw. The first two parameters specify the number of
indices and the number of instances. We’re not using instancing, so just specify 1 instance. The
number of indices represents the number of vertices that will be passed to the vertex shader. The
next parameter specifies an offset into the index buffer, using a value of 1 would cause the graphics
card to start reading at the second index. The second to last parameter specifies an offset to add to
the vertex index before indexing into the vertex buffer. The final parameter specifies an offset for
instancing, which we’re not using.
Now run your program, and you should see the following:
You now know how to save memory by reusing vertices with index buffers. This will become
especially important in a future chapter where we’re going to load complex 3D models.
The previous chapter already mentioned that you should allocate multiple resources like buffers
from a single memory allocation, but in fact you should go a step further. Driver developers
recommend that you also store multiple buffers, like the vertex and index buffer, into a single
VkBuffer and use offsets in commands like vkCmdBindVertexBuffers. The advantage is that your data
is more cache friendly in that case, because it’s closer together. It is even possible to reuse the same
chunk of memory for multiple resources if they are not used during the same render operations,
provided that their data is refreshed, of course. This is known as aliasing and some Vulkan
functions have explicit flags to specify that you want to do this.
The next chapter we’ll learn how to pass frequently changing parameters to the GPU.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
130
Descriptor layout and buffer
Introduction
We’re now able to pass arbitrary attributes to the vertex shader for each vertex, but what about
global variables? We’re going to move on to 3D graphics from this chapter on, and that requires a
model-view-projection matrix. We could include it as vertex data, but that’s a waste of memory, and
it would require us to update the vertex buffer whenever the transformation changes. The
transformation could easily change every single frame.
The right way to tackle this in Vulkan is to use resource descriptors. A descriptor is a way for
shaders to freely access resources like buffers and images. We’re going to set up a buffer that
contains the transformation matrices and have the vertex shader access them through a descriptor.
Usage of descriptors consists of three parts:
The descriptor set layout specifies the types of resources that are going to be accessed by the
pipeline, just like a render pass specifies the types of attachments that will be accessed. A descriptor
set specifies the actual buffer or image resources that will be bound to the descriptors, just like a
framebuffer specifies the actual image views to bind to render pass attachments. The descriptor set
is then bound for the drawing commands just like the vertex buffers and framebuffer.
There are many types of descriptors, but in this chapter we’ll work with uniform buffer objects
(UBO). We’ll look at other types of descriptors in future chapters, but the basic process is the same.
Let’s say we have the data we want the vertex shader to have in a C struct like this:
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
Then we can copy the data to a VkBuffer and access it through a uniform buffer object descriptor
from the vertex shader like this:
struct VSInput {
float2 inPosition;
float3 inColor;
};
struct UniformBuffer {
float4x4 model;
131
float4x4 view;
float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;
struct VSOutput
{
float4 pos : SV_Position;
float3 color;
};
[shader("vertex")]
VSOutput vertMain(VSInput input) {
VSOutput output;
[Link] = mul([Link], mul([Link], mul([Link], float4([Link],
0.0, 1.0))));
[Link] = [Link];
return output;
}
We’re going to update the model, view and projection matrices every frame to make the rectangle
from the previous chapter spin around in 3D.
Vertex shader
Modify the vertex shader to include the uniform buffer object like it was specified above. I will
assume that you are familiar with MVP transformations. If you’re not, see the resource mentioned
in the first chapter.
struct VSInput {
float2 inPosition;
float3 inColor;
};
struct UniformBuffer {
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;
struct VSOutput
{
float4 pos : SV_Position;
float3 color;
};
[shader("vertex")]
VSOutput vertMain(VSInput input) {
132
VSOutput output;
[Link] = mul([Link], mul([Link], mul([Link], float4([Link],
0.0, 1.0))));
[Link] = [Link];
return output;
}
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return float4([Link], 1.0);
}
The line with SV_Position is changed to use the transformations to compute the final position in clip
coordinates. Unlike the 2D triangles, the last component of the clip coordinates may not be 1, which
will result in a division when converted to the final normalized device coordinates on the screen.
This is used in perspective projection as the perspective division and is essential for making closer
objects look larger than objects that are further away.
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
We can exactly match the definition in the shader using data types in GLM. The data in the matrices
is binary compatible with the way the shader expects it, so we can later just memcpy a
UniformBufferObject to a VkBuffer.
We need to provide details about every descriptor binding used in the shaders for pipeline creation,
just like we had to do for every vertex attribute and its location index. We’ll set up a new function
to define all of this information called createDescriptorSetLayout. It should be called right before
pipeline creation, because we’re going to need it there.
void initVulkan() {
...
createDescriptorSetLayout();
createGraphicsPipeline();
...
}
...
133
void createDescriptorSetLayout() {
void createDescriptorSetLayout() {
vk::DescriptorSetLayoutBinding uboLayoutBinding(0,
vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex, nullptr);
}
The first two fields specify the binding used in the shader and the type of descriptor, which is a
uniform buffer object. It is possible for the shader variable to represent an array of uniform buffer
objects, and descriptorCount specifies the number of values in the array. This could be used to
specify a transformation for each of the bones in a skeleton for skeletal animation, for example.
Our MVP transformation is in a single uniform buffer object, so we’re using a descriptorCount of 1.
We also need to specify in which shader stages the descriptor is going to be referenced. The
stageFlags field can be a combination of VkShaderStageFlagBits values or the value
VK_SHADER_STAGE_ALL_GRAPHICS. In our case, we’re only referencing the descriptor from the vertex
shader.
The pImmutableSamplers field is only relevant for image sampling related descriptors, which we’ll
look at later. You can leave this to its default value.
All the descriptor bindings are combined into a single VkDescriptorSetLayout object. Define a new
class member above pipelineLayout:
We need to specify the descriptor set layout during pipeline creation to tell Vulkan which
descriptors the shaders will be using. Descriptor set layouts are specified in the pipeline layout
object. Modify the VkPipelineLayoutCreateInfo to reference the layout object:
134
&*descriptorSetLayout, .pushConstantRangeCount = 0 };
You may be wondering why it’s possible to specify multiple descriptor set layouts here, because a
single one already includes all of the bindings. We’ll get back to that in the next chapter, where
we’ll look into descriptor pools and descriptor sets.
Uniform buffer
In the next chapter we’ll specify the buffer that contains the UBO data for the shader, but we need
to create this buffer first. We’re going to copy new data to the uniform buffer every frame, so it
doesn’t really make any sense to have a staging buffer. It would just add extra overhead in this case
and likely degrade performance instead of improving it.
We should have multiple buffers, because multiple frames may be in flight at the same time and we
don’t want to update the buffer in preparation of the next frame while a previous one is still
reading from it! Thus, we need to have as many uniform buffers as we have frames in flight, and
write to a uniform buffer that is not currently being read by the GPU.
To that end, add new class members for uniformBuffers, and uniformBuffersMemory:
std::vector<vk::raii::Buffer> uniformBuffers;
std::vector<vk::raii::DeviceMemory> uniformBuffersMemory;
std::vector<void*> uniformBuffersMapped;
Similarly, create a new function createUniformBuffers that is called after createIndexBuffer and
allocates the buffers:
void initVulkan() {
...
createVertexBuffer();
createIndexBuffer();
createUniformBuffers();
...
}
...
void createUniformBuffers() {
[Link]();
[Link]();
[Link]();
135
vk::raii::Buffer buffer({});
vk::raii::DeviceMemory bufferMem({});
createBuffer(bufferSize, vk::BufferUsageFlagBits::eUniformBuffer,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
buffer, bufferMem);
uniformBuffers.emplace_back(std::move(buffer));
uniformBuffersMemory.emplace_back(std::move(bufferMem));
uniformBuffersMapped.emplace_back( uniformBuffersMemory[i].mapMemory(0,
bufferSize));
}
}
We map the buffer right after creation using vkMapMemory to get a pointer to which we can write the
data later on. The buffer stays mapped to this pointer for the application’s whole lifetime. This
technique is called "persistent mapping" and works on all Vulkan implementations. Not having to
map the buffer every time we need to update it increases performances, as mapping is not free.
void drawFrame() {
...
updateUniformBuffer(frameIndex);
...
const vk::SubmitInfo
submitInfo{.waitSemaphoreCount = 1,
.pWaitSemaphores =
&*presentCompleteSemaphores[frameIndex],
.pWaitDstStageMask =
&waitDestinationStageMask,
.commandBufferCount = 1,
.pCommandBuffers =
&*commandBuffers[frameIndex],
.signalSemaphoreCount = 1,
.pSignalSemaphores =
&*renderFinishedSemaphores[imageIndex]};
...
}
...
136
}
This function will generate a new transformation every frame to make the geometry spin around.
We need to include two new headers to implement this functionality:
#include <glm/[Link]>
#include <glm/gtc/matrix_transform.hpp>
#include <chrono>
The glm/gtc/matrix_transform.hpp header exposes functions that can be used to generate model
transformations like glm::rotate, view transformations like glm::lookAt and projection
transformations like glm::perspective.
The chrono standard library header exposes functions to do precise timekeeping. We’ll use this to
make sure that the geometry rotates 90 degrees per second regardless of frame rate.
The updateUniformBuffer function will start out with some logic to calculate the time in seconds
since rendering has started with floating point accuracy.
We will now define the model, view and projection transformations in the uniform buffer object.
The model rotation will be a simple rotation around the Z-axis using the time variable:
UniformBufferObject ubo{};
[Link] = rotate(glm::mat4(1.0f), time * glm::radians(90.0f), glm::vec3(0.0f, 0.0f,
1.0f));
The glm::rotate function takes an existing transformation, rotation angle and rotation axis as
parameters. The glm::mat4(1.0f) constructor returns an identity matrix. Using a rotation angle of
time * glm::radians(90.0f) accomplishes the purpose of rotation 90 degrees per second.
For the view transformation I’ve decided to look at the geometry from above at a 45 degree angle.
The glm::lookAt function takes the eye position, center position and up axis as parameters.
137
[Link] = glm::perspective(glm::radians(45.0f),
static_cast<float>([Link]) /
static_cast<float>([Link]), 0.1f, 10.0f);
I’ve chosen to use a perspective projection with a 45 degree vertical field-of-view. The other
parameters are the aspect ratio, near and far view planes. It is important to use the current swap
chain extent to calculate the aspect ratio to take into account the new width and height of the
window after a resize.
[Link][1][1] *= -1;
GLM was originally designed for OpenGL, where the Y coordinate of the clip coordinates is
inverted. The easiest way to compensate for that is to flip the sign on the scaling factor of the Y axis
in the projection matrix. If you don’t do this, then the image will be rendered upside down.
All of the transformations are defined now, so we can copy the data in the uniform buffer object to
the current uniform buffer. This happens in exactly the same way as we did for vertex buffers,
except without a staging buffer. As noted earlier, we only map the uniform buffer once, so we can
directly write to it without having to map again:
Using a UBO this way is not the most efficient way to pass frequently changing values to the shader.
A more efficient way to pass a small buffer of data to shaders is push constants. We may look at
these in a future chapter.
In the next chapter we’ll look at descriptor sets, which will actually bind the VkBuffers to the
uniform buffer descriptors so that the shader can access this transformation data.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Descriptor pool
Descriptor sets can’t be created directly, they must be allocated from a pool like command buffers.
The equivalent for descriptor sets is unsurprisingly called a descriptor pool. We’ll write a new
138
function createDescriptorPool to set it up.
void initVulkan() {
...
createUniformBuffers();
createDescriptorPool();
...
}
...
void createDescriptorPool() {
We first need to describe which descriptor types our descriptor sets are going to contain and how
many of them, using VkDescriptorPoolSize structures.
vk::DescriptorPoolSize poolSize(vk::DescriptorType::eUniformBuffer,
MAX_FRAMES_IN_FLIGHT);
We will allocate one of these descriptors for every frame. This pool size structure is referenced by
the main VkDescriptorPoolCreateInfo:
Aside from the maximum number of individual descriptors that are available, we also need to
specify the maximum number of descriptor sets that may be allocated:
The structure has an optional flag similar to command pools that determines if individual
descriptor sets can be freed or not: VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT. We’re not
going to touch the descriptor set after creating it, so we don’t need this flag. You can leave flags to
its default value of 0.
...
Add a new class member to store the handle of the descriptor pool and call vkCreateDescriptorPool
to create it.
139
Descriptor set
We can now allocate the descriptor sets themselves. Add a createDescriptorSets function for that
purpose:
void initVulkan() {
...
createDescriptorPool();
createDescriptorSets();
...
}
...
void createDescriptorSets() {
std::vector<vk::DescriptorSetLayout> layouts(MAX_FRAMES_IN_FLIGHT,
*descriptorSetLayout);
vk::DescriptorSetAllocateInfo allocInfo{ .descriptorPool = descriptorPool,
.descriptorSetCount = static_cast<uint32_t>([Link]()), .pSetLayouts =
[Link]() };
In our case, we will create one descriptor set for each frame in flight, all with the same layout.
Unfortunately, we do need all the copies of the layout because the next function expects an array
matching the number of sets.
Add a class member to hold the descriptor set handles and allocate them with
vkAllocateDescriptorSets:
...
[Link]();
descriptorSets = [Link](allocInfo);
The descriptor sets have been allocated now, but the descriptors within still need to be configured.
We’ll now add a loop to populate every descriptor:
140
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
Descriptors that refer to buffers, like our uniform buffer descriptor, are configured with a
VkDescriptorBufferInfo struct. This structure specifies the buffer and the region within it that
contains the data for the descriptor.
If you’re overwriting the whole buffer, like we are in this case, then it is also possible to use the
VK_WHOLE_SIZE value for the range. The configuration of descriptors is updated using the
vkUpdateDescriptorSets function, which takes an array of VkWriteDescriptorSet structs as
parameter.
The first two fields specify the descriptor set to update and the binding. We gave our uniform
buffer binding index 0. Remember that descriptors can be arrays, so we also need to specify the
first index in the array that we want to update. We’re not using an array, so the index is simply 0.
We need to specify the type of descriptor again. It’s possible to update multiple descriptors at once
in an array, starting at index dstArrayElement. The descriptorCount field specifies how many array
elements you want to update.
The last field references an array with descriptorCount structs that actually configure the
descriptors. It depends on the type of descriptor which one of the three you actually need to use.
The pBufferInfo field is used for descriptors that refer to buffer data, pImageInfo is used for
descriptors that refer to image data, and pTexelBufferView is used for descriptors that refer to buffer
views. Our descriptor is based on buffers, so we’re using pBufferInfo.
[Link](descriptorWrite, {});
The updates are applied using vkUpdateDescriptorSets. It accepts two kinds of arrays as parameters:
an array of VkWriteDescriptorSet and an array of VkCopyDescriptorSet. The latter can be used to
copy descriptors to each other, as its name implies.
141
Using descriptor sets
We now need to update the recordCommandBuffer function to actually bind the right descriptor set for
each frame to the descriptors in the shader with vkCmdBindDescriptorSets. This needs to be done
before the vkCmdDrawIndexed call:
commandBuffers[frameIndex].bindDescriptorSets(vk::PipelineBindPoint::eGraphics,
pipelineLayout, 0, *descriptorSets[frameIndex], nullptr);
commandBuffers[frameIndex].drawIndexed([Link](), 1, 0, 0, 0);
Unlike vertex and index buffers, descriptor sets are not unique to graphics pipelines. Therefore, we
need to specify if we want to bind descriptor sets to the graphics or compute pipeline. The next
parameter is the layout that the descriptors are based on. The next three parameters specify the
index of the first descriptor set, the number of sets to bind, and the array of sets to bind. We’ll get
back to this in a moment. The last two parameters specify an array of offsets that are used for
dynamic descriptors. We’ll look at these in a future chapter.
If you run your program now, then you’ll notice that unfortunately nothing is visible. The problem
is that because of the Y-flip we did in the projection matrix, the vertices are now being drawn in
counter-clockwise order instead of clockwise order. This causes backface culling to kick in and
prevents any geometry from being drawn. Go to the createGraphicsPipeline function and modify
the frontFace in VkPipelineRasterizationStateCreateInfo to correct this:
Run your program again, and you should now see the following:
The rectangle has changed into a square because the projection matrix now corrects for aspect
ratio. The updateUniformBuffer takes care of screen resizing, so we don’t need to recreate the
descriptor set in recreateSwapChain.
Alignment requirements
One thing we’ve glossed over so far is how exactly the data in the C++ structure should match with
the uniform definition in the shader. It seems obvious enough to simply use the same types in both:
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
142
struct UniformBuffer {
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;
However, that’s not all there is to it. For example, try modifying the struct and shader to look like
this:
struct UniformBufferObject {
glm::vec2 foo;
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
struct UniformBuffer {
float2 foo;
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;
Recompile your shader and your program and run it, and you’ll find that the colorful square you
worked so far has disappeared! That’s because we haven’t taken into account the alignment
requirements.
Vulkan expects the data in your structure to be aligned in memory in a specific way, for example:
• A nested structure must be aligned by the base alignment of its members rounded up to a
multiple of 16.
You can find the full list of alignment requirements in the specification.
Our original shader with just three mat4 fields already met the alignment requirements. As each
mat4 is 4 x 4 x 4 = 64 bytes in size, model has an offset of 0, view has an offset of 64 and proj has an
offset of 128. All of these are multiples of 16 and that’s why it worked fine.
The new structure starts with a vec2 which is only 8 bytes in size and therefore throws off all of the
offsets. Now model has an offset of 8, view an offset of 72 and proj an offset of 136, none of which are
143
multiples of 16. To fix this problem we can use the alignas specifier introduced in C++11:
struct UniformBufferObject {
glm::vec2 foo;
alignas(16) glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
If you now compile and run your program again, you should see that the shader correctly receives
its matrix values once again.
Luckily there is a way to not have to think about these alignment requirements most of the time.
We can define GLM_FORCE_DEFAULT_ALIGNED_GENTYPES right before including GLM:
#define GLM_FORCE_DEFAULT_ALIGNED_GENTYPES
#include <glm/[Link]>
This will force GLM to use a version of vec2 and mat4 that has the alignment requirements already
specified for us. If you add this definition then you can remove the alignas specifier and your
program should still work.
Unfortunately, this method can break down if you start using nested structures. Consider the
following definition in the C++ code:
struct Foo {
glm::vec2 v;
};
struct UniformBufferObject {
Foo f1;
Foo f2;
};
struct Foo {
vec2 v;
};
struct UniformBuffer {
Foo f1;
Foo f2;
};
ConstantBuffer<UniformBuffer> ubo;
144
In this case f2 will have an offset of 8 whereas it should have an offset of 16 since it is a nested
structure. In this case, you must specify the alignment yourself:
struct UniformBufferObject {
Foo f1;
alignas(16) Foo f2;
};
These gotchas are a good reason to always be explicit about alignment. That way you won’t be
caught off guard by the strange symptoms of alignment errors.
struct UniformBufferObject {
alignas(16) glm::mat4 model;
alignas(16) glm::mat4 view;
alignas(16) glm::mat4 proj;
};
struct UniformBuffer {
};
ConstantBuffer<UniformBuffer> ubo;
You can use this feature to put descriptors that vary per-object and descriptors that are shared into
separate descriptor sets. In that case, you avoid rebinding most of the descriptors across draw calls
which are potentially more efficient.
In the next chapters we’ll build upon what we just learned and add textures to our scene.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Images
Introduction
The geometry has been colored using per-vertex colors so far, which is a rather limited approach.
In this part of the tutorial, we’re going to implement texture mapping to make the geometry look
145
more interesting. This will also allow us to load and draw basic 3D models in a future chapter.
• Add a combined image sampler descriptor to sample colors from the texture
We’ve already worked with image objects before, but those were automatically created by the swap
chain extension. This time we’ll have to create one by ourselves. Creating an image and filling it
with data is similar to vertex buffer creation but with some added complexity due to how GPUs
handle images. We’ll start by creating a staging resource and filling it with pixel data and then we
copy this to the final image object that we’ll use for rendering. Although it is possible to create a
staging image for this purpose, Vulkan also allows you to directly copy pixels from a buffer to an
image. That’s less verbose, less limited and usually faster. For that we’ll first create such a buffer
that is accessible by the host and fill it with pixel values, and then we’ll create an image to copy the
pixels to. Creating an image is not very different from creating buffers. It involves querying the
memory requirements, allocating device memory and binding it, just like we’ve seen before.
However, there is something extra that we’ll have to take care of when working with images.
Images can have different layouts that affect how the pixels are organized in memory. Due to the
way graphics hardware works, simply storing the pixels row by row may not lead to the best
performance, for example. When performing any operation on images, you must make sure that
they have the layout that is optimal for use in that operation. We’ve actually already seen some of
these layouts when we specified the render pass:
One of the most common ways to transition the layout of an image is a pipeline barrier. Pipeline
barriers are primarily used for synchronizing access to resources, like making sure that an image
was written to before it is read, but they can also be used to transition layouts. In this chapter we’ll
see how pipeline barriers are used for this purpose. Barriers can additionally be used to transfer
queue family ownership when using vk::SharingMode::eExclusive.
Loading an image
Include the image library like this:
146
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
The header only defines the prototypes of the functions by default. One code file needs to include
the header with the STB_IMAGE_IMPLEMENTATION definition to include the function bodies, otherwise
we’ll get linking errors.
void initVulkan() {
...
createCommandPool();
createTextureImage();
createVertexBuffer();
...
}
...
void createTextureImage() {
Create a new function createTextureImage where we’ll load an image and upload it into a Vulkan
image object. We’re going to use command buffers, so it should be called after createCommandPool.
Create a new directory textures next to the shaders directory to store texture images in. We’re going
to load an image called [Link] from that directory. I’ve chosen to use the following CC0
licensed image resized to 512 x 512 pixels, but feel free to pick any image you want. The library
supports most common image file formats, like JPEG, PNG, BMP and GIF.
[texture] | /images/[Link]
void createTextureImage() {
int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load("textures/[Link]", &texWidth, &texHeight,
&texChannels, STBI_rgb_alpha);
vk::DeviceSize imageSize = texWidth * texHeight * 4;
if (!pixels) {
throw std::runtime_error("failed to load texture image!");
}
}
The stbi_load function takes the file path and number of channels to load as arguments. The
STBI_rgb_alpha value forces the image to be loaded with an alpha channel, even if it doesn’t have
one, which is nice for consistency with other textures in the future. The middle three parameters
147
are outputs for the width, height and actual number of channels in the image. The pointer that is
returned is the first element in an array of pixel values. The pixels are laid out row by row with 4
bytes per pixel in the case of STBI_rgb_alpha for a total of texWidth * texHeight * 4 values.
Staging buffer
Next we need to upload the image to the GPU for optimal access during shader reads. For that we’re
going to create a buffer in host visible memory that we can map to copy the pixels to. This buffer
will be the source for copying that data to the GPU.
Images should always reside in GPU memory. Leaving them in host only visible
memory would require the GPU to read data via the PCI interface for each frame,
which has a much smaller bandwidth than the GPU’s memory. That would cause a
NOTE
big performance impact. Staging is the process of getting image data into the GPU’s
memory. It is not always required, as devices may offer a memory type that’s both
host visible and device local. It’s possible to skip staging on such configurations.
vk::raii::Buffer stagingBuffer({});
vk::raii::DeviceMemory stagingBufferMemory({});
The buffer should be in host visible memory (eHostVisible) so that we can map it. It should also be
host coherent (eHostCoherent), to ensure the data written to it is immediately available (and not
cached in some way). And it should be usable as a transfer source ('eTransferSrc') so that we can
copy it to an image later on:
createBuffer(imageSize, vk::BufferUsageFlagBits::eTransferSrc,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
stagingBuffer, stagingBufferMemory);
We can then directly copy the pixel values that we got from the image loading library to the buffer:
stbi_image_free(pixels);
148
Texture Image
Although we could set up the shader to access the pixel values in the buffer, it’s better to use image
objects in Vulkan for this purpose. Image objects will make it easier and faster to retrieve colors by
allowing us to use 2D coordinates, for one. Pixels within an image object are known as texels, and
we’ll use that name from this point on. Add the following new class members:
The image type, specified in the imageType field, tells Vulkan with what kind of coordinate system
the texels in the image are going to be addressed. It is possible to create 1D, 2D and 3D images. One
dimensional images can be used to store an array of data or gradient, two dimensional images are
mainly used for textures, and three dimensional images can be used to store voxel volumes, for
example. The extent field specifies the dimensions of the image, basically how many texels there
are on each axis. That’s why depth must be 1 instead of 0. Our texture will not be an array and we
won’t be using mipmapping for now.
Vulkan supports many possible image formats, but we should use the same format for the texels as
the pixels in the buffer, otherwise the copy operation will fail.
The tiling file specifies how texels are arranged in memory. Vulkan supports two fundamentally
different modes for this:
• vk::ImageTiling::eLinear: Texels are laid out in memory in row-major order, possibly with
some padding on each row.
Linear tiled images are very limited. They e.g. may only work for 2D images, can’t
be used for depth/stencil, can’t have multiple mip levels or layers. GPU access is also
NOTE
much slower than for optimal tiled images. So there are very little use cases for
them.
Thus, we will be using vk::ImageTiling::eOptimal for efficient access from the shader.
There are only two possible values for the initialLayout of an image:
• vk::ImageLayout::eUndefined: Not usable by the GPU and the very first transition will discard the
149
texels.
• vk::ImageLayout::ePreinitialized: Not usable by the GPU, but the first transition will preserve
the texels.
There are very few situations where it is necessary for the texels to be preserved during the first
transition. One example, however, would be if you wanted to use an linear tiled image as a staging
image. In that case, you’d want to upload the texel data to it and then transition the image to be a
transfer source without losing the data.
In our case, however, we’re first going to transition the image to be a transfer destination and then
copy texel data to it from a buffer object, so we don’t need this property and can safely use
vk::ImageLayout::eUndefined.
The usage field has the same semantics as the one during buffer creation. The image is going to be
used as destination for the buffer copy, so it should be set up as a transfer destination. We also want
to be able to access the image from the shader to color our mesh, so the usage should include
vk::ImageUsageFlagBits::eSampled.
The image will only be used by one queue family: the one that supports graphics (and therefore
also) transfer operations.
The samples flag is related to multisampling. This is only relevant for images that will be used as
attachments, so stick to one sample. There are some optional flags for images that are related to
sparse images. Sparse images are images where only certain regions are actually backed by
memory. If you were using a 3D texture for a voxel terrain, for example, then you could use this to
avoid allocating memory to store large volumes of "air" values. We won’t be using it in this tutorial,
so leave it to its default value of 0.
The image is created using the vk::raii::Image constructor, which doesn’t have any particularly
noteworthy parameters. It is possible that the vk::Format::eR8G8B8A8Srgb, format is not supported
by the graphics hardware. You should have a list of acceptable alternatives and go with the best one
that is supported. However, support for this particular format is so widespread that we’ll skip this
step. Using different formats would also require annoying conversions. We will get back to this in
the depth buffer chapter, where we’ll implement such a system.
Allocating memory for an image works in exactly the same way as allocating memory for a buffer.
Use the default vk::raii::DeviceMemory constructor, and use bindMemory on the image.
150
This function is already getting quite large and there’ll be a need to create more images in later
chapters, so we should abstract image creation into a createImage function, like we did for buffers.
Create the function and move the image object creation and memory allocation to it:
I’ve made the width, height, format, tiling mode, usage, and memory properties parameters,
because these will all vary between the images we’ll be creating throughout this tutorial.
void createTextureImage() {
int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load("textures/[Link]", &texWidth, &texHeight,
&texChannels, STBI_rgb_alpha);
vk::DeviceSize imageSize = texWidth * texHeight * 4;
if (!pixels) {
throw std::runtime_error("failed to load texture image!");
}
vk::raii::Buffer stagingBuffer({});
vk::raii::DeviceMemory stagingBufferMemory({});
createBuffer(imageSize, vk::BufferUsageFlagBits::eTransferSrc,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
stagingBuffer, stagingBufferMemory);
stbi_image_free(pixels);
151
vk::raii::Image textureImageTemp({});
vk::raii::DeviceMemory textureImageMemoryTemp({});
createImage(texWidth, texHeight, vk::Format::eR8G8B8A8Srgb,
vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransferDst |
vk::ImageUsageFlagBits::eSampled, vk::MemoryPropertyFlagBits::eDeviceLocal,
textureImageTemp, textureImageMemoryTemp);
}
Layout transitions
As mentioned earlier, images in Vulkan can exist in different layouts that affect how the pixel data
is organized in memory. These layouts are optimized for specific operations - some layouts are
better for reading from shaders, others for being render targets, and yet others for being the source
or destination of transfer operations.
Layout transitions are a crucial aspect of Vulkan’s design that gives you explicit control over these
memory organizations. Unlike in some other graphics APIs where the driver automatically handles
these transitions, Vulkan requires you to manage them explicitly. This approach allows for better
performance optimization as you can schedule transitions exactly when needed and batch
operations efficiently.
For our texture image, we’ll need to perform several transitions: 1. From the initial undefined
layout to a layout optimized for receiving data (transfer destination) 2. From transfer destination to
a layout optimized for shader reading, so our fragment shader can sample from it
These transitions are performed using pipeline barriers, which not only change the image layout
but also ensure proper synchronization between operations that access the image. Without proper
synchronization, we might end up with race conditions where the shader tries to read from the
texture before the copy operation has completed.
The function we’re going to write now involves recording and executing a command buffer again,
so now’s a good time to move that logic into a helper function or two:
vk::raii::CommandBuffer beginSingleTimeCommands() {
vk::CommandBufferAllocateInfo allocInfo{ .commandPool = commandPool, .level =
vk::CommandBufferLevel::ePrimary, .commandBufferCount = 1 };
vk::raii::CommandBuffer commandBuffer =
std::move([Link](allocInfo).front());
return commandBuffer;
}
152
vk::SubmitInfo submitInfo{ .commandBufferCount = 1, .pCommandBuffers =
&*commandBuffer };
[Link](submitInfo, nullptr);
[Link]();
}
The code for these functions is based on the existing code in copyBuffer. You can now simplify that
function to:
If we were still using buffers, then we could now write a function to record and execute
copyBufferToImage to finish the job, but this command requires the image to be in the right layout
first. Create a new function to handle layout transitions:
endSingleTimeCommands(commandBuffer);
}
One of the most common ways to perform layout transitions is using an image memory barrier. A
pipeline barrier like that is generally used to synchronize access to resources, like ensuring that a
write to a buffer completes before reading from it, but it can also be used to transition image
layouts and transfer queue family ownership when vk::SharingMode::eExclusive is used. There is
an equivalent buffer memory barrier to do this for buffers.
oldLayout and newLayout specify the the layout transition. It is possible to use
vk::ImageLayout::eUndefined as oldLayout if you don’t care about the existing contents of the image.
If you are using the barrier to transfer queue family ownership, then oldLayout and newLayout fields
should be the indices of the queue families. They must be set to VK_QUEUE_FAMILY_IGNORED if you don’t
want to do this (not the default value!).
The image and subresourceRange specify the image that is affected and the specific part of the image.
Our image is not an array and does not have mipmapping levels, so only one level and layer are
153
specified.
Barriers are primarily used for synchronization purposes, so you must specify which types of
operations that involve the resource must happen before the barrier, and which operations that
involve the resource must wait on the barrier. We need to do that despite already using
[Link]() to manually synchronize. The right values depend on the old and new layout, so
we’ll get back to this once we’ve figured out which transitions we’re going to use.
All types of pipeline barriers are submitted using the same function. The first parameter after the
command buffer specifies in which pipeline stage the operations occur that should happen before
the barrier. The second parameter specifies the pipeline stage in which operations will wait on the
barrier. The pipeline stages that you are allowed to specify before and after the barrier depend on
how you use the resource before and after the barrier. The allowed values are listed in this table of
the specification. For example, if you’re going to read from a uniform after the barrier, you would
specify a usage of vk::AccessFlagBits::eUniformRead and the earliest shader that will read from the
uniform as pipeline stage, for example vk::PipelineStageFlagBits::eFragmentShader. It would not
make sense to specify a non-shader pipeline stage for this type of usage and the validation layers
will warn you when you specify a pipeline stage that does not match the type of usage.
The third parameter is either 0 or vk::DependencyFlagBits::eByRegion. The latter turns the barrier
into a per-region condition. That means that the implementation is allowed to already begin
reading from the parts of a resource that were written so far, for example.
The last three pairs of parameter reference arrays of pipeline barriers of the three available types:
memory barriers, buffer memory barriers, and image memory barriers like the one we’re using
here. Note that we’re not using the VkFormat parameter yet, but we’ll be using that one for special
transitions in the depth buffer chapter.
endSingleTimeCommands(commandBuffer);
}
Just like with buffer copies, you need to specify which part of the buffer is going to be copied to
which part of the image. This happens through vk::BufferImageCopy structs:
154
vk::BufferImageCopy region{ .bufferOffset = 0, .bufferRowLength = 0,
.bufferImageHeight = 0,
.imageSubresource = { vk::ImageAspectFlagBits::eColor, 0, 0, 1 }, .imageOffset =
{0, 0, 0}, .imageExtent = {width, height, 1} };
Most of these fields are self-explanatory. The bufferOffset specifies the byte offset in the buffer at
which the pixel values start. The bufferRowLength and bufferImageHeight fields specify how the
pixels are laid out in memory. For example, you could have some padding bytes between rows of
the image. Specifying 0 for both indicates that the pixels are simply tightly packed like they are in
our case. The imageSubresource, imageOffset and imageExtent fields indicate to which part of the
image we want to copy the pixels.
Buffer to image copy operations are enqueued using the vkCmdCopyBufferToImage function:
The fourth parameter indicates which layout the image is currently using. I’m assuming here that
the image has already been transitioned to the layout that is optimal for copying pixels to. Right
now we’re only copying one chunk of pixels to the whole image, but it’s possible to specify an array
of vk::BufferImageCopy to perform many different copies from this buffer to the image in one
operation.
transitionImageLayout(textureImage, vk::ImageLayout::eUndefined,
vk::ImageLayout::eTransferDstOptimal);
copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth),
static_cast<uint32_t>(texHeight));
The image was created with the vk::ImageLayout::eUndefined layout, so that one should be specified
as old layout when transitioning textureImage. Remember that we can do this because we don’t care
about its contents before performing the copy operation.
155
To be able to start sampling from the texture image in the shader, we need one last transition to
prepare it for shader access:
transitionImageLayout(textureImage, vk::ImageLayout::eTransferDstOptimal,
vk::ImageLayout::eShaderReadOnlyOptimal);
• Undefined → transfer destination: transfer writes that don’t need to wait on anything
• Transfer destination → shader reading: shader reads should wait on transfer writes, specifically
the shader reads in the fragment shader, because that’s where we’re going to use the texture
These rules are specified using the following access masks and pipeline stages:
vk::PipelineStageFlags sourceStage;
vk::PipelineStageFlags destinationStage;
sourceStage = vk::PipelineStageFlagBits::eTopOfPipe;
destinationStage = vk::PipelineStageFlagBits::eTransfer;
} else if (oldLayout == vk::ImageLayout::eTransferDstOptimal && newLayout ==
vk::ImageLayout::eShaderReadOnlyOptimal) {
[Link] = vk::AccessFlagBits::eTransferWrite;
[Link] = vk::AccessFlagBits::eShaderRead;
sourceStage = vk::PipelineStageFlagBits::eTransfer;
destinationStage = vk::PipelineStageFlagBits::eFragmentShader;
} else {
throw std::invalid_argument("unsupported layout transition!");
}
As you can see in the aforementioned table, transfer writes must occur in the pipeline transfer
stage. Since the writings don’t have to wait on anything, you may specify an empty access mask and
156
the earliest possible pipeline stage vk::PipelineStageFlagBits::eTopOfPipe for the pre-barrier
operations. It should be noted that vk::PipelineStageFlagBits::eTransfer is not a real stage within
the graphics and compute pipelines. It is more of a pseudo-stage where transfers happen. See the
documentation for more information and other examples of pseudo-stages.
The image will be written in the same pipeline stage and subsequently read by the fragment
shader, which is why we specify shader reading access in the fragment shader pipeline stage.
If we need to do more transitions in the future, then we’ll extend the function. The application
should now run successfully, although there are of course no visual changes yet.
There is actually a special type of image layout that supports all operations,
vk::ImageLayout::eGeneral. But unless using certain extensions, which we don’t do in the tutorial,
using the general layout might come with a performance penalty as it may disable certain
optimizations on some GPUs. It is required for some special cases, like using an image as both input
and output, or for reading an image after it has left the preinitialized layout.
All the helper functions that submit commands so far have been set up to execute synchronously by
waiting for the queue to become idle. For practical applications it is recommended to combine these
operations in a single command buffer and execute them asynchronously for higher throughput,
especially the transitions and copy in the createTextureImage function. Try to experiment with this
by creating a setupCommandBuffer that the helper functions record commands into, and add a
flushSetupCommands to execute the commands that have been recorded so far. It’s best to do this
after the texture mapping works to check if the texture resources are still set up correctly.
The image now contains the texture, but we still need a way to access it from the graphics pipeline.
We’ll work on that in the next chapter.
C++ code / slang shader / GLSL Vertex shader / GLSL Frag shader :pp: ++
157
texture image.
Add a class member to hold a VkImageView for the texture image and create a new function
createTextureImageView where we’ll create it:
...
void initVulkan() {
...
createTextureImage();
createTextureImageView();
createVertexBuffer();
...
}
...
void createTextureImageView() {
The code for this function can be based directly on createImageViews. The only two changes you
have to make are the format and the image:
Because so much of the logic is duplicated from createImageViews, you may wish to abstract it into a
new createImageView function:
158
The createTextureImageView function can now be simplified to:
void createTextureImageView() {
textureImageView = createImageView(textureImage, vk::Format::eR8G8B8A8Srgb);
}
void createImageViews() {
[Link]([Link]());
Samplers
It is possible for shaders to read texels directly from images, but that is not very common when
they are used as textures. Textures are usually accessed through samplers, which will apply
filtering and transformations to compute the final color that is retrieved.
These filters are helpful to deal with problems like oversampling. Consider a texture mapped to
geometry with more fragments than texels. If you simply took the closest texel for the texture
coordinate in each fragment, then you would get a result like the first image:
If you combined the 4 closest texels through linear interpolation, then you would get a smoother
result like the one on the right. Of course your application may have art style requirements that fit
the left style more (think Minecraft), but the right is preferred in conventional graphics
applications. A sampler object automatically applies this filtering for you when reading a color
from the texture.
Undersampling is the opposite problem, where you have more texels than fragments. This will lead
to artifacts when sampling high frequency patterns like a checkerboard texture at a sharp angle:
As shown in the left image, the texture turns into a blurry mess in the distance. The solution to this
is anisotropic filtering, which can also be applied automatically by a sampler.
Aside from these filters, a sampler can also take care of transformations. It determines what
happens when you try to read texels outside the image through its addressing mode. The image
below displays some possibilities:
159
[texture addressing] | /images/texture_addressing.png
We will now create a function createTextureSampler to set up such a sampler object. We’ll be using
that sampler to read colors from the texture in the shader later on.
void initVulkan() {
...
createTextureImage();
createTextureImageView();
createTextureSampler();
...
}
...
void createTextureSampler() {
Samplers are configured through a VkSamplerCreateInfo structure, which specifies all filters and
transformations that it should apply.
The magFilter and minFilter fields specify how to interpolate texels that are magnified or minified.
Magnification concerns the oversampling problem describes above, and minification concerns
undersampling. The choices are VK_FILTER_NEAREST and VK_FILTER_LINEAR, corresponding to the
modes demonstrated in the images above.
The addressing mode can be specified per axis using the addressMode fields. The available values are
listed below. Most of these are demonstrated in the image above. Note that the axes are called U, V
and W instead of X, Y and Z. This is a convention for texture space coordinates.
• VK_SAMPLER_ADDRESS_MODE_REPEAT: Repeat the texture when going beyond the image dimensions.
160
opposite to the closest edge.
It doesn’t really matter which addressing mode we use here, because we’re not going to sample
outside of the image in this tutorial. However, the repeat mode is probably the most common mode,
because it can be used to tile textures like floors and walls.
The anisotropyEnable field specifies if anisotropic filtering should be used. There is no reason not to
use this unless performance is a concern. The maxAnisotropy field limits the number of texel
samples that can be used to calculate the final color. A lower value results in better performance,
but lower quality results. To figure out which value we can use, we need to retrieve the properties
of the physical device like so:
If you look at the documentation for the VkPhysicalDeviceProperties structure, you’ll see that it
contains a VkPhysicalDeviceLimits member named limits. This struct in turn has a member called
maxSamplerAnisotropy and this is the maximum value we can specify for maxAnisotropy. If we want to
go for maximum quality, we can simply use that value directly:
You can either query the properties at the beginning of your program and pass them around to the
functions that need them, or query them in the createTextureSampler function itself.
[Link] = vk::BorderColor::eIntOpaqueBlack;
The borderColor field specifies which color is returned when sampling beyond the image with
clamp to border addressing mode. It is possible to return black, white or transparent in either float
161
or int formats. You cannot specify an arbitrary color.
[Link] = vk::False;
The unnormalizedCoordinates field specifies which coordinate system you want to use to address
texels in an image. If this field is VK_TRUE, then you can simply use coordinates within the [0,
texWidth) and [0, texHeight) range. If it is VK_FALSE, then the texels are addressed using the [0, 1)
range on all axes. Real-world applications almost always use normalized coordinates, because then
it’s possible to use textures of varying resolutions with the exact same coordinates.
[Link] = vk::False;
[Link] = vk::CompareOp::eAlways;
If a comparison function is enabled, then texels will first be compared to a value, and the result of
that comparison is used in filtering operations. This is mainly used for percentage-closer filtering
on shadow maps. We’ll look at this in a future chapter.
[Link] = vk::SamplerMipmapMode::eLinear;
[Link] = 0.0f;
[Link] = 0.0f;
[Link] = 0.0f;
All of these fields apply to mipmapping. We will look at mipmapping in a later chapter, but
basically it’s another type of filter that can be applied.
The functioning of the sampler is now fully defined. Add a class member to hold the handle of the
sampler object and create the sampler with vkCreateSampler:
...
void createTextureSampler() {
...
Note the sampler does not reference a VkImage anywhere. The sampler is a distinct object that
provides an interface to extract colors from a texture. It can be applied to any image you want,
whether it is 1D, 2D or 3D. This is different from many older APIs, which combined texture images
and filtering into a single state.
162
Anisotropy device feature
If you run your program right now, you’ll see a validation layer message like this:
That’s because anisotropic filtering is actually an optional device feature. We need to update the
createLogicalDevice function to request it:
vk::StructureChain<vk::PhysicalDeviceFeatures2, vk::PhysicalDeviceVulkan13Features,
vk::PhysicalDeviceExtendedDynamicStateFeaturesEXT> featureChain = {
{.features = {.samplerAnisotropy = true } }, //
vk::PhysicalDeviceFeatures2
{.synchronization2 = true, .dynamicRendering = true }, //
vk::PhysicalDeviceVulkan13Features
{.extendedDynamicState = true } //
vk::PhysicalDeviceExtendedDynamicStateFeaturesEXT
};
And even though it is very unlikely that a modern graphics card will not support it, we should
update isDeviceSuitable to check if it is available:
Instead of enforcing the availability of anisotropic filtering, it’s also possible to simply not use it by
conditional setting:
[Link] = VK_FALSE;
[Link] = 1.0f;
In the next chapter we will expose the image and sampler objects to the shaders to draw the texture
onto the square.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
163
Combined image sampler
Introduction
We looked at descriptors for the first time in the uniform buffers part of the tutorial. In this
chapter, we will look at a new type of descriptor: combined image sampler. This descriptor makes it
possible for shaders to access an image resource through a sampler object like the one we created
in the previous chapter.
It’s worth noting that Vulkan provides flexibility in how textures are accessed in shaders through
different descriptor types. While we’ll be using a combined image sampler in this tutorial, Vulkan
also supports separate descriptors for samplers (VK_DESCRIPTOR_TYPE_SAMPLER) and sampled images
(VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE). Using separate descriptors allows you to reuse the same
sampler with multiple images or access the same image with different sampling parameters. This
can be more efficient in scenarios where you have many textures that use identical sampling
configurations. However, the combined image sampler is often more convenient and can offer
better performance on some hardware due to optimized cache usage.
We’ll start by modifying the descriptor set layout, descriptor pool and descriptor set to include such
a combined image sampler descriptor. After that, we’re going to add texture coordinates to Vertex
and modify the fragment shader to read colors from the texture instead of just interpolating the
vertex colors.
std::array bindings = {
vk::DescriptorSetLayoutBinding( 0, vk::DescriptorType::eUniformBuffer, 1,
vk::ShaderStageFlagBits::eVertex, nullptr),
vk::DescriptorSetLayoutBinding( 1, vk::DescriptorType::eCombinedImageSampler, 1,
vk::ShaderStageFlagBits::eFragment, nullptr)
};
Make sure to set the stageFlags to indicate that we intend to use the combined image sampler
descriptor in the fragment shader. That’s where the color of the fragment is going to be determined.
It is possible to use texture sampling in the vertex shader, for example to dynamically deform a
grid of vertices by a heightmap.
We must also create a larger descriptor pool to make room for the allocation of the combined image
sampler by adding another VkPoolSize of type VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER to the
VkDescriptorPoolCreateInfo. Go to the createDescriptorPool function and modify it to include a
164
VkDescriptorPoolSize for this descriptor:
std::array poolSize {
vk::DescriptorPoolSize( vk::DescriptorType::eUniformBuffer, MAX_FRAMES_IN_FLIGHT),
vk::DescriptorPoolSize( vk::DescriptorType::eCombinedImageSampler,
MAX_FRAMES_IN_FLIGHT)
};
vk::DescriptorPoolCreateInfo
poolInfo(vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet, MAX_FRAMES_IN_FLIGHT,
poolSize);
Inadequate descriptor pools are a good example of a problem that the validation layers will not
catch: As of Vulkan 1.1, vkAllocateDescriptorSets may fail with the error code
VK_ERROR_POOL_OUT_OF_MEMORY if the pool is not sufficiently large, but the driver may also try to solve
the problem internally. This means that sometimes (depending on hardware, pool size and
allocation size) the driver will let us get away with an allocation that exceeds the limits of our
descriptor pool. Other times, vkAllocateDescriptorSets will fail and return
VK_ERROR_POOL_OUT_OF_MEMORY. This can be particularly frustrating if the allocation succeeds on some
machines, but fails on others.
Since Vulkan shifts the responsibility for the allocation to the driver, it is no longer a strict
requirement to only allocate as many descriptors of a certain type
(VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, etc.) as specified by the corresponding descriptorCount
members for the creation of the descriptor pool. However, it remains best practice to do so, and in
the future, VK_LAYER_KHRONOS_validation will warn about this type of problem if you enable Best
Practice Validation.
The final step is to bind the actual image and sampler resources to the descriptors in the descriptor
set. Go to the createDescriptorSets function.
...
}
The resources for a combined image sampler structure must be specified in a VkDescriptorImageInfo
struct, just like the buffer resource for a uniform buffer descriptor is specified in a
VkDescriptorBufferInfo struct. This is where the objects from the previous chapter come together.
std::array descriptorWrites{
vk::WriteDescriptorSet{ .dstSet = descriptorSets[i], .dstBinding = 0,
.dstArrayElement = 0, .descriptorCount = 1,
.descriptorType = vk::DescriptorType::eUniformBuffer, .pBufferInfo =
165
&bufferInfo },
vk::WriteDescriptorSet{ .dstSet = descriptorSets[i], .dstBinding = 1,
.dstArrayElement = 0, .descriptorCount = 1,
.descriptorType = vk::DescriptorType::eCombinedImageSampler, .pImageInfo =
&imageInfo }
};
[Link](descriptorWrites, {});
The descriptors must be updated with this image info, just like the buffer. This time we’re using the
pImageInfo array instead of pBufferInfo. The descriptors are now ready to be used by the shaders!
Texture coordinates
There is one important ingredient for texture mapping that is still missing, and that’s the actual
texture coordinates for each vertex, often called "uv coordinates". The texture coordinates
determine how the image is actually mapped to the geometry.
struct Vertex {
glm::vec2 pos;
glm::vec3 color;
glm::vec2 texCoord;
Modify the Vertex struct to include a vec2 for texture coordinates. Make sure to also add a
VkVertexInputAttributeDescription so that we can use access texture coordinates as input in the
vertex shader. That is necessary to be able to pass them to the fragment shader for interpolation
across the surface of the square.
166
{{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}, {0.0f, 1.0f}},
{{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}, {1.0f, 1.0f}}
};
In this tutorial, I will simply fill the square with the texture by using coordinates from 0, 0 in the
top-left corner to 1, 1 in the bottom-right corner. Feel free to experiment with different
coordinates. Try using coordinates below 0 or above 1 to see the addressing modes in action!
Shaders
The final step is modifying the shaders to sample colors from the texture. We first need to modify
the vertex shader to pass through the texture coordinates to the fragment shader:
struct VSInput {
float2 inPos;
float3 inColor;
float2 inTexCoord;
};
struct UniformBuffer {
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;
struct VSOutput
{
float4 pos : SV_Position;
float3 fragColor;
float2 fragTexCoord;
};
[shader("vertex")]
VSOutput vertMain(VSInput input) {
VSOutput output;
[Link] = mul([Link], mul([Link], mul([Link], float4([Link], 0.0,
1.0))));
[Link] = [Link];
[Link] = [Link];
return output;
}
Sampler2D texture;
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return [Link]([Link]);
167
}
You should see something like the image below. Remember to recompile the shaders!
The green channel represents the horizontal coordinates and the red channel the vertical
coordinates. The black and yellow corners confirm that the texture coordinates are correctly
interpolated from 0, 0 to 1, 1 across the square. Visualizing data using colors is the shader
programming equivalent of printf debugging, for lack of a better option!
A sampler represents a combined image sampler descriptor in Slang. Add a reference to it in the
fragment shader:
Sampler2D texture;
There are equivalent sampler1D and sampler3D types for other types of images. Make sure to use the
correct binding here.
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return [Link]([Link]);
}
Textures are sampled using the built-in texture function. It takes a sampler and coordinate as
arguments. The sampler automatically takes care of the filtering and transformations in the
background. You should now see the texture on the square when you run the application:
Try experimenting with the addressing modes by scaling the texture coordinates to values higher
than 1. For example, the following fragment shader produces the result in the image below when
using VK_SAMPLER_ADDRESS_MODE_REPEAT:
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return [Link]([Link]);
}
You can also manipulate the texture colors using the vertex colors:
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return float4([Link] * [Link]([Link]).rgb, 1.0);
168
}
I’ve separated the RGB and alpha channels here to not scale the alpha channel.
You now know how to access images in shaders! This is a very powerful technique when combined
with images that are also written to in framebuffers. You can use these images as inputs to
implement cool effects like post-processing and camera displays within the 3D world.
In the next chapter we’ll learn how to add depth buffering for properly sorting objects.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Depth Buffering
Introduction
The geometry we’ve worked with so far is projected into 3D, but it’s still completely flat. In this
chapter, we’re going to add a Z coordinate to the position to prepare for 3D meshes. We’ll use this
third coordinate to place a square over the current square to see a problem that arises when
geometry is not sorted by depth.
3D geometry
Change the Vertex struct to use a 3D vector for the position, and update the format in the
corresponding vk::VertexInputAttributeDescription:
struct Vertex {
glm::vec3 pos;
glm::vec3 color;
glm::vec2 texCoord;
...
169
...
}
};
Next, update the vertex shader to accept and transform 3D coordinates as input by changing the
type for inPosition from float2 to float3:
struct VSInput {
float3 inPosition;
...
};
...
[shader("vertex")]
VSOutput vertMain(VSInput input) {
VSOutput output;
[Link] = mul([Link], mul([Link], mul([Link], float4([Link],
1.0))));
[Link] = [Link];
[Link] = [Link];
return output;
}
If you run your application now, then you should see exactly the same result as before. It’s time to
add some extra geometry to make the scene more interesting, and to demonstrate the problem that
we’re going to tackle in this chapter. Duplicate the vertices to define positions for a square right
under the current one like this:
Use Z coordinates of -0.5f and add the appropriate indices for the extra square:
170
{{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
{{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}},
Run your program now, and you’ll see something resembling an Escher illustration:
The problem is that the fragments of the lower square are drawn over the fragments of the upper
square, simply because it comes later in the index array. There are two ways to solve this:
The first approach is commonly used for drawing transparent objects, because order-independent
transparency is a difficult challenge to solve. However, the problem of ordering fragments by depth
is much more commonly solved using a depth buffer. A depth buffer is an additional attachment
that stores the depth for every position just like the color attachment stores the color of every
position. Every time the rasterizer produces a fragment, the depth test will check if the new
fragment is closer than the previous one. If it isn’t, then the new fragment is discarded. A fragment
that passes the depth test writes its own depth to the depth buffer. It is possible to manipulate this
value from the fragment shader, just like you can manipulate the color output.
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/[Link]>
#include <glm/gtc/matrix_transform.hpp>
The perspective projection matrix generated by GLM will use the OpenGL depth range of -1.0 to 1.0
by default. We need to configure it to use the Vulkan range of 0.0 to 1.0 using the
GLM_FORCE_DEPTH_ZERO_TO_ONE definition.
171
vk::raii::Image depthImage = nullptr;
vk::raii::DeviceMemory depthImageMemory = nullptr;
vk::raii::ImageView depthImageView = nullptr;
void initVulkan() {
...
createCommandPool();
createDepthResources();
createTextureImage();
...
}
...
void createDepthResources() {
Creating a depth image is fairly straightforward. It should have the same resolution as the color
attachment, defined by the swap chain extent, an image usage appropriate for a depth attachment,
optimal tiling and device local memory. The only question is: what is the right format for a depth
image? The format must contain a depth component, indicated by D?? in the vk::Format.
Unlike the texture image, we don’t necessarily need a specific format, because we won’t be directly
accessing the texels from the program. It just needs to have a reasonable accuracy, at least 24 bits is
common in real-world applications. There are several formats that fit this requirement:
• vk::Format::eD32SfloatS8Uint: 32-bit signed float for depth and 8 bit stencil component
The stencil component is used for stencil tests, which is an additional test that can be combined
with depth testing. We’ll look at this in a future chapter.
We could simply go for the vk::Format::eD32Sfloat format, because support for it is extremely
common (see the hardware database), but it’s nice to add some extra flexibility to our application
where possible. We’re going to write a function findSupportedFormat that takes a list of candidate
formats in order from most desirable to least desirable, and checks which is the first one that is
supported:
172
The support of a format depends on the tiling mode and usage, so we must also include these as
parameters. The support of a format can be queried using the [Link]
function:
Only the first two are relevant here, and the one we check depends on the tiling parameter of the
function:
If none of the candidate formats support the desired usage, then we can either return a special
value or simply throw an exception:
173
We’ll use this function now to create a findDepthFormat helper function to select a format with a
depth component that supports usage as depth attachment:
vk::Format findDepthFormat() {
return findSupportedFormat(
{vk::Format::eD32Sfloat, vk::Format::eD32SfloatS8Uint,
vk::Format::eD24UnormS8Uint},
vk::ImageTiling::eOptimal,
vk::FormatFeatureFlagBits::eDepthStencilAttachment
);
}
Make sure to use the vk::FormatFeatureFlagBits instead of vk::ImageUsageFlagBits in this case. All of
these candidate formats contain a depth component, but the latter two also contain a stencil
component. We won’t be using that yet, but we do need to take that into account when performing
layout transitions on images with these formats. Add a simple helper function that tells us if the
chosen depth format contains a stencil component:
We now have all the required information to invoke our createImage and createImageView helper
functions:
However, the createImageView function currently assumes that the subresource is always the
vk::ImageAspectFlagBits::eColor, so we will need to turn that field into a parameter:
174
...
}
That’s it for creating the depth image. We don’t need to map it or copy another image to it, because
we’re going to clear it at the start of our command buffer like the color attachment.
Command buffer
Clear values
Because we now have multiple attachments that will be cleared to vk::AttachmentLoadOp::eClear
(color and depth), we also need to specify multiple clear values. Go to recordCommandBuffer and
create and add an additional vk::ClearValue variable called clearDepth:
The range of depths in the depth buffer is 0.0 to 1.0 in Vulkan, where 1.0 lies at the far view plane
and 0.0 at the near view plane. The initial value at each point in the depth buffer should be the
furthest possible depth, which is 1.0.
Dynamic rendering
Now that we have our depth image set up, we need to make use of it in recordCommandBuffer. This
will be part of dynamic rendering and is similar to setting up our color output image.
vk::RenderingAttachmentInfo depthAttachmentInfo = {
.imageView = depthImageView,
.imageLayout = vk::ImageLayout::eDepthAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eDontCare,
175
.clearValue = clearDepth};
vk::RenderingInfo renderingInfo = {
...
.pDepthAttachment = &depthAttachmentInfo};
As we now deal with an additional image type (depth), first add a new argument to the
transition_image_layout function for the image aspect:
void transition_image_layout(
...
vk::ImageAspectFlags image_aspect_flags)
{
vk::ImageMemoryBarrier2 barrier = {
...
.subresourceRange = {
.aspectMask = image_aspect_flags,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1}};
}
Now add new image layout transition at the start of the command buffer in recordCommandBuffer:
commandBuffers[currentFrame].begin({});
// Transition for the color attachment
transition_image_layout(
...
vk::ImageAspectFlagBits::eColor);
// New transition for the depth image
transition_image_layout(
*depthImage,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eDepthAttachmentOptimal,
vk::AccessFlagBits2::eDepthStencilAttachmentWrite,
176
vk::AccessFlagBits2::eDepthStencilAttachmentWrite,
vk::PipelineStageFlagBits2::eEarlyFragmentTests |
vk::PipelineStageFlagBits2::eLateFragmentTests,
vk::PipelineStageFlagBits2::eEarlyFragmentTests |
vk::PipelineStageFlagBits2::eLateFragmentTests,
vk::ImageAspectFlagBits::eDepth);
Unlike as with the color image we don’t need multiple barriers here. As we don’t care for the
contents of the depth attachment once the frame is finished, we can always translate from
vk::ImageLayout::eUndefined. What’s special about this layout, is the fact that you can always use it
as a source without having to care what happens before.
Also make sure you adjust all other calls to the transition_image_layout function call to pass the
correct image aspect:
vk::PipelineDepthStencilStateCreateInfo depthStencil{
.depthTestEnable = vk::True,
.depthWriteEnable = vk::True,
.depthCompareOp = vk::CompareOp::eLess,
.depthBoundsTestEnable = vk::False,
.stencilTestEnable = vk::False};
The depthTestEnable field specifies if the depth of new fragments should be compared to the depth
buffer to see if they should be discarded. The depthWriteEnable field specifies if the new depth of
fragments that pass the depth test should actually be written to the depth buffer.
The depthCompareOp field specifies the comparison that is performed to keep or discard fragments.
We’re sticking to the convention of lower depth = closer, so the depth of new fragments should be
less.
The depthBoundsTestEnable, minDepthBounds and maxDepthBounds fields are used for the optional depth
bound test. Basically, this allows you to only keep fragments that fall within the specified depth
range. We won’t be using this functionality.
The last three fields configure stencil buffer operations, which we also won’t be using in this
177
tutorial. If you want to use these operations, then you will have to make sure that the format of the
depth/stencil image contains a stencil component.
A depth stencil state must always be specified if the dynamic rendering setup contains a depth
stencil attachment:
Update the pipelineCreateInfoChain structure chain to reference the depth stencil state we just
filled in and also add a reference to the depth format we’re using:
vk::StructureChain<vk::GraphicsPipelineCreateInfo, vk::PipelineRenderingCreateInfo>
pipelineCreateInfoChain = {
{.stageCount = 2,
...
.pDepthStencilState = &depthStencil,
...
{.colorAttachmentCount = 1, .pColorAttachmentFormats =
&[Link], .depthAttachmentFormat = depthFormat}};
If you run your program now, then you should see that the fragments of the geometry are now
correctly ordered:
void recreateSwapChain() {
int width = 0, height = 0;
while (width == 0 || height == 0) {
glfwGetFramebufferSize(window, &width, &height);
glfwWaitEvents();
}
[Link](device);
cleanupSwapChain();
createSwapChain();
createImageViews();
createDepthResources();
}
Congratulations, your application is now finally ready to render arbitrary 3D geometry and have it
look right. We’re going to try this out in the next chapter by drawing a textured model!
178
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Loading Models
Introduction
Your program is now ready to render textured 3D meshes, but the current geometry in the vertices
and indices arrays is not very interesting yet. In this chapter, we’re going to extend the program to
load the vertices and indices from an actual model file to make the graphics card actually do some
work.
Many graphics API tutorials have the reader write their own OBJ loader in a chapter like this. The
problem with this is that any remotely interesting 3D application will soon require features that are
not supported by this file format, like skeletal animation. We will load mesh data from an OBJ
model in this chapter, but we’ll focus more on integrating the mesh data with the program itself
rather than the details of loading it from a file.
Library
We will use the tinyobjloader library to load vertices and faces from an OBJ file. It’s fast and it’s
easy to integrate because it’s a single file library like stb_image. This was mentioned in the
Development Environment chapter and should be part of the dependencies for this portion of the
tutorial.
Sample mesh
In this chapter, we won’t be enabling lighting yet, so it helps to use a sample model that has lighting
baked into the texture. An easy way to find such models is to look for 3D scans on Sketchfab. Many
of the models on that site are available in OBJ format with a permissive license.
For this tutorial, I’ve decided to go with the Viking room model by nigelgoh (CC BY 4.0). I tweaked
the size and orientation of the model to use it as a drop-in replacement for the current geometry:
• viking_room.obj
• viking_room.png
Feel free to use your own model, but make sure that it only consists of one material and that is has
dimensions of about 1.5 x 1.5 x 1.5 units. If it is larger than that, then you’ll have to change the view
matrix. Put the model file in a new models directory next to shaders and textures, and put the
texture image in the textures directory.
Put two new configuration variables in your program to define the model and texture paths:
179
const std::string MODEL_PATH = "models/viking_room.obj";
const std::string TEXTURE_PATH = "textures/viking_room.png";
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
vk::raii::Buffer vertexBuffer = nullptr;
vk::raii::DeviceMemory vertexBufferMemory = nullptr;
vk::raii::Buffer indexBuffer = nullptr;
vk::raii::DeviceMemory indexBufferMemory = nullptr;
You should change the type of the indices from uint16_t to uint32_t, because there are going to be a
lot more vertices than 65535. Remember to also change the vkCmdBindIndexBuffer parameter:
The tinyobjloader library is included in the same way as STB libraries. Include the
tiny_obj_loader.h file and make sure to define TINYOBJLOADER_IMPLEMENTATION in one source file to
include the function bodies and avoid linker errors:
#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>
We’re now going to write a loadModel function that uses this library to populate the vertices and
indices containers with the vertex data from the mesh. It should be called somewhere before the
vertex and index buffers are created:
void initVulkan() {
...
loadModel();
createVertexBuffer();
createIndexBuffer();
...
}
180
...
void loadModel() {
A model is loaded into the library’s data structures by calling the tinyobj::LoadObj function:
void loadModel() {
tinyobj::attrib_t attrib;
std::vector<tinyobj::shape_t> shapes;
std::vector<tinyobj::material_t> materials;
std::string warn, err;
An OBJ file consists of positions, normals, texture coordinates and faces. Faces consist of an
arbitrary amount of vertices, where each vertex refers to a position, normal and/or texture
coordinate by index. This makes it possible to not just reuse entire vertices, but also individual
attributes.
The attrib container holds all of the positions, normals and texture coordinates in its
[Link], [Link] and [Link] vectors. The shapes container contains all of
the separate objects and their faces. Each face consists of an array of vertices, and each vertex
contains the indices of the position, normal and texture coordinate attributes. OBJ models can also
define a material and texture per face, but we will be ignoring those.
The err string contains errors and the warn string contains warnings that occurred while loading
the file, like a missing material definition. Loading only really failed if the LoadObj function returns
false. As mentioned above, faces in OBJ files can actually contain an arbitrary number of vertices,
whereas our application can only render triangles. Luckily the LoadObj has an optional parameter
to automatically triangulate such faces, which is enabled by default.
We’re going to combine all of the faces in the file into a single model, so just iterate over all of the
shapes:
The triangulation feature has already made sure that there are three vertices per face, so we can
now directly iterate over the vertices and dump them straight into our vertices vector:
181
for (const auto& shape : shapes) {
for (const auto& index : [Link]) {
Vertex vertex{};
vertices.push_back(vertex);
indices.push_back([Link]());
}
}
For simplicity, we will assume that every vertex is unique for now, hence the simple auto-increment
indices. The index variable is of type tinyobj::index_t, which contains the vertex_index,
normal_index and texcoord_index members. We need to use these indices to look up the actual vertex
attributes in the attrib arrays:
[Link] = {
[Link][3 * index.vertex_index + 0],
[Link][3 * index.vertex_index + 1],
[Link][3 * index.vertex_index + 2]
};
[Link] = {
[Link][2 * index.texcoord_index + 0],
[Link][2 * index.texcoord_index + 1]
};
Unfortunately the [Link] array is an array of float values instead of something like
glm::vec3, so you need to multiply the index by 3. Similarly, there are two texture coordinate
components per entry. The offsets of 0, 1 and 2 are used to access the X, Y and Z components, or the
U and V components in the case of texture coordinates.
Run your program now with optimization enabled (e.g. Release mode in Visual Studio and with the
-O3 compiler flag for GCC`). This is necessary, because otherwise loading the model will be very
slow. You should see something like the following:
Great, the geometry looks correct, but what’s going on with the texture? The OBJ format assumes a
coordinate system where a vertical coordinate of 0 means the bottom of the image, however we’ve
uploaded our image into Vulkan in a top to bottom orientation where 0 means the top of the image.
Solve this by flipping the vertical component of the texture coordinates:
[Link] = {
[Link][2 * index.texcoord_index + 0],
1.0f - [Link][2 * index.texcoord_index + 1]
};
182
When you run your program again, you should now see the correct result:
All that hard work is finally beginning to pay off with a demo like this!
As the model rotates you may notice that the rear (backside of the walls)
looks a bit funny. This is normal and is simply because the model is not
really designed to be viewed from that side.
Vertex deduplication
Unfortunately, we’re not really taking advantage of the index buffer yet. The vertices vector
contains a lot of duplicated vertex data, because many vertices are included in multiple triangles.
We should keep only the unique vertices and use the index buffer to reuse them whenever they
come up. A straightforward way to implement this is to use a map or unordered_map to keep track of
the unique vertices and respective indices:
#include <unordered_map>
...
...
if ([Link](vertex) == 0) {
uniqueVertices[vertex] = static_cast<uint32_t>([Link]());
vertices.push_back(vertex);
}
indices.push_back(uniqueVertices[vertex]);
}
}
Every time we read a vertex from the OBJ file, we check if we’ve already seen a vertex with the
exact same position and texture coordinates before. If not, we add it to vertices and store its index
in the uniqueVertices container. After that we add the index of the new vertex to indices. If we’ve
seen the exact same vertex before, then we look up its index in uniqueVertices and store that index
in indices.
The program will fail to compile right now, because using a user-defined type like our Vertex struct
as key in a hash table requires us to implement two functions: equality test and hash calculation.
183
The former is easy to implement by overriding the == operator in the Vertex struct:
A hash function for Vertex is implemented by specifying a template specialization for std::hash<T>.
Hash functions are a complex topic, but [Link] recommends the following approach
combining the fields of a struct to create a decent quality hash function:
namespace std {
template<> struct hash<Vertex> {
size_t operator()(Vertex const& vertex) const {
return ((hash<glm::vec3>()([Link]) ^
(hash<glm::vec3>()([Link]) << 1)) >> 1) ^
(hash<glm::vec2>()([Link]) << 1);
}
};
}
This code should be placed outside the Vertex struct. The hash functions for the GLM types need to
be included using the following header:
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/[Link]>
The hash functions are defined in the gtx folder, which means that it is technically still an
experimental extension to GLM. Therefore, you need to define GLM_ENABLE_EXPERIMENTAL to use it. It
means that the API could change with a new version of GLM in the future, but in practice the API is
very stable.
You should now be able to successfully compile and run your program. If you check the size of
vertices, then you’ll see that it has shrunk down from 1,500,000 to 265,645! That means that each
vertex is reused in an average number of ~6 triangles. This definitely saves us a lot of GPU memory.
In the next chapter, we’ll learn about a technique to improve texture rendering.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Generating Mipmaps
Introduction
Our program can now load and render 3D models. In this chapter, we will add one more feature,
184
mipmap generation. Mipmaps are widely used in games and rendering software, and Vulkan gives
us complete control over how they are created.
Mipmaps are precalculated, downscaled versions of an image. Each new image is half the width
and height of the previous one. Mipmaps are used as a form of Level of Detail or LOD. Objects that
are far away from the camera will sample their textures from the smaller mip images. Using
smaller images increases the rendering speed and avoids artifacts such as Moiré patterns. An
example of what mipmaps look like:
Image creation
In Vulkan, each of the mip images is stored in different mip levels of a VkImage. Mip level 0 is the
original image, and the mip levels after level 0 are commonly referred to as the mip chain.
The number of mip levels is specified when the VkImage is created. Up until now, we have always set
this value to one. We need to calculate the number of mip levels from the dimensions of the image.
First, add a class member to store this number:
...
uint32_t mipLevels;
std::unique_ptr<vk::raii::Image> textureImage;
...
The value for mipLevels can be found once we’ve loaded the texture in createTextureImage:
This calculates the number of levels in the mip chain. The max function selects the largest
dimension. The log2 function calculates how many times that dimension can be divided by 2. The
floor function handles cases where the largest dimension is not a power of 2. 1 is added so that the
original image has a mip level.
To use this value, we need to change the createImage, createImageView, and transitionImageLayout
functions to allow us to specify the number of mip levels. Add a mipLevels parameter to the
functions:
185
[Link] = mipLevels;
...
}
transitionImageLayout(depthImage, depthFormat,
vk::ImageLayout::eUndefined,vk::ImageLayout::eDepthStencilAttachmentOptimal, 1);
...
transitionImageLayout(textureImage, vk::ImageLayout::eUndefined,
vk::ImageLayout::eTransferDstOptimal, mipLevels);
186
Generating Mipmaps
Our texture image now has multiple mip levels, but the staging buffer can only be used to fill mip
level 0. The other levels are still undefined. To fill these levels, we need to generate the data from
the single level that we have. We will use the vkCmdBlitImage command. This command performs
copying, scaling, and filtering operations. We will call this multiple times to blit data to each level of
our texture image.
vkCmdBlitImage is considered a transfer operation, so we must inform Vulkan that we intend to use
the texture image as both the source and destination of a transfer. Add
VK_IMAGE_USAGE_TRANSFER_SRC_BIT to the texture image’s usage flags in createTextureImage:
...
createImage(texWidth, texHeight, mipLevels, vk::Format::eR8G8B8A8Srgb,
vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransferSrc |
vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled,
vk::MemoryPropertyFlagBits::eDeviceLocal, textureImage, textureImageMemory);
...
Like other image operations, vkCmdBlitImage depends on the layout of the image it operates on. We
could transition the entire image to VK_IMAGE_LAYOUT_GENERAL, but this will most likely be slow. For
optimal performance, the source image should be in VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL and the
destination image should be in VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL. Vulkan allows us to
transition each mip level of an image independently. Each blit will only deal with two mip levels at
a time, so we can transition each level into the optimal layout between blits commands.
transitionImageLayout only performs layout transitions on the entire image, so we’ll need to write a
few more pipeline barrier commands. Remove the existing transition to
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL in createTextureImage:
...
transitionImageLayout(textureImage, vk::ImageLayout::eUndefined,
vk::ImageLayout::eTransferDstOptimal, mipLevels);
copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth),
static_cast<uint32_t>(texHeight));
//transitioned to VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL while generating mipmaps
...
This will leave each level of the texture image in VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL. Each level
will be transitioned to VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL after the blit command reading
from it is finished.
We’re now going to write the function that generates the mipmaps:
187
beginSingleTimeCommands();
We’re going to make several transitions, so we’ll reuse this VkImageMemoryBarrier. The fields set
above will remain the same for all barriers. [Link], oldLayout, newLayout,
srcAccessMask, and dstAccessMask will be changed for each transition.
This loop will record each of the VkCmdBlitImage commands. Note that the loop variable starts at 1,
not 0.
[Link] = i - 1;
[Link] = vk::ImageLayout::eTransferDstOptimal;
[Link] = vk::ImageLayout::eTransferSrcOptimal;
[Link] = vk::AccessFlagBits::eTransferWrite;
[Link] = vk::AccessFlagBits::eTransferRead;
commandBuffer->pipelineBarrier(vk::PipelineStageFlagBits::eTransfer,
vk::PipelineStageFlagBits::eTransfer, {}, {}, {}, barrier);
188
mipHeight / 2 : 1, 1);
vk::ImageBlit blit = { .srcSubresource = {}, .srcOffsets = offsets,
.dstSubresource = {}, .dstOffsets = dstOffsets };
[Link] = vk::ImageSubresourceLayers( vk::ImageAspectFlagBits::eColor, i -
1, 0, 1);
[Link] = vk::ImageSubresourceLayers( vk::ImageAspectFlagBits::eColor, i,
0, 1);
Next, we specify the regions that will be used in the blit operation. The source mip level is i - 1 and
the destination mip level is i. The two elements of the srcOffsets array determine the 3D region
that data will be blitted from. dstOffsets determines the region that data will be blitted to. The X
and Y dimensions of the dstOffsets[1] are divided by two since each mip level is half the size of the
previous level. The Z dimension of srcOffsets[1] and dstOffsets[1] must be 1, since a 2D image has
a depth of 1.
Now, we record the blit command. Note that textureImage is used for both the srcImage and dstImage
parameter. This is because we’re blitting between different levels of the same image. The source
mip level was just transitioned to VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL and the destination level is
still in VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL from createTextureImage.
Beware if you are using a dedicated transfer queue (as suggested in Vertex buffers): vkCmdBlitImage
must be submitted to a queue with graphics capability.
The last parameter allows us to specify a VkFilter to use in the blit. We have the same filtering
options here that we had when making the VkSampler. We use the VK_FILTER_LINEAR to enable
interpolation.
[Link] = vk::ImageLayout::eTransferSrcOptimal;
[Link] = vk::ImageLayout::eShaderReadOnlyOptimal;
[Link] = vk::AccessFlagBits::eTransferRead;
[Link] = vk::AccessFlagBits::eShaderRead;
commandBuffer->pipelineBarrier(vk::PipelineStageFlagBits::eTransfer,
vk::PipelineStageFlagBits::eFragmentShader, {}, {}, {}, barrier);
...
if (mipWidth > 1) mipWidth /= 2;
if (mipHeight > 1) mipHeight /= 2;
}
189
At the end of the loop, we divide the current mip dimensions by two. We check each dimension
before the division to ensure that dimension never becomes 0. This handles cases where the image
is not square, since one of the mip dimensions would reach 1 before the other dimension. When
this happens, that dimension should remain 1 for all remaining levels.
[Link] = mipLevels - 1;
[Link] = vk::ImageLayout::eTransferDstOptimal;
[Link] = vk::ImageLayout::eShaderReadOnlyOptimal;
[Link] = vk::AccessFlagBits::eTransferWrite;
[Link] = vk::AccessFlagBits::eShaderRead;
commandBuffer->pipelineBarrier(vk::PipelineStageFlagBits::eTransfer,
vk::PipelineStageFlagBits::eFragmentShader, {}, {}, {}, barrier);
endSingleTimeCommands(*commandBuffer);
}
Before we end the command buffer, we insert one more pipeline barrier. This barrier transitions
the last mip level from VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL to
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL. The loop didn’t handle this, since the last mip level is
never blitted from.
transitionImageLayout(*textureImage, vk::ImageLayout::eUndefined,
vk::ImageLayout::eTransferDstOptimal, mipLevels);
copyBufferToImage(stagingBuffer, *textureImage, static_cast<uint32_t>(texWidth),
static_cast<uint32_t>(texHeight));
//transitioned to VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL while generating mipmaps
...
generateMipmaps(textureImage, texWidth, texHeight, mipLevels);
void createTextureImage() {
...
190
generateMipmaps(*textureImage, vk::Format::eR8G8B8A8Srgb, texWidth, texHeight,
mipLevels);
}
...
}
...
if (!([Link] &
vk::FormatFeatureFlagBits::eSampledImageFilterLinear)) {
throw std::runtime_error("texture image format does not support linear
blitting!");
}
There are two alternatives in this case. You could implement a function that searches common
texture image formats for one that does support linear blitting, or you could implement the
mipmap generation in software with a library like stb_image_resize. Each mip level can then be
loaded into the image in the same way that you loaded the original image.
It should be noted that it is uncommon in practice to generate the mipmap levels at runtime
anyway. Usually they are pre-generated and stored in the texture file alongside the base level to
improve loading speed. Implementing resizing in software and loading multiple levels from a file is
left as an exercise to the reader.
191
Sampler
While the VkImage holds the mipmap data, VkSampler controls how that data is read while rendering.
Vulkan allows us to specify minLod, maxLod, mipLodBias, and mipmapMode ("Lod" means "Level of
Detail"). When a texture is sampled, the sampler selects a mip level according to the following
pseudocode:
if (mipmapMode == vk::SamplerMipmapMode::eNearest) {
color = sample(level);
} else {
color = blend(sample(level), sample(level + 1));
}
if (lod <= 0) {
color = readTexture(uv, magFilter);
} else {
color = readTexture(uv, minFilter);
}
If the object is close to the camera, magFilter is used as the filter. If the object is further from the
camera, minFilter is used. Normally, lod is non-negative, and is only 0 when close the camera.
mipLodBias lets us force Vulkan to use lower lod and level than it would normally use.
To see the results of this chapter, we need to choose values for our textureSampler. We’ve already
set the minFilter and magFilter to use VK_FILTER_LINEAR. We just need to choose values for minLod,
maxLod, mipLodBias, and mipmapMode.
void createTextureSampler() {
vk::PhysicalDeviceProperties properties = [Link]();
vk::SamplerCreateInfo samplerInfo {
.magFilter = vk::Filter::eLinear,
.minFilter = vk::Filter::eLinear,
.mipmapMode = vk::SamplerMipmapMode::eLinear,
.addressModeU = vk::SamplerAddressMode::eRepeat,
.addressModeV = vk::SamplerAddressMode::eRepeat,
192
.addressModeW = vk::SamplerAddressMode::eRepeat,
.mipLodBias = 0.0f,
.anisotropyEnable = vk::True,
.maxAnisotropy = [Link],
.compareEnable = vk::False,
.compareOp = vk::CompareOp::eAlways,
.minLod = 0.0f,
.maxLod = vk::LodClampNone
};
...
}
In the code above, we’ve set up the sampler with linear filtering for both minification and
magnification, and linear interpolation between mip levels. We’ve also set the mip level bias to 0.0f.
The minLod and maxLod are used to effectively set the range of mip levels to be used by clamping the
minimum and maximum LOD values. By setting minLod to 0.0f and maxLod to VK_LOD_CLAMP_NONE we
ensure the full range of mip levels will be used.
Now run your program, and you should see the following:
[mipmaps] | /images/[Link]
It’s not a dramatic difference, since our scene is so simple. There are subtle differences if you look
close.
The most noticeable difference is the writing in the papers. With mipmaps, the writing has been
smoothed. Without mipmaps, the writing has harsh edges and gaps from Moiré artifacts.
You can play around with the sampler settings to see how they affect mipmapping. For example, by
changing minLod, you can force the sampler to not use the lowest mip levels:
[highmipmaps] | /images/[Link]
This is how higher mip levels will be used when objects are further away from the camera.
The next chapter will walk us through multisampling to produce a smoother image.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
193
Multisampling
Introduction
Our program can now load multiple levels of detail for textures which fix artifacts when rendering
objects far away from the viewer. The image is now a lot smoother, however; on closer inspection,
you will notice jagged saw-like patterns along the edges of drawn geometric shapes. This is
especially visible in one of our early programs when we rendered a quad:
This undesired effect is called "aliasing," and it’s a result of a limited number of pixels that are
available for rendering. Since there are no displays out there with unlimited resolution, it will
always be visible to some extent. There are a number of ways to fix this, and in this chapter we’ll
focus on one of the more popular ones: Multisample antialiasing (MSAA).
In ordinary rendering, the pixel color is determined based on a single sample point which in most
cases is the center of the target pixel on screen. If part of the drawn line passes through a certain
pixel but doesn’t cover the sample point, that pixel will be left blank, leading to the jagged
"staircase" effect.
[aliasing] | /images/[Link]
What MSAA does is it uses multiple sample points per pixel (hence the name) to determine its final
color. As one might expect, more samples lead to better results, however, it is also more
computationally expensive.
[antialiasing] | /images/[Link]
In our implementation, we will focus on using the maximum available sample count. Depending on
your application, this may not always be the best approach, and it might be better to use fewer
samples for the sake of higher performance if the final result meets your quality demands.
...
vk::SampleCountFlagBits msaaSamples = vk::SampleCountFlagBits::e1;
...
By default, we’ll be using only one sample per pixel which is equivalent to no multisampling, in
which case the final image will remain unchanged. The exact maximum number of samples can be
extracted from VkPhysicalDeviceProperties associated with our selected physical device. We’re
194
using a depth buffer, so we have to take into account the sample count for both color and depth.
The highest sample count that both support (and) will be the maximum we can support. Add a
function that will fetch this information for us:
vk::SampleCountFlagBits getMaxUsableSampleCount() {
vk::PhysicalDeviceProperties physicalDeviceProperties = physicalDevice-
>getProperties();
vk::SampleCountFlags counts =
[Link] &
[Link];
if (counts & vk::SampleCountFlagBits::e64) { return vk::SampleCountFlagBits::e64;
}
if (counts & vk::SampleCountFlagBits::e32) { return vk::SampleCountFlagBits::e32;
}
if (counts & vk::SampleCountFlagBits::e16) { return vk::SampleCountFlagBits::e16;
}
if (counts & vk::SampleCountFlagBits::e8) { return vk::SampleCountFlagBits::e8; }
if (counts & vk::SampleCountFlagBits::e4) { return vk::SampleCountFlagBits::e4; }
if (counts & vk::SampleCountFlagBits::e2) { return vk::SampleCountFlagBits::e2; }
return vk::SampleCountFlagBits::e1;
}
We will now use this function to set the msaaSamples variable during the physical device selection
process. For this, we have to slightly modify the pickPhysicalDevice function:
void pickPhysicalDevice() {
...
for (const auto& device : devices) {
if (isDeviceSuitable(device)) {
physicalDevice = device;
msaaSamples = getMaxUsableSampleCount();
break;
}
}
...
}
195
buffer. Add the following class members:
...
vk::raii::Image colorImage = nullptr;
vk::raii::DeviceMemory colorImageMemory = nullptr;
vk::raii::ImageView colorImageView = nullptr;
...
This new image will have to store the desired number of samples per pixel, so we need to pass this
number to VkImageCreateInfo during the image creation process. Modify the createImage function by
adding a numSamples parameter:
For now, update all calls to this function using VK_SAMPLE_COUNT_1_BIT - we will be replacing this with
proper values as we progress with implementation:
createImage([Link], [Link], 1,
vk::SampleCountFlagBits::e1, depthFormat, vk::ImageTiling::eOptimal,
vk::ImageUsageFlagBits::eDepthStencilAttachment,
vk::MemoryPropertyFlagBits::eDeviceLocal, depthImage, depthImageMemory);
...
createImage(texWidth, texHeight, mipLevels, vk::SampleCountFlagBits::e1,
vk::Format::eR8G8B8A8Srgb, vk::ImageTiling::eOptimal,
vk::ImageUsageFlagBits::eTransferSrc | vk::ImageUsageFlagBits::eTransferDst |
vk::ImageUsageFlagBits::eSampled, vk::MemoryPropertyFlagBits::eDeviceLocal,
textureImage, textureImageMemory);
We will now create a multi-sampled color buffer. Add a createColorResources function and note that
we’re using msaaSamples here as a function parameter to createImage. We’re also using only one mip
level, since this is enforced by the Vulkan specification in case of images with more than one
sample per pixel. Also, this color buffer doesn’t need mipmaps since it’s not going to be used as a
texture:
void createColorResources() {
vk::Format colorFormat = swapChainImageFormat;
196
colorImage, colorImageMemory);
colorImageView = createImageView(colorImage, colorFormat,
vk::ImageAspectFlagBits::eColor, 1);
}
void initVulkan() {
...
createColorResources();
createDepthResources();
...
}
Now that we have a multi-sampled color buffer in place, it’s time to take care of depth. Modify
createDepthResources and update the number of samples used by the depth buffer:
void createDepthResources() {
...
createImage([Link], [Link], 1, msaaSamples,
depthFormat, vk::ImageTiling::eOptimal,
vk::ImageUsageFlagBits::eDepthStencilAttachment,
vk::MemoryPropertyFlagBits::eDeviceLocal, depthImage_, depthImageMemory_);
...
}
And update the recreateSwapChain so that the new color image can be recreated in the correct
resolution when the window is resized:
void recreateSwapChain() {
...
createImageViews();
createColorResources();
createDepthResources();
...
}
We made it past the initial MSAA setup, now we need to start using this new resource in our
graphics pipeline, framebuffer, render pass and see the results!
197
void createRenderPass() {
...
[Link] = msaaSamples;
[Link] = vk::ImageLayout::eColorAttachmentOptimal;
...
[Link] = msaaSamples;
...
...
vk::AttachmentDescription colorAttachmentResolve({}, swapChainImageFormat,
vk::SampleCountFlagBits::e1, vk::AttachmentLoadOp::eDontCare,
vk::AttachmentStoreOp::eStore, vk::AttachmentLoadOp::eDontCare,
vk::AttachmentStoreOp::eDontCare, vk::ImageLayout::eUndefined,
vk::ImageLayout::ePresentSrcKHR);
...
The render pass now has to be instructed to resolve multi-sampled color image into regular
attachment. Create a new attachment reference that will point to the color buffer which will serve
as the resolve target:
...
vk::AttachmentReference colorAttachmentResolveRef(2,
vk::ImageLayout::eColorAttachmentOptimal);
...
Set the pResolveAttachments subpass struct member to point to the newly created attachment
reference. This is enough to let the render pass define a multisample resolve operation which will
let us render the image to screen:
...
[Link] = &colorAttachmentResolveRef;
...
Since we’re reusing the multi-sampled color image, it’s necessary to update the srcAccessMask of the
VkSubpassDependency. This update ensures that any write operations to the color attachment are
completed before later ones begin, thus preventing write-after-write hazards that can lead to
unstable rendering results:
...
198
[Link] = vk::AccessFlagBits::eColorAttachmentWrite |
vk::AccessFlagBits::eDepthStencilAttachmentWrite;
...
Now update render pass info struct with the new color attachment:
...
std::array attachments = {colorAttachment, depthAttachment,
colorAttachmentResolve};
...
With the render pass in place, modify createFramebuffers and add the new image view to the list:
void createFramebuffers() {
...
vk::ImageView attachments[] = { *colorImageView, *depthImageView, view };
...
}
Finally, tell the newly created pipeline to use more than one sample by modifying
createGraphicsPipeline:
void createGraphicsPipeline() {
...
[Link] = msaaSamples;
...
}
Now run your program, and you should see the following:
[multisampling] | /images/[Link]
Just like with mipmapping, the difference may not be apparent straight away. On a closer look,
you’ll notice that the edges are not as jagged anymore and the whole image seems a bit smoother
compared to the original.
The difference is more noticeable when looking up close at one of the edges:
Quality improvements
There are certain limitations of our current MSAA implementation that may impact the quality of
the output image in more detailed scenes. For example, we’re currently not solving potential
199
problems caused by shader aliasing, i.e. MSAA only smoothens out the edges of geometry but not
the interior filling. This may lead to a situation when you get a smooth polygon rendered on screen,
but the applied texture will still look aliased if it contains high contrasting colors. One way to
approach this problem is to enable Sample Shading which will improve the image quality even
further, though at an additional performance cost:
void createLogicalDevice() {
...
[Link] = vk::True; // enable sample shading
feature for the device
...
}
void createGraphicsPipeline() {
...
[Link] = vk::True; // enable sample shading in the
pipeline
[Link] = .2f; // min fraction for sample shading; closer
to one is smoother
...
}
In this example, we’ll leave sample shading disabled, but in certain scenarios the quality
improvement may be noticeable:
Conclusion
It has taken a lot of work to get to this point, but now you finally have a good base for a Vulkan
program. The knowledge of the basic principles of Vulkan that you now possess should be sufficient
to start exploring more of the features, like:
• Push constants
• Instanced rendering
• Dynamic uniforms
• Pipeline cache
• Multiple subpasses
• Compute shaders
The current program can be extended in many ways, like adding Blinn-Phong lighting, post-
processing effects, and shadow mapping. You should be able to learn how these effects work from
tutorials for other APIs, because despite Vulkan’s explicitness, many concepts still work the same.
200
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader :pp: ++
Compute Shader
Introduction
In this bonus chapter, we’ll take a look at compute shaders. Up until now, all previous chapters
dealt with the traditional graphics part of the Vulkan pipeline. But unlike older APIs like OpenGL,
compute shader support in Vulkan is mandatory. This means that you can use compute shaders on
every Vulkan implementation available, no matter if it’s a high-end desktop GPU or a low-powered
embedded device.
This opens up the world of general purpose computing on graphics processor units (GPGPU), no
matter where your application is running. GPGPU means that you can do general computations on
your GPU, something that has traditionally been a domain of CPUs. But with GPUs having become
more and more powerful and more flexible, many workloads that would require the general
purpose capabilities of a CPU can now be done on the GPU in realtime.
A few examples of where the compute capabilities of a GPU can be used are image manipulation,
visibility testing, post-processing, advanced lighting calculations, animations, physics, (e.g., for a
particle system) and much more. And it’s even possible to use compute for non-visual
computational only work that does not require any graphics output, e.g., number crunching or AI
related things. This is called "headless compute".
Advantages
Doing computationally expensive calculations on the GPU has several advantages. The most
obvious one is offloading work from the CPU. Another one is not requiring moving data between
the CPU’s main memory and the GPU’s memory. All the data can stay on the GPU without having to
wait for slow transfers from the main memory.
Aside from these, GPUs are heavily parallelized with some of them having tens of thousands of
small compute units. This often makes them a better fit for highly parallel workflows than a CPU
with a few large compute units.
In this diagram we can see the traditional graphics part of the pipeline on the left, and several
stages on the right that are not part of this graphics pipeline, including the compute shader (stage).
With the compute shader stage being detached from the graphics pipeline, we’ll be able to use it
anywhere we see fit. This is very different from e.g., the fragment shader which is always applied to
201
the transformed output of the vertex shader.
The center of the diagram also shows that e.g., descriptor sets are also used by compute, so
everything we learned about descriptor layouts, descriptor sets, and descriptors also applies here.
An example
An easy-to-understand example that we will implement in this chapter is a GPU-based particle
system. Such systems are used in many games and often consist of thousands of particles that need
to be updated at interactive frame rates. Rendering such a system requires two main parts: vertices,
passed as vertex buffers, and a way to update them based on some equation.
The "classical" CPU-based particle system would store particle data in the system’s main memory
and then use the CPU to update them. After the update, the vertices need to be transferred to the
GPU’s memory again so it can display the updated particles in the next frame. The most straight-
forward way would be recreating the vertex buffer with the new data for each frame. This is very
costly. Depending on your implementation, there are other options like mapping GPU memory so it
can be written by the CPU. (Called "resizable BAR" on desktop systems, or unified memory on
integrated GPUs) or just using a host local buffer (which would be the slowest method due to PCI-E
bandwidth). But no matter what buffer update method you choose, you always require a "round-
trip" to the CPU to update the particles.
With a GPU-based particle system, this round-trip is no longer required. Vertices are only uploaded
to the GPU at the start, and all updates are done in the GPU’s memory using compute shaders. One
of the main reasons why this is faster is the much higher bandwidth between the GPU and its local
memory. In a CPU-based scenario, you’d be limited by main memory and PCI-express bandwidth,
which is often just a fraction of the GPU’s memory bandwidth.
When doing this on a GPU with a dedicated compute queue, you can update particles in parallel to
the rendering part of the graphics pipeline. This is called "async compute", and is an advanced topic
not covered in this tutorial.
Here is a screenshot from this chapter’s code. The particles shown here are updated by a compute
shader directly on the GPU, without any CPU interaction:
Data manipulation
In this tutorial, we have already learned about different buffer types like vertex and index buffers
for passing primitives and uniform buffers for passing data to a shader. And we also used images to
do texture mapping. But up until now, we always wrote data using the CPU and only did reads on
the GPU.
An important concept introduced with compute shaders is the ability to arbitrarily read from and
write to buffers. For this, Vulkan offers two dedicated storage types.
202
Shader storage buffer objects (SSBO)
A shader storage buffer (SSBO) allows shaders to read from and write to a buffer. Using this is
similar to using uniform buffer objects. The biggest differences are that you can alias other buffer
types to SSBOs and that they can be arbitrarily large.
Going back to the GPU-based particle system, you might now wonder how to deal with vertices
being updated (written) by the compute shader and read (drawn) by the vertex shader, as both
usages would seemingly require different buffer types.
But that’s not the case. In Vulkan, you can specify multiple usages for buffers and images. So for the
particle vertex buffer to be used as a vertex buffer (in the graphics pass) and as a storage buffer (in
the compute pass), you simply create the buffer with those two usage flags:
vk::BufferCreateInfo bufferInfo{};
...
[Link] = vk::BufferUsageFlagBits::eVertexBuffer |
vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eTransferDst;
...
vk::raii::Buffer shaderStorageBufferTemp({});
vk::raii::DeviceMemory shaderStorageBufferTempMemory({});
createBuffer(bufferSize, vk::BufferUsageFlagBits::eStorageBuffer |
vk::BufferUsageFlagBits::eVertexBuffer | vk::BufferUsageFlagBits::eTransferDst,
vk::MemoryPropertyFlagBits::eDeviceLocal, shaderStorageBufferTemp,
shaderStorageBufferTempMemory);
copyBuffer(stagingBuffer, shaderStorageBufferTemp, bufferSize);
shaderStorageBuffers.emplace_back(std::move(shaderStorageBufferTemp));
shaderStorageBuffersMemory.emplace_back(std::move(shaderStorageBufferTempMemory));
The Slang shader declaration for accessing such a buffer looks like this:
struct Particle {
float2 position;
float2 velocity;
float4 color;
203
};
struct ParticleSSBO {
Particle particles;
};
StructuredBuffer<ParticleSSBO> particlesIn;
RWStructuredBuffer<ParticleSSBO> particlesOut;
In this example we have a typed SSBO with each particle having a position and velocity value (see
the Particle struct). The SSBO then contains an unbound number of particles as it is placed into a
StructuredBuffer without upper limit, and it’s a read-only buffer for particlesIn. For particlesOut,
we place it into a RWStructedBuffer. Not having to specify the number of elements in an SSBO is
one of the advantages over e.g. uniform buffers.
Writing to such a storage buffer object in the compute shader is straight-forward and similar to
how you’d write to the buffer on the C++ side:
particlesOut[index].[Link] = particlesIn[index].[Link] +
particlesIn[index].[Link] * [Link];
Storage images
Note that we won’t be doing image manipulation in this chapter. This paragraph is here to make
readers aware that compute shaders can also be used for image manipulation.
A storage image allows you to read from and write to an image. Typical use cases are applying
image effects to textures, doing post-processing (which in turn is very similar) or generating mip-
maps.
The Slang shader declaration for storage image looks similar to sampled images used, e.g., in the
fragment shader:
204
[vk::image_format("r32f")] Texture2D<float> inputImage;
[vk::image_format("r32f")] RWTexture2D<float> outputImage;
A few differences here are additional attributes like r32f for the format of the image, the usage of
the read-only Texture2D and read-write RWTexture2D designations. And last but not least we need
to use the RWTexture2D type to declare a storage image.
Reading from and writing to storage images in the compute shader is then done array lookup
syntax:
Note that Vulkan requires an implementation which supports graphics operations to have at least
one queue family that supports both graphics and compute operations, but it’s also possible that
implementations offer a dedicated compute queue. This dedicated compute queue (that does not
have the graphics bit) hints at an asynchronous compute queue. To keep this tutorial beginner-
friendly though, we’ll use a queue that can do both graphics and compute operations. This will also
save us from dealing with several advanced synchronization mechanisms.
For our compute sample, we need to change the device creation code a bit:
// get the first index into queueFamilyProperties which supports graphics and compute
auto graphicsAndComputeQueueFamilyProperty =
std::find_if( [Link](),
[Link](),
[]( vk::QueueFamilyProperties const & qfp ) { return ([Link] &
vk::QueueFlagBits::eGraphics && [Link] & vk::QueueFlagBits::eCompute); } );
graphicsAndComputeIndex = static_cast<uint32_t>( std::distance(
[Link](), graphicsAndComputeQueueFamilyProperty ) );
The changed queue family index selection code will now try to find a queue family that supports
both graphics and compute.
We can then get a compute queue from this queue family in createLogicalDevice:
205
computeQueue = std::make_unique<vk::raii::Queue>( *device, graphicsAndComputeIndex, 0
);
vk::PipelineShaderStageCreateInfo computeShaderStageInfo({},
vk::ShaderStageFlagBits::eCompute, shaderModule, "compMain");
...
In the frames in flight chapter, we talked about duplicating resources per frame in flight, so we can
keep the CPU and the GPU busy. First, we declare a vector for the buffer object and the device
memory backing it up:
std::vector<vk::raii::Buffer> shaderStorageBuffers;
std::vector<vk::raii::DeviceMemory> shaderStorageBuffersMemory;
In the createShaderStorageBuffers we then clear those vectors to clean up any objects already
created in their as is our RAII practice.
[Link]();
[Link]();
206
With this setup in place, we can start to move the initial particle information to the GPU. We first
initialize a vector of particles on the host side:
// Initialize particles
std::default_random_engine rndEngine((unsigned)time(nullptr));
std::uniform_real_distribution<float> rndDist(0.0f, 1.0f);
We then create a staging buffer in the host’s memory to hold the initial particle properties:
Using this staging buffer as a source, we then create the per-frame shader storage buffers and copy
the particle properties from the staging buffer to each of these:
207
shaderStorageBuffersMemory.emplace_back(std::move(shaderStorageBufferTempMemory));
}
}
Descriptors
Setting up descriptors for compute is almost identical to graphics. The only difference is that
descriptors need to have the vk::ShaderStageFlagBits::eCompute set to make them accessible by the
compute stage:
std::array layoutBindings{
vk::DescriptorSetLayoutBinding(0, vk::DescriptorType::eUniformBuffer, 1,
vk::ShaderStageFlagBits::eCompute, nullptr),
};
...
Note that you can combine shader stages here, so if you want the descriptor to be accessible from
the vertex and compute stage, e.g., for a uniform buffer with parameters shared across them, you
set the bits for both stages:
layoutBindings[0].stageFlags = vk::ShaderStageFlagBits::eVertex |
vk::ShaderStageFlagBits::eCompute;
Here is the descriptor setup for our sample. The layout looks like this:
std::array layoutBindings{
vk::DescriptorSetLayoutBinding(0, vk::DescriptorType::eUniformBuffer, 1,
vk::ShaderStageFlagBits::eCompute, nullptr),
vk::DescriptorSetLayoutBinding(1, vk::DescriptorType::eStorageBuffer, 1,
vk::ShaderStageFlagBits::eCompute, nullptr),
vk::DescriptorSetLayoutBinding(2, vk::DescriptorType::eStorageBuffer, 1,
vk::ShaderStageFlagBits::eCompute, nullptr)
};
Looking at this setup, you might wonder why we have two layout bindings for shader storage
buffer objects, even though we’ll only render a single particle system. This is because the particle
positions are updated frame by frame based on a delta time. This means that each frame needs to
know about the last frames' particle positions, so it can update them with a new delta time and
write them to its own SSBO:
208
[compute ssbo read write] | /images/compute_ssbo_read_write.svg
For that, the compute shader needs to have access to the last and current frame’s SSBOs. This is
done by passing both to the compute shader in our descriptor setup. See the
storageBufferInfoLastFrame and storageBufferInfoCurrentFrame:
vk::DescriptorBufferInfo storageBufferInfoLastFrame(shaderStorageBuffers[(i - 1) %
MAX_FRAMES_IN_FLIGHT], 0, sizeof(Particle) * PARTICLE_COUNT);
vk::DescriptorBufferInfo storageBufferInfoCurrentFrame(shaderStorageBuffers[i], 0,
sizeof(Particle) * PARTICLE_COUNT);
std::array descriptorWrites{
vk::WriteDescriptorSet( computeDescriptorSets[i], 0, 0, 1,
vk::DescriptorType::eUniformBuffer, nullptr, &bufferInfo ),
vk::WriteDescriptorSet( computeDescriptorSets[i], 1, 0, 1,
vk::DescriptorType::eStorageBuffer, nullptr, &storageBufferInfoLastFrame),
vk::WriteDescriptorSet( computeDescriptorSets[i], 2, 0, 1,
vk::DescriptorType::eStorageBuffer, nullptr, &storageBufferInfoCurrentFrame),
};
device->updateDescriptorSets(descriptorWrites, {});
}
Remember that we also have to request the descriptor types for the SSBOs from our descriptor pool:
std::array poolSize {
vk::DescriptorPoolSize( vk::DescriptorType::eUniformBuffer, MAX_FRAMES_IN_FLIGHT),
vk::DescriptorPoolSize( vk::DescriptorType::eStorageBuffer, MAX_FRAMES_IN_FLIGHT
* 2)
};
Compute pipelines
As compute is not a part of the graphics pipeline, we can’t use device→createGraphicsPipeline.
Instead, we need to create a dedicated compute pipeline with device→createComputePipeline for
running our compute commands. Since a compute pipeline does not touch any of the rasterization
state, it has a lot less state than a graphics pipeline:
209
pipelineLayoutInfo );
The setup is a lot simpler, as we only require one shader stage and a pipeline layout. The pipeline
layout works the same as with the graphics pipeline:
Compute space
Before we get into how a compute shader works and how we submit compute workloads to the
GPU, we need to talk about two important compute concepts: work groups and invocations. They
define an abstract execution model for how compute workloads are processed by the compute
hardware of the GPU in three dimensions (x, y, and z).
Work groups define how the compute workloads are formed and processed by the compute
hardware of the GPU. You can think of them as work items the GPU has to work through. Work
group dimensions are set by the application at command buffer time using a dispatch command.
And each work group then is a collection of invocations that execute the same compute shader.
Invocations can potentially run in parallel, and their dimensions are set in the compute shader.
Invocations within a single workgroup have access to shared memory.
This image shows the relation between these two in three dimensions:
As an example: If we dispatch a work group count of [64, 1, 1] with a compute shader local size of
[32, 32, 1], our compute shader will be invoked 64 x 32 x 32 = 65,536 times.
Note that the maximum count for work groups and local sizes differs from implementation to
implementation, so you should always check the compute related maxComputeWorkGroupCount,
maxComputeWorkGroupInvocations and maxComputeWorkGroupSize limits in VkPhysicalDeviceLimits.
Compute shaders
Now that we have learned about all the parts required to set up a compute shader pipeline, it’s time
to take a look at compute shaders. All the things we learned about using GLSL shaders, e.g., for
vertex and fragment shaders also apply to compute shaders. The syntax is the same, and many
concepts like passing data between the application and the shader are the same. But there are some
210
important differences.
A very basic compute shader for updating a linear array of particles may look like this:
struct Particle {
float2 position;
float2 velocity;
float4 color;
};
struct UniformBuffer {
float deltaTime;
};
ConstantBuffer<UniformBuffer> ubo;
struct ParticleSSBO {
Particle particles;
};
StructuredBuffer<ParticleSSBO> particlesIn;
RWStructuredBuffer<ParticleSSBO> particlesOut;
[shader("compute")]
[numthreads(256,1,1)]
void compMain(uint3 threadId : SV_DispatchThreadID)
{
uint index = threadId.x;
particlesOut[index].[Link] = particlesIn[index].[Link] +
particlesIn[index].[Link] * [Link];
particlesOut[index].[Link] = particlesIn[index].[Link];
The top part of the shader contains the declarations for the shader’s input. First is a uniform buffer
object at binding 0, something we already learned about in this tutorial. Below we declare our
Particle structure that matches the declaration in the C++ code. Binding 1 then refers to the shader
211
storage buffer object with the particle data from the last frame (see the descriptor setup). Binding 2
points to the SSBO for the current frame, which is the one we’ll be updating with this shader.
[numthreads(256,1,1)]
This defines the number of invocations of this compute shader in the current work group. As noted
earlier, this is the local part of the compute space. As we work on a linear 1D array of particles, we
only need to specify a number for x dimension in [numthreads(x,y,z)].
The compMain function then reads from the last frame’s SSBO and writes the updated particle
position to the SSBO for the current frame. Similar to other shader types, compute shaders have
their own set of builtin input variables. Our passed in ThreadId is a variable that uniquely
identifies the current compute shader invocation across the current dispatch. It gains that
capability by the SV_DispatchThreadID annotation. We use this to index into our particle array.
computeCommandBuffers[frameIndex]->begin({});
...
computeCommandBuffers[frameIndex]->bindPipeline(vk::PipelineBindPoint::eCompute,
*computePipeline);
computeCommandBuffers[frameIndex]->bindDescriptorSets(vk::PipelineBindPoint::eCompute,
*computePipelineLayout, 0, {computeDescriptorSets[frameIndex]}, {});
...
computeCommandBuffers[frameIndex]->end();
212
numbers right usually takes some tinkering and profiling, depending on your workload and the
hardware you’re running on. If your particle size is dynamic and can’t always be divided by e.g.,
256, you can always use gl_GlobalInvocationID at the start of your compute shader and return from
it if the global invocation index is greater than the number of your particles.
And just as was the case for the compute pipeline, a compute command buffer has a lot less state
than a graphics command buffer. There’s no need to start a render pass or set a viewport.
Submitting work
As our sample does both compute and graphics operations, we’ll be doing two submits to both the
graphics and compute queue per frame (see the drawFrame function):
...
computeQueue->submit(submitInfo, **computeInFlightFences[frameIndex]);
...
graphicsQueue->submit(submitInfo, **inFlightFences[frameIndex]);
The first submit to the compute queue updates the particle positions using the compute shader, and
the second submit will then use that updated data to draw the particle system.
So we must make sure that those cases don’t happen by properly synchronizing the graphics and
the compute load. There are different ways of doing so, depending on how you submit your
compute workload, but in our case with two separate submits, we’ll be using semaphores and
fences to ensure that the vertex shader won’t start fetching vertices until the compute shader has
finished updating them.
This is necessary as even though the two submits are ordered one-after-another, there is no
guarantee that they execute on the GPU in this order. Adding in wait and signal semaphores
ensures this execution order.
So we first add a new set of synchronization primitives for the compute work in createSyncObjects.
The compute fences, just like the graphics fences, are created in the signaled state because
otherwise, the first draw would time out while waiting for the fences to be signaled as detailed
here:
std::vector<std::unique_ptr<vk::raii::Fence>> computeInFlightFences;
std::vector<std::unique_ptr<vk::raii::Semaphore>> computeFinishedSemaphores;
...
213
[Link](MAX_FRAMES_IN_FLIGHT);
[Link](MAX_FRAMES_IN_FLIGHT);
We then use these to synchronize the compute buffer submission with the graphics submission:
{
// Compute submission
while ( vk::Result::eTimeout == device-
>waitForFences(**computeInFlightFences[frameIndex], vk::True, UINT64_MAX) )
;
updateUniformBuffer(frameIndex);
device->resetFences( **computeInFlightFences[frameIndex] );
computeCommandBuffers[frameIndex]->reset();
recordComputeCommandBuffer();
device->resetFences( **inFlightFences[frameIndex] );
commandBuffers[frameIndex]->reset();
recordCommandBuffer(imageIndex);
Similar to the sample in the semaphore chapter, this setup will immediately run the compute
shader as we haven’t specified any wait semaphores. Note that we’re using scoping braces above to
214
ensure that the RAII temporary variables we use get a chance to clean themselves up between the
compute and the graphics stage. This is fine, as we are waiting for the compute command buffer of
the current frame to finish execution before the compute submission with the device→waitForFences
command.
The graphics submission, on the other hand, needs to wait for the compute work to finish so it
doesn’t start fetching vertices while the compute buffer is still updating them. So we wait on the
computeFinishedSemaphores for the current frame and have the graphics submission wait on the
vk::PipelineStageFlagBits::eVertexInput stage, where vertices are consumed.
But it also needs to wait for presentation, so the fragment shader won’t output to the color
attachments until the image has been presented. So we also wait on the imageAvailableSemaphores
on the current frame at the vk::PipelineStageFlagBits::eColorAttachmentOutput stage.
Timeline semaphores were introduced as an extension and later promoted to core in Vulkan 1.2.
Unlike binary semaphores, timeline semaphores have a 64-bit unsigned integer counter value that
can be waited on and signaled to specific values. This provides several advantages over binary
semaphores:
1. Reusability: A single timeline semaphore can be used for multiple synchronization points,
reducing the number of semaphores needed.
2. Host synchronization: Timeline semaphores can be signaled and waited on from the host
(CPU) without submitting commands to a queue.
3. Out-of-order signaling: You can signal a timeline semaphore to a value higher than what’s
currently being waited on, allowing for more flexible synchronization patterns.
4. Multiple pending signals: Unlike binary semaphores, which can only be pending-signaled
once, timeline semaphores can have multiple pending signals.
Let’s see how we can modify our particle system example to use timeline semaphores instead of
binary semaphores:
First, we need to enable the timeline semaphore feature when creating the logical device:
vk::PhysicalDeviceTimelineSemaphoreFeaturesKHR timelineSemaphoreFeatures;
[Link] = vk::True;
// Chain this to your device creation info
215
vk::SemaphoreTypeCreateInfo semaphoreType{ .semaphoreType =
vk::SemaphoreType::eTimeline, .initialValue = 0 };
semaphore = vk::raii::Semaphore(device, {.pNext = &semaphoreType});
timelineValue = 0;
In our draw frame function, we use incrementing timeline values to coordinate work between
compute and graphics:
For the compute submission, we use a timeline semaphore submit info structure:
vk::TimelineSemaphoreSubmitInfo computeTimelineInfo{
.waitSemaphoreValueCount = 1,
.pWaitSemaphoreValues = &computeWaitValue,
.signalSemaphoreValueCount = 1,
.pSignalSemaphoreValues = &computeSignalValue
};
vk::SubmitInfo computeSubmitInfo{
.pNext = &computeTimelineInfo,
.waitSemaphoreCount = 1,
.pWaitSemaphores = &*semaphore,
.pWaitDstStageMask = waitStages,
.commandBufferCount = 1,
.pCommandBuffers = &*computeCommandBuffers[frameIndex],
.signalSemaphoreCount = 1,
.pSignalSemaphores = &*semaphore
};
[Link](computeSubmitInfo, nullptr);
216
vk::SubmitInfo graphicsSubmitInfo{
.pNext = &graphicsTimelineInfo,
.waitSemaphoreCount = 1,
.pWaitSemaphores = &*semaphore,
.pWaitDstStageMask = &waitStage,
.commandBufferCount = 1,
.pCommandBuffers = &*commandBuffers[frameIndex],
.signalSemaphoreCount = 1,
.pSignalSemaphores = &*semaphore
};
[Link](graphicsSubmitInfo, nullptr);
vk::SemaphoreWaitInfo waitInfo{
.semaphoreCount = 1,
.pSemaphores = &*semaphore,
.pValues = &graphicsSignalValue
};
vk::PresentInfoKHR presentInfo{
.waitSemaphoreCount = 0, // No binary semaphores needed
.pWaitSemaphores = nullptr,
.swapchainCount = 1,
.pSwapchains = &*swapChain,
.pImageIndices = &imageIndex
};
This timeline semaphore approach offers several benefits over the binary semaphore
implementation:
2. More explicit synchronization: The timeline values make it clear which operations depend on
each other.
3. Reduced overhead: With fewer synchronization objects, there’s less overhead in managing
them.
217
synchronization scenarios that would be difficult with binary semaphores.
Timeline semaphores are particularly useful in scenarios with multiple dependent operations, like
our compute-then-graphics workflow, or when you need to synchronize between the host and
device. They provide a more powerful and flexible synchronization mechanism that can simplify
your code while enabling more complex synchronization patterns.
We first set up the vertex input state to match our particle structure:
struct Particle {
...
Note that we don’t add velocity to the vertex input attributes, as this is only used by the compute
shader.
We then bind and draw it like we would with any vertex buffer:
commandBuffers[frameIndex]->bindVertexBuffers(0, { *shaderStorageBuffers[frameIndex]
}, {0});
commandBuffers[frameIndex]->draw( PARTICLE_COUNT, 1, 0, 0 );
Conclusion
In this chapter, we learned how to use compute shaders to offload work from the CPU to the GPU.
Without compute shaders, many effects in modern games and applications would either not be
possible or would run a lot slower. But even more than graphics, compute has a lot of use-cases,
and this chapter only gives you a glimpse of what’s possible. So now that you know how to use
218
compute shaders, you may want to take a look at some advanced compute topics like:
• Shared memory
• Asynchronous compute
• Atomic operations
• Subgroups
You can find some advanced compute samples in the official Khronos Vulkan Samples repository.
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader / GLSL Compute shader :pp:
++
This knowledge is essential for developing Vulkan applications that can run on a diverse range of
hardware, from the latest high-end GPUs to older or more limited devices.
• Available extensions
• Feature support
• Implementation limits
219
• Format properties
This information is crowdsourced from users who run the Vulkan Hardware Capability Viewer
tool, which reports their GPU’s capabilities to the database.
1. Determine minimum requirements: Understand what Vulkan version and extensions you
need to target to support your desired range of hardware.
2. Check feature availability: Verify if specific features like dynamic rendering, timeline
semaphores, or ray tracing are widely supported.
3. Identify implementation limits: Discover the practical limits of various Vulkan features across
different hardware.
4. Compare vendors and devices: Understand the differences in Vulkan support between
NVIDIA, AMD, Intel, and mobile GPU vendors.
To determine how widely supported Vulkan 1.3 (which introduced dynamic rendering) is:
1. Visit [Link]
You’ll find that while newer GPUs support Vulkan 1.3+, there are still many devices limited to
Vulkan 1.0, 1.1, or 1.2.
This helps you decide whether to require the extension or provide a fallback path.
The Vulkan Configurator tool (executable name vkconfig on all platforms) is included in the Vulkan
SDK and provides a convenient way to configure Vulkan settings on your system. Here’s how to use
220
it:
◦ On Windows: It’s recommended to start "Vulkan Configurator" from the Start menu, as
running [Link] from the command line only shows limited options
5. Export Configuration:
◦ Save your configuration for later use or to share with team members
Using the Vulkan Configurator is particularly helpful when: - Debugging Vulkan applications with
different validation layer configurations - Testing your application with different Vulkan settings
without modifying code - Setting up a development environment with specific Vulkan requirements
In many Vulkan applications, validation layers are enabled programmatically during instance
creation, typically only in debug builds. Here’s how this is commonly done:
221
#else
constexpr bool enableValidationLayers = true;
#endif
void createInstance() {
// Check if validation layers are available
if (enableValidationLayers && !checkValidationLayerSupport()) {
throw std::runtime_error("validation layers requested, but not available!");
}
// Application info...
A better approach is to use the Vulkan Configurator to manage validation layers externally. Here’s
how to modify your code to take advantage of this:
void createInstance() {
// Application info...
222
}
2. You use the Vulkan Configurator to enable validation layers when needed
1. Launch the Vulkan Configurator (from the Start menu on Windows, or run vkconfig from the
terminal - the executable is called vkconfig on all platforms)
This configuration will apply to all Vulkan applications run in that environment, making it easy to
toggle validation on and off without code changes.
• Cleaner code: Your application code doesn’t need to handle validation layers
◦ vkconfig (Vulkan Configurator): A configuration tool for managing Vulkan settings (see
Example: Using the Vulkan Configurator Tool for details)
• Vendor-specific Tools:
223
Supporting Older GPUs
Now that we understand how to discover GPU capabilities, let’s explore how to modify our code to
support older GPUs that don’t have Vulkan 1.3/1.4 features like dynamic rendering.
void createRenderPass() {
if ([Link]) {
// No render pass needed with dynamic rendering
return;
}
224
.format = swapChainImageFormat,
.samples = vk::SampleCountFlagBits::e1,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.stencilLoadOp = vk::AttachmentLoadOp::eDontCare,
.stencilStoreOp = vk::AttachmentStoreOp::eDontCare,
.initialLayout = vk::ImageLayout::eUndefined,
.finalLayout = vk::ImageLayout::ePresentSrcKHR
};
// Subpass description
vk::SubpassDescription subpass{
.pipelineBindPoint = vk::PipelineBindPoint::eGraphics,
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachmentRef
};
renderPass = [Link](renderPassInfo);
}
Creating Framebuffers
void createFramebuffers() {
225
if ([Link]) {
// No framebuffers needed with dynamic rendering
return;
}
[Link]([Link]());
vk::FramebufferCreateInfo framebufferInfo{
.renderPass = renderPass,
.attachmentCount = 1,
.pAttachments = attachments,
.width = [Link],
.height = [Link],
.layers = 1
};
swapChainFramebuffers[i] = [Link](framebufferInfo);
}
}
When creating the graphics pipeline, we need to specify the render pass if dynamic rendering isn’t
available:
void createGraphicsPipeline() {
// ... existing shader stage and fixed function setup ...
vk::GraphicsPipelineCreateInfo pipelineInfo{};
if ([Link]) {
// Use dynamic rendering
vk::PipelineRenderingCreateInfo pipelineRenderingCreateInfo{
.colorAttachmentCount = 1,
.pColorAttachmentFormats = &swapChainImageFormat
};
[Link] = &pipelineRenderingCreateInfo;
[Link] = nullptr;
} else {
// Use traditional render pass
[Link] = nullptr;
[Link] = renderPass;
[Link] = 0;
226
}
if ([Link]) {
// Begin dynamic rendering
vk::RenderingAttachmentInfo colorAttachment{
.imageView = swapChainImageViews[imageIndex],
.imageLayout = vk::ImageLayout::eAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.clearValue = clearColor
};
vk::RenderingInfo renderingInfo{
.renderArea = {{0, 0}, swapChainExtent},
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachment
};
[Link](renderingInfo);
} else {
// Begin traditional render pass
vk::RenderPassBeginInfo renderPassInfo{
.renderPass = renderPass,
.framebuffer = swapChainFramebuffers[imageIndex],
.renderArea = {{0, 0}, swapChainExtent},
.clearValueCount = 1,
.pClearValues = &clearColor
};
[Link](renderPassInfo, vk::SubpassContents::eInline);
}
if ([Link]) {
[Link]();
} else {
[Link]();
}
227
// ... end command buffer ...
}
Timeline Semaphores
vk::SemaphoreCreateInfo semaphoreInfo{
.pNext = &timelineCreateInfo
};
timelineSemaphore = [Link](semaphoreInfo);
} else {
// Create binary semaphores and fences
vk::SemaphoreCreateInfo semaphoreInfo{};
vk::FenceCreateInfo fenceInfo{.flags = vk::FenceCreateFlagBits::eSignaled};
228
for (size_t i = 0; i < [Link](); i++)
{
renderFinishedSemaphores[i] = [Link](semaphoreInfo);
}
Synchronization2
The Synchronization2 feature (Vulkan 1.3) simplifies pipeline barriers and memory dependencies.
If it’s not available, use the original synchronization commands:
vk::DependencyInfo dependencyInfo{
.imageMemoryBarrierCount = 1,
229
.pImageMemoryBarriers = &barrier
};
commandBuffer.pipelineBarrier2(dependencyInfo);
} else {
// Use original synchronization API
vk::ImageMemoryBarrier barrier{
.srcAccessMask = vk::AccessFlagBits::eNone,
.dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite,
.oldLayout = vk::ImageLayout::eUndefined,
.newLayout = vk::ImageLayout::eColorAttachmentOptimal,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = swapChainImages[i],
.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}
};
[Link](
vk::PipelineStageFlagBits::eTopOfPipe,
vk::PipelineStageFlagBits::eColorAttachmentOutput,
vk::DependencyFlagBits::eByRegion,
{},
{},
{ barrier }
);
}
1. Check feature availability at runtime: Don’t assume features are available based on the
Vulkan version alone. Always check for specific features and extensions.
2. Provide fallback paths: Implement alternative code paths for when modern features aren’t
available.
3. Use feature structures: When creating a logical device, use the appropriate feature structures
to enable only the features you need and that are available.
4. Test on various hardware: Use [Link] to identify common hardware configurations and
test your application on a representative sample.
6. Document requirements: Clearly document the minimum and recommended Vulkan version
and extension requirements for your application.
230
Conclusion
Understanding Vulkan ecosystem utilities and knowing how to adapt your code for different GPU
capabilities are essential skills for Vulkan developers. By following the approaches outlined in this
chapter, you can create applications that run on a wide range of hardware while still taking
advantage of the latest features when available.
1. Define a set of features, extensions, and limits that your application requires
3. Eliminate the need for manual feature detection and fallback paths
Vulkan profiles are particularly valuable for developers who want to ensure their applications
work consistently across a wide range of hardware without the complexity of manually checking
for feature support.
Instead of manually checking for each feature and extension and implementing fallback paths, you
can simply specify a profile that your application requires. The Vulkan profiles library will handle
231
the compatibility checks and provide appropriate error messages if the user’s hardware doesn’t
meet the requirements.
1. API Profiles: Represent specific Vulkan API versions (e.g., Vulkan 1.1, 1.2, 1.3)
2. Vendor Profiles: Target specific hardware vendors (e.g., NVIDIA, AMD, Intel)
In this chapter, we’ll use the Best Practices profile as an example, additionally, we will demonstrate
how profiles can simplify your code by eliminating the need for manual feature detection.
4. Repeat this process for every feature (timeline semaphores, synchronization2, etc.)
This approach leads to complex, hard-to-maintain code with multiple conditional branches.
1. Drastically reduced code complexity: No need for multiple feature checks and conditional
branches
3. Future-proofing: As new Vulkan versions are released, profiles can be updated without
232
changing your code
4. Clearer requirements: Profiles provide a clear specification of what your application needs
#include <vulkan/vulkan_profiles.hpp>
This header provides the necessary functions and structures to work with Vulkan profiles.
The Vulkan Profiles header is NOT part of the standard Vulkan headers. It is only available if you
use the Vulkan SDK. Make sure you have the Vulkan SDK installed and properly configured in your
development environment.
if (!supported) {
throw std::runtime_error("Roadmap 2022 profile is not supported on this device");
}
233
features and extensions:
This automatically enables all the features and extensions required by the Best Practices profile,
without having to manually specify them.
vk::RenderingInfo renderingInfo{
.renderArea = {{0, 0}, swapChainExtent},
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachment
};
[Link](renderingInfo);
// ... draw commands ...
[Link]();
234
try {
// Try to create a device with the Best Practices profile
vpCreateDevice(physicalDevice, &deviceCreateInfo, &bestPracticesProfile, nullptr,
&device);
} catch (const std::exception& e) {
// Profile is not supported, provide user-friendly error message
std::cerr << "Your GPU does not support the required Vulkan features for optimal
performance." << std::endl;
std::cerr << "Error: " << [Link]() << std::endl;
And then we had to create conditional code paths throughout our application:
235
// When creating the pipeline
if ([Link]) {
// Use dynamic rendering
vk::PipelineRenderingCreateInfo renderingInfo{
.colorAttachmentCount = 1,
.pColorAttachmentFormats = &swapChainImageFormat
};
[Link] = &renderingInfo;
[Link] = nullptr;
} else {
// Use traditional render pass
[Link] = nullptr;
[Link] = renderPass;
[Link] = 0;
}
We had to repeat this pattern for every feature we wanted to use conditionally (timeline
semaphores, synchronization2, etc.), resulting in complex, branching code that’s challenging to
maintain.
236
// Check if the profile is supported
VkBool32 supported = false;
vpGetPhysicalDeviceProfileSupport(instance, physicalDevice, &profile, &supported);
if (supported) {
// Create device with the profile - all features enabled automatically
vpCreateDevice(physicalDevice, &deviceCreateInfo, &profile, nullptr, &device);
// Now we can use any feature guaranteed by the profile without checks
// For example, dynamic rendering is always available:
vk::RenderingAttachmentInfo colorAttachment{/*...*/};
vk::RenderingInfo renderingInfo{/*...*/};
[Link](renderingInfo);
// ... draw commands ...
[Link]();
}
1. Significantly shorter
1. Choose the right profile: Select a profile that matches your application’s requirements without
being overly restrictive.
2. Provide fallback options: If the Best Practices profile isn’t supported, consider falling back to a
more basic profile.
3. Communicate requirements clearly: Inform users about the hardware requirements based on
the profiles you support.
4. Test on various hardware: Even with profiles, it’s important to test your application on
different GPUs.
5. Stay updated: Profiles evolve with new Vulkan versions, so keep your implementation up to
237
date.
Conclusion
Vulkan profiles provide a powerful way to simplify your Vulkan code by eliminating the need for
manual feature detection and conditional code paths. As we’ve seen in this chapter, profiles can
dramatically reduce the amount of code you need to write and maintain, making your application:
The example we’ve explored in this chapter demonstrates how profiles can replace the complex
feature detection and fallback paths we had to implement in the previous chapter. By using
profiles, you can focus more on your application’s core functionality and less on the intricacies of
hardware compatibility.
While Vulkan was designed to be cross-platform from the ground up, deploying to Android
introduces some new challenges and opportunities. The core Vulkan API remains the same, but the
surrounding ecosystem - from window management to build systems - requires a different
approach.
This chapter will guide you through adapting your Vulkan application for Android, reusing as much
code as possible while addressing platform-specific requirements. You’ll see that with the right
setup, you can maintain a single codebase that works across desktop and mobile platforms.
Android-specific Considerations
Before diving into implementation details, let’s understand the key differences when developing
Vulkan applications for Android compared to desktop:
238
1. Window System Integration: Instead of GLFW, we use Android’s native window system and
activity lifecycle.
2. Application Lifecycle: Android apps can be paused, resumed, or terminated by the system at
any time, requiring careful resource management.
3. Asset Loading: Resources are packaged in APK files and accessed through Android’s asset
manager.
4. Build System: We use Gradle and CMake together to build Android applications.
5. Input Handling: Touch input replaces mouse and keyboard, requiring different event handling.
These differences might seem daunting at first, but with the right approach, we can address them
while maintaining a clean, maintainable codebase.
Project Setup
Now that we understand the key differences, let’s set up our Android project. Our goal is to reuse as
much code as possible from our desktop implementation while addressing Android-specific
requirements.
Prerequisites
Before we begin, make sure you have the following tools installed:
• Android NDK (Native Development Kit): Enables native C++ development on Android
• Android SDK: With a recent API level (24+, which corresponds to Android 7.0 or higher) for
Vulkan support
• CMake and Ninja build tools: For building native code (these can be installed through Android
Studio)
Unlike the desktop environment, Vulkan HPP (the C++ bindings for Vulkan)
is NOT included by default in the Android NDK. You’ll need to download it
IMPORTANT
separately from the Vulkan-Hpp GitHub repository or use the version
included in the Vulkan SDK.
Project Structure
Let’s start by understanding the structure of our Android project. We’ll follow the standard Android
application structure, but with some modifications to efficiently reuse code from our main project:
android/
├── app/
│ ├── [Link] // App-level build configuration
239
│ ├── src/
│ │ ├── main/
│ │ │ ├── [Link] // App manifest
│ │ │ ├── cpp/ // Native code
│ │ │ │ ├── [Link] // CMake build script
│ │ │ │ └── game_activity_bridge.cpp // Bridge between GameActivity and
our Vulkan code
│ │ │ ├── java/ // Java code
│ │ │ │ └── com/example/vulkantutorial/
│ │ │ │ └── [Link] // Main activity (extends
GameActivity)
│ │ │ └── res/ // Resources
│ │ │ └── values/
│ │ │ ├── [Link] // String resources
│ │ │ └── [Link] // Style resources
├── [Link] // Project-level build configuration
├── gradle/ // Gradle wrapper
├── [Link] // Project settings
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
240
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".VulkanActivity"
android:label="@string/app_name"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="true">
<intent-filter>
<action android:name="[Link]" />
<category android:name="[Link]" />
</intent-filter>
</activity>
</application>
</manifest>
Key points:
• We specify a minimum SDK version of 24 (Android 7.0), which is required for Vulkan support.
• We declare that our app uses Vulkan with specific version requirements.
• We set up our main activity (VulkanActivity) as the entry point for our application.
Java Activity
After configuring the manifest, we need to create the Java side of our application. While most of our
Vulkan code will run in native C++, we still need a Java activity to serve as the entry point for our
application.
For our Vulkan application, we’ll use the GameActivity from the Android Game SDK instead of the
traditional NativeActivity. This modern approach offers better performance and features
specifically designed for games and graphics-intensive applications:
package [Link];
import [Link];
import [Link];
import [Link];
241
// Load the native library
static {
[Link]("vulkan_tutorial_android");
}
}
Key points:
• We extend GameActivity from the Android Game SDK, which provides a more optimized bridge
between Java and native code.
Build Configuration
With our Java activity in place, we need to configure the build process. Android uses Gradle as its
build system, which we’ll configure to work with our native Vulkan code and assets.
The build configuration is split across multiple files, with different responsibilities:
Project-level [Link]:
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath '[Link]:gradle:7.2.2'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
App-level [Link]:
242
plugins {
id '[Link]'
}
android {
compileSdkVersion 33
defaultConfig {
applicationId "[Link]"
minSdkVersion 24
targetSdkVersion 33
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('[Link]'),
'[Link]'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
externalNativeBuild {
cmake {
path "src/main/cpp/[Link]"
version "3.22.1"
}
}
ndkVersion "25.2.9519653"
// Use assets from the main project and locally compiled shaders
sourceSets {
main {
assets {
srcDirs = [
// Point to the main project's assets
'../../../../', // For models and textures in the attachments
directory
// Use locally compiled shaders from the build directory for all
ABIs
// These paths are relative to the app directory
'.externalNativeBuild/cmake/debug/arm64-v8a/shaders',
'.externalNativeBuild/cmake/debug/armeabi-v7a/shaders',
'.externalNativeBuild/cmake/debug/x86/shaders',
243
'.externalNativeBuild/cmake/debug/x86_64/shaders',
// Also include release build paths
'.externalNativeBuild/cmake/release/arm64-v8a/shaders',
'.externalNativeBuild/cmake/release/armeabi-v7a/shaders',
'.externalNativeBuild/cmake/release/x86/shaders',
'.externalNativeBuild/cmake/release/x86_64/shaders'
]
}
}
}
}
dependencies {
implementation '[Link]:appcompat:1.6.1'
implementation '[Link]:material:1.9.0'
implementation '[Link]:game-activity:1.2.0'
}
Key points:
• We specify the minimum SDK version as 24 (Android 7.0) for Vulkan support.
• We set up asset directories to reference the main project’s assets and locally compiled shaders.
• This approach avoids duplicating assets and ensures we’re using the latest versions.
CMake Configuration
While Gradle handles the overall Android build process, we use CMake to build our native C++
code. This is where we’ll set up our Vulkan environment, compile shaders, and link against the
necessary libraries.
Let’s examine our [Link] file, which is the heart of our native code configuration:
cmake_minimum_required(VERSION 3.22.1)
project(vulkan_tutorial_android)
244
set_property(TARGET glslang::validator PROPERTY IMPORTED_LOCATION
"${GLSLANG_VALIDATOR}")
245
"${Vulkan_INCLUDE_DIR}"
FILES
"${Vulkan_INCLUDE_DIR}/vulkan/[Link]"
)
# Compile shaders
set(SHADER_SOURCES "${SHADER_OUTPUT_DIR}/27_shader_depth.frag"
"${SHADER_OUTPUT_DIR}/27_shader_depth.vert")
add_shaders_target(android_shaders CHAPTER_NAME "${SHADER_OUTPUT_DIR}" SOURCES
${SHADER_SOURCES})
246
)
Key points:
• We find the Vulkan package and include the game-activity library instead of native_app_glue.
• We set the C++ standard to C++20 and optionally create a Vulkan C++ module (recommended)
when modules are enabled.
• We set up shader compilation for the 34_android chapter, copying shader source files from the
main project.
• We add the main native library, which uses the 34_android.cpp file from the main project and a
bridge file to connect with GameActivity.
Native Implementation
Now that we’ve set up our build configuration, let’s dive into the native C++ code that powers our
Vulkan application on Android. This is where the real magic happens - we’ll see how to adapt our
existing Vulkan code to work on Android while minimizing platform-specific changes.
One of the key advantages of our approach is code reuse. Instead of maintaining separate
codebases for desktop and Android, we’ve structured our project to share as much code as possible:
1. 34_android.cpp: This is the same file used in our main project, containing the core Vulkan
implementation. By reusing this file, we ensure that our rendering code is identical across
platforms.
2. game_activity_bridge.cpp: This small bridge file connects the Android GameActivity to our
core Vulkan code. It handles the platform-specific initialization and event processing.
This separation of concerns allows us to focus on the Vulkan implementation without getting
bogged down in platform-specific details. When we make improvements to our rendering code,
both desktop and Android versions benefit automatically.
GameActivity Bridge
Let’s take a closer look at our bridge code, which is the key to connecting our Java GameActivity
with our native Vulkan implementation. This small but crucial file handles the translation between
Android’s Java-based activity lifecycle and our C++ code:
#include <game-activity/GameActivity.h>
#include <game-activity/native_app_glue/android_native_app_glue.h>
#include <android/log.h>
247
__VA_ARGS__))
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, "VulkanTutorial",
__VA_ARGS__))
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "VulkanTutorial",
__VA_ARGS__))
// Clean up
delete app;
}
}
Let’s look at how we initialize our Vulkan application from this entry point:
248
[Link]();
} catch (const std::exception& e) {
LOGE("Exception caught: %s", [Link]());
}
}
void createSurface() {
VkSurfaceKHR _surface;
VkResult result = VK_SUCCESS;
if (result != VK_SUCCESS) {
throw std::runtime_error("Failed to create Android surface");
}
249
static void handleAppCommand(android_app* app, int32_t cmd) {
auto* vulkanApp = static_cast<VulkanApplication*>(app->userData);
switch (cmd) {
case APP_CMD_INIT_WINDOW:
// Window created, initialize Vulkan
if (app->window != nullptr) {
vulkanApp->initVulkan();
}
break;
case APP_CMD_TERM_WINDOW:
// Window destroyed, clean up Vulkan
vulkanApp->cleanup();
break;
default:
break;
}
}
return 1;
}
return 0;
}
Cross-Platform Implementation
While we’ve focused on Android-specific code so far, our approach allows us to maintain a single
codebase that works on both desktop and Android platforms. This is achieved through careful use
of preprocessor directives and platform-specific abstractions.
Platform Detection
The first step in our cross-platform approach is to detect which platform we’re building for. We use
preprocessor directives to check for platform-specific predefined macros:
// Platform detection
#if defined(__ANDROID__)
#define PLATFORM_ANDROID 1
250
#else
#define PLATFORM_DESKTOP 1
#endif
This approach leverages the standard predefined macro ANDROID which is automatically defined by
the compiler when building for Android platforms. These platform macros are then used
throughout the code to conditionally compile platform-specific code.
Platform-Specific Includes
Different platforms require different header files. We use preprocessor directives to include the
appropriate headers:
// Platform-specific includes
#if PLATFORM_ANDROID
// Android-specific includes
#include <android/log.h>
#include <android_native_app_glue.h>
#include <android/asset_manager.h>
#include <android/asset_manager_jni.h>
#else
// Desktop-specific includes
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
251
#include <stb_image.h>
#include <tiny_obj_loader.h>
#endif
252
#else
// Desktop main entry point
int main() {
try {
HelloTriangleApplication app;
[Link]();
} catch (const std::exception& e) {
std::cerr << [Link]() << std::endl;
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
#endif
1. Simplicity: We don’t need to maintain platform-specific compile definitions in our CMake files.
3. Maintainability: Less build system configuration means fewer potential points of failure.
By using the compiler’s predefined macros, we can maintain a single codebase that works on both
desktop and Android platforms, with minimal platform-specific code. When we make
improvements to our rendering code, both desktop and Android versions benefit automatically.
In our approach, we compile shaders locally during the build process, similar to how it’s done in
the main project. This strategy offers several significant advantages:
1. Consistency: We use the same shader source files for both desktop and Android builds,
ensuring identical visual results across platforms.
2. Maintainability: When we need to update a shader, we only need to change it in one place, and
both desktop and Android versions benefit.
3. Build-time validation: Shader compilation errors are caught during the build process, not at
runtime, making debugging much easier.
253
Local Shader Compilation
We’ve set up our CMake configuration to compile shaders locally during the build process:
function(add_shaders_target TARGET)
cmake_parse_arguments("SHADER" "" "CHAPTER_NAME" "SOURCES" ${ARGN})
set(SHADERS_DIR ${SHADER_CHAPTER_NAME}/shaders)
add_custom_command(
OUTPUT ${SHADERS_DIR}
COMMAND ${CMAKE_COMMAND} -E make_directory ${SHADERS_DIR}
)
add_custom_command(
OUTPUT ${SHADERS_DIR}/[Link] ${SHADERS_DIR}/[Link]
COMMAND glslang::validator
ARGS --target-env vulkan1.0 ${SHADER_SOURCES} --quiet
WORKING_DIRECTORY ${SHADERS_DIR}
DEPENDS ${SHADERS_DIR} ${SHADER_SOURCES}
COMMENT "Compiling Shaders"
VERBATIM
)
add_custom_target(${TARGET} DEPENDS ${SHADERS_DIR}/[Link]
${SHADERS_DIR}/[Link])
endfunction()
# Compile shaders
254
set(SHADER_SOURCES "${SHADER_OUTPUT_DIR}/27_shader_depth.frag"
"${SHADER_OUTPUT_DIR}/27_shader_depth.vert")
add_shaders_target(android_shaders CHAPTER_NAME "${SHADER_OUTPUT_DIR}" SOURCES
${SHADER_SOURCES})
sourceSets {
main {
assets {
srcDirs = [
// Point to the main project's assets
'../../../../', // For models and textures in the attachments
directory
// Use locally compiled shaders from the build directory for all
ABIs
'.externalNativeBuild/cmake/debug/arm64-v8a/shaders',
'.externalNativeBuild/cmake/debug/armeabi-v7a/shaders',
// ... other ABIs ...
]
}
}
}
We use the same approach to load texture images and model files:
255
// Process the image data...
#else
// Load directly from filesystem
// ...
#endif
This unified approach gives us the best of both worlds: we use the same code structure for both
platforms, with the platform-specific differences handled by the readFile function itself. This makes
our code more maintainable and easier to understand.
Android Studio will handle the rest - it will build the application, compile the shaders, package
everything into an APK, install it on the device/emulator, and launch it. If everything is set up
correctly, you should see your Vulkan application running on Android, rendering the same scene as
on desktop.
Conclusion
In this chapter, we’ve explored how to take our Vulkan application from desktop to mobile by
adapting it for Android. We’ve seen that while the core Vulkan API remains the same across
platforms, the surrounding ecosystem requires platform-specific adaptations.
Our approach demonstrates several key principles that you can apply to your own Vulkan projects:
1. Code Reuse: By structuring our project properly, we can use the same core rendering code
(34_android.cpp) for both desktop and Android platforms, minimizing duplication and
maintenance overhead.
2. Modern Android Integration: We leverage the GameActivity from the Android Game SDK for
better performance and more streamlined integration compared to the older NativeActivity
approach.
3. Efficient Asset Management: Instead of duplicating assets, we reference them from the main
project, ensuring consistency and reducing APK size.
4. Local Shader Compilation: By compiling shaders during the build process, we catch errors
early and ensure compatibility across platforms.
256
keeping our core Vulkan implementation clean and portable.
This approach not only makes it easier to maintain and update our application but also provides a
solid foundation for expanding to other platforms in the future. When you make improvements to
your core rendering code, both desktop and Android versions benefit automatically.
The complete Android example can be found in the attachments/android directory. Feel free to use
it as a template for your own Vulkan projects on Android.
Remember that Vulkan HPP is not included by default in the Android NDK, so you’ll need to
download it separately from the Vulkan-Hpp GitHub repository or use the version included in the
Vulkan SDK.
2. Common image formats like PNG (loaded with stb_image) to KTX2 (loaded with the KTX library)
• More comprehensive model data: glTF supports animations, skeletal rigs, PBR materials, and
more
• GPU-optimized textures: KTX2 supports compressed texture formats, mipmaps, and other
GPU-friendly features
• Industry standard: Both glTF and KTX2 are Khronos standards designed specifically for
modern graphics APIs
Let’s dive into the migration process and see how to adapt our Vulkan application to use these
modern formats.
257
Understanding glTF
What is glTF?
glTF (GL Transmission Format) is a royalty-free specification for the efficient transmission and
loading of 3D scenes and models. Developed by the Khronos Group, glTF is designed to be a "JPEG
for 3D" - a common publishing format for 3D content.
• Runtime-ready: Data is stored in formats that can be directly used by the GPU
Understanding KTX2
What is KTX2?
KTX2 (Khronos Texture 2.0) is a container file format for storing texture data optimized for GPU
usage. It’s designed to work efficiently with modern graphics APIs like Vulkan, OpenGL, and
258
DirectX.
• GPU-ready formats: Supports all GPU texture formats including compressed formats
• Direct uploads: Data can often be uploaded directly to the GPU without processing
Supported features Basic 2D images All GPU texture types (2D, 3D,
cubemaps, arrays)
Setting Up tinygltf
First, we need to include the tinygltf library instead of tinyobjloader:
// Replace this:
#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>
// With this:
#define TINYGLTF_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
259
#include <tiny_gltf.h>
Note that tinygltf uses stb_image internally for image loading, but we’ll be replacing the texture
loading code with KTX2 later.
void loadModel() {
// Use tinygltf to load the model instead of tinyobjloader
tinygltf::Model model;
tinygltf::TinyGLTF loader;
std::string err;
std::string warn;
if (![Link]()) {
std::cout << "glTF warning: " << warn << std::endl;
}
if (![Link]()) {
std::cout << "glTF error: " << err << std::endl;
}
if (!ret) {
throw std::runtime_error("Failed to load glTF model");
}
260
// Get texture coordinates if available
bool hasTexCoords = [Link]("TEXCOORD_0") !=
[Link]();
const tinygltf::Accessor* texCoordAccessor = nullptr;
const tinygltf::BufferView* texCoordBufferView = nullptr;
const tinygltf::Buffer* texCoordBuffer = nullptr;
if (hasTexCoords) {
texCoordAccessor =
&[Link][[Link]("TEXCOORD_0")];
texCoordBufferView = &[Link][texCoordAccessor->bufferView];
texCoordBuffer = &[Link][texCoordBufferView->buffer];
}
// Process vertices
for (size_t i = 0; i < [Link]; i++) {
Vertex vertex{};
// Get position
const float* pos = reinterpret_cast<const
float*>(&[Link][[Link] + [Link] + i * 12]);
[Link] = {pos[0], pos[1], pos[2]};
// Process indices
const unsigned char* indexData =
&[Link][[Link] + [Link]];
261
const uint16_t* indices16 = reinterpret_cast<const
uint16_t*>(indexData);
for (size_t i = 0; i < [Link]; i++) {
Vertex vertex = vertices[indices16[i]];
indices.push_back(uniqueVertices[vertex]);
}
} else if ([Link] ==
TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT) {
const uint32_t* indices32 = reinterpret_cast<const
uint32_t*>(indexData);
for (size_t i = 0; i < [Link]; i++) {
Vertex vertex = vertices[indices32[i]];
indices.push_back(uniqueVertices[vertex]);
}
} else if ([Link] ==
TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE) {
const uint8_t* indices8 = reinterpret_cast<const uint8_t*>(indexData);
for (size_t i = 0; i < [Link]; i++) {
Vertex vertex = vertices[indices8[i]];
indices.push_back(uniqueVertices[vertex]);
}
}
}
}
}
The key differences in this implementation compared to the tinyobjloader version are:
1. Data structure: glTF uses a more complex data structure with accessors, buffer views, and
buffers
2. Attribute access: We need to navigate through these structures to access vertex data
3. Multiple meshes and primitives: glTF models can contain multiple meshes, each with multiple
primitives
4. Component types: We need to handle different index component types (8-bit, 16-bit, 32-bit)
• Scenes and nodes: Process scene hierarchy through [Link] and [Link]
For a complete application, you would typically process these additional features to take full
advantage of glTF.
262
Migrating from stb_image to KTX
For loading KTX files (instead of formats like png or jpeg) we’ll be using the open source KTX
(Khronos Texture) Library and Tools.
Setting Up KTX
First, we need to include the KTX library:
// Replace this:
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
// With this:
#include <ktx.h>
void createTextureImage() {
// Load KTX2 texture instead of using stb_image
ktxTexture* kTexture;
KTX_error_code result = ktxTexture_CreateFromNamedFile(
TEXTURE_PATH.c_str(),
KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT,
&kTexture);
if (result != KTX_SUCCESS) {
throw std::runtime_error("failed to load ktx texture image!");
}
263
memcpy(data, ktxTextureData, imageSize);
[Link]();
The key differences in this implementation compared to the stb_image version are:
Handling Mipmaps
KTX2 files can contain pre-generated mipmaps. Here’s how to use them:
264
};
KTX2 supports GPU texture compression formats. Here’s how to handle them:
265
};
The KTX-Software package provides command-line tools for creating KTX2 files:
• toktx: The primary tool for creating KTX2 files from existing images
Basic usage:
266
Using the KTX Library API
You can also create KTX2 files programmatically using the KTX library API:
#include <ktx.h>
// Write to file
ktxTexture_WriteToNamedFile(ktxTexture(texture), "output.ktx2");
// Clean up
ktxTexture_Destroy(ktxTexture(texture));
delete[] imageData;
Some image editing and 3D modeling software can export directly to KTX2:
267
From PNG/JPEG/TIFF
268
Optimizing KTX2 Files
To get the most out of KTX2 files, consider these optimization techniques:
Compression Options
Mipmap Generation
# Generate mipmaps
toktx --mipmap texture.ktx2 [Link]
Metadata
Command-line Tools
• KTX-Software Suite:
269
• ktx2ktx2: Convert between KTX versions
• KTX-Software Library: C/C++ library for reading, writing, and processing KTX files
• glTF-Transform: JavaScript library that can process KTX2 textures in glTF files
• KTX-Software: A library with tools for creating and manipulating KTX files
Example using toktx to create a KTX2 file with Basis Universal compression:
270
Conclusion
Migrating from OBJ/PNG to glTF/KTX2 brings significant benefits for modern graphics applications:
While the migration requires some code changes, the benefits in terms of performance, features,
and future-proofing make it worthwhile for serious graphics applications.
Overview
When rendering multiple objects, we need to consider which resources should be: 1. Shared across
all objects - to minimize memory usage and state changes 2. Duplicated for each object - to allow
for independent positioning and appearance
Here’s a quick reference for what typically falls into each category:
Shared resources:
• Vertex and index buffers (when objects use the same mesh)
• Render passes
• Command pools
Per-object resources:
271
• Push constants (for small, frequently changing data)
Implementation
Let’s walk through the key changes needed to render multiple objects:
272
Create an Array of GameObjects
In our application class, we’ll replace the single set of uniform buffers and descriptor sets with an
array of GameObjects:
// Initialize the game objects with different positions, rotations, and scales
void setupGameObjects() {
// Object 1 - Center
gameObjects[0].position = {0.0f, 0.0f, 0.0f};
gameObjects[0].rotation = {0.0f, 0.0f, 0.0f};
gameObjects[0].scale = {1.0f, 1.0f, 1.0f};
// Object 2 - Left
gameObjects[1].position = {-2.0f, 0.0f, -1.0f};
gameObjects[1].rotation = {0.0f, glm::radians(45.0f), 0.0f};
gameObjects[1].scale = {0.75f, 0.75f, 0.75f};
// Object 3 - Right
gameObjects[2].position = {2.0f, 0.0f, -1.0f};
gameObjects[2].rotation = {0.0f, glm::radians(-45.0f), 0.0f};
gameObjects[2].scale = {0.75f, 0.75f, 0.75f};
}
This method is called from initVulkan() after loading the model but before creating uniform
buffers.
273
[Link]();
[Link]();
[Link].emplace_back([Link][i].mapMem
ory(0, bufferSize));
}
}
}
void createDescriptorPool() {
// We need MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT descriptor sets
std::array poolSize {
vk::DescriptorPoolSize(vk::DescriptorType::eUniformBuffer, MAX_OBJECTS *
MAX_FRAMES_IN_FLIGHT),
vk::DescriptorPoolSize(vk::DescriptorType::eCombinedImageSampler, MAX_OBJECTS
* MAX_FRAMES_IN_FLIGHT)
};
vk::DescriptorPoolCreateInfo poolInfo{
.flags = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet,
.maxSets = MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT,
.poolSizeCount = static_cast<uint32_t>([Link]()),
.pPoolSizes = [Link]()
};
descriptorPool = vk::raii::DescriptorPool(device, poolInfo);
}
void createDescriptorSets() {
274
// For each game object
for (auto& gameObject : gameObjects) {
// Create descriptor sets for each frame in flight
std::vector<vk::DescriptorSetLayout> layouts(MAX_FRAMES_IN_FLIGHT,
*descriptorSetLayout);
vk::DescriptorSetAllocateInfo allocInfo{
.descriptorPool = *descriptorPool,
.descriptorSetCount = static_cast<uint32_t>([Link]()),
.pSetLayouts = [Link]()
};
[Link]();
[Link] = [Link](allocInfo);
275
Update Uniform Buffers for All Objects
We’ll modify the uniform buffer update to handle all objects:
void updateUniformBuffers() {
static auto startTime = std::chrono::high_resolution_clock::now();
auto currentTime = std::chrono::high_resolution_clock::now();
float time = std::chrono::duration<float>(currentTime - startTime).count();
Note that we’re sharing the view and projection matrices across all objects, but each object has its
own model matrix.
276
// ... (beginning of the method remains the same)
Performance Considerations
When rendering multiple objects, keep these performance considerations in mind:
2. Use instancing for many identical objects (not covered in this tutorial).
3. Consider push constants for small, frequently changing data instead of uniform buffers.
5. Use indirect drawing for large numbers of objects (not covered here).
Conclusion
You’ve now learned how to render multiple objects in Vulkan by:
2. Duplicating the necessary resources with (uniform buffers, descriptor sets) for each object
4. Updating the rendering loop to draw each object with its own transformation
This approach gives you the flexibility to position, rotate, and scale objects independently while
277
maintaining good performance by sharing resources where appropriate.
The foundation you’ve built here will serve as a solid starting point for these more advanced
techniques.
Overview
Vulkan was designed with multithreading in mind, offering several advantages over older APIs:
1. Thread-safe command buffer recording: Multiple threads can record commands to different
command buffers simultaneously.
2. Explicit synchronization: Vulkan requires explicit synchronization, giving you precise control
over resource access across threads.
In this chapter, we’ll implement a multithreaded rendering system that builds upon our previous
work with compute shaders. We’ll create a particle system where:
278
1. One thread handles window events and presentation
2. Multiple worker threads record command buffers for different particle groups
Implementation
Let’s walk through the key components needed to implement multithreading in our Vulkan
application:
public:
// Create a command pool for each worker thread
void createThreadCommandPools(vk::raii::Device& device, uint32_t queueFamilyIndex,
uint32_t threadCount) {
std::lock_guard<std::mutex> lock(resourceMutex);
[Link]();
for (uint32_t i = 0; i < threadCount; i++) {
vk::CommandPoolCreateInfo poolInfo{
.flags = vk::CommandPoolCreateFlagBits::eResetCommandBuffer,
.queueFamilyIndex = queueFamilyIndex
};
commandPools.emplace_back(device, poolInfo);
}
}
279
[Link]();
for (uint32_t i = 0; i < threadCount; i++) {
vk::CommandBufferAllocateInfo allocInfo{
.commandPool = *commandPools[i],
.level = vk::CommandBufferLevel::ePrimary,
.commandBufferCount = buffersPerThread
};
auto threadBuffers = [Link](allocInfo);
for (auto& buffer : threadBuffers) {
commandBuffers.emplace_back(std::move(buffer));
}
}
}
class MultithreadedApplication {
private:
// Thread-related members
uint32_t threadCount;
std::vector<std::thread> workerThreads;
std::atomic<bool> shouldExit{false};
std::vector<std::atomic<bool>> threadWorkReady;
std::vector<std::atomic<bool>> threadWorkDone;
// Synchronization primitives
std::mutex queueSubmitMutex;
std::condition_variable workCompleteCv;
// Resource manager
ThreadSafeResourceManager resourceManager;
280
// ... other Vulkan resources ...
public:
void initThreads() {
// Determine the number of threads to use (leave one core for the main thread)
threadCount = std::max(1u, std::thread::hardware_concurrency() - 1);
281
// Record commands for this particle group
recordComputeCommandBuffer(cmdBuffer, [Link], [Link]);
// Add a push constant to specify the particle range for this thread
struct PushConstants {
uint32_t startIndex;
uint32_t count;
} pushConstants{startIndex, count};
[Link]<PushConstants>(*computePipelineLayout,
vk::ShaderStageFlagBits::eCompute, 0, pushConstants);
[Link]();
}
void signalThreadsToWork() {
// Signal all threads to start working
for (uint32_t i = 0; i < threadCount; i++) {
threadWorkDone[i] = false;
threadWorkReady[i] = true;
}
}
void waitForThreadsToComplete() {
// Wait for all threads to complete their work
std::unique_lock<std::mutex> lock(queueSubmitMutex);
[Link](lock, [this]() {
282
for (uint32_t i = 0; i < threadCount; i++) {
if (!threadWorkDone[i]) {
return false;
}
}
return true;
});
}
void cleanup() {
// Signal threads to exit and join them
shouldExit = true;
for (auto& thread : workerThreads) {
if ([Link]()) {
[Link]();
}
}
[numthreads(256,1,1)]
void compMain(uint3 threadId : SV_DispatchThreadID)
{
uint index = threadId.x;
283
uint globalIndex = [Link] + index;
void drawFrame() {
// Wait for the previous frame to finish
auto fenceResult = [Link](*inFlightFences[frameIndex], vk::True,
UINT64_MAX);
if (fenceResult != vk::Result::eSuccess)
{
throw std::runtime_error("failed to wait for fence!");
}
284
// While worker threads are busy, record the graphics command buffer on the main
thread
recordGraphicsCommandBuffer(imageIndex);
{
std::lock_guard<std::mutex> lock(queueSubmitMutex);
[Link](computeSubmitInfo, nullptr);
}
{
std::lock_guard<std::mutex> lock(queueSubmitMutex);
[Link](*inFlightFences[frameIndex]);
[Link](graphicsSubmitInfo, *inFlightFences[frameIndex]);
}
285
.swapchainCount = 1,
.pSwapchains = &*swapChain,
.pImageIndices = &imageIndex
};
result = [Link](presentInfo);
// In worker thread:
vk::CommandBufferInheritanceInfo inheritanceInfo{
.renderPass = *renderPass,
.subpass = 0,
.framebuffer = *framebuffers[imageIndex]
};
vk::CommandBufferBeginInfo beginInfo{
.flags = vk::CommandBufferUsageFlagBits::eRenderPassContinue,
.pInheritanceInfo = &inheritanceInfo
};
[Link](beginInfo);
// Record rendering commands...
[Link]();
// In main thread:
[Link]({});
[Link](...);
[Link](secondaryCommandBuffers);
286
[Link]();
[Link]();
class ThreadPool {
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queueMutex;
std::condition_variable condition;
bool stop;
public:
ThreadPool(size_t threads) : stop(false) {
for (size_t i = 0; i < threads; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queueMutex);
[Link](lock, [this] { return stop || ![Link]();
});
if (stop && [Link]()) {
return;
}
task = std::move([Link]());
[Link]();
}
task();
}
});
}
}
template<class F>
void enqueue(F&& f) {
{
std::unique_lock<std::mutex> lock(queueMutex);
[Link](std::forward<F>(f));
}
condition.notify_one();
}
~ThreadPool() {
{
287
std::unique_lock<std::mutex> lock(queueMutex);
stop = true;
}
condition.notify_all();
for (std::thread& worker : workers) {
[Link]();
}
}
};
Performance Considerations
When implementing multithreading in Vulkan, keep these performance considerations in mind:
1. Thread Creation Overhead: Creating threads has overhead, so create them once at startup
rather than per-frame.
2. Work Granularity: Ensure each thread has enough work to justify the threading overhead.
3. False Sharing: Be aware of cache line contention when multiple threads access adjacent
memory.
5. Memory Barriers: Use memory barriers correctly to ensure visibility of memory operations
across threads.
6. Command Pool Per Thread: Each thread should have its own command pool to avoid
synchronization overhead.
288
Debugging Multithreaded Vulkan
Applications
Debugging multithreaded applications can be challenging. Here are some tips:
4. Simplify: Start with a simpler threading model and gradually add complexity.
5. Atomic Operations: Use atomic operations for thread-safe counters and flags.
Conclusion
In this chapter, we’ve explored how to leverage multithreading with Vulkan to improve
performance. We’ve implemented a multithreaded particle system where:
By distributing work across multiple CPU cores, we can significantly improve performance,
especially for computationally intensive applications. Vulkan’s explicit design makes it well-suited
for multithreaded architectures, allowing for fine-grained control over synchronization and
resource access.
As you continue to develop your Vulkan applications, consider how multithreading can help you
leverage the full power of modern CPUs, and remember to always measure performance to ensure
your threading model is actually beneficial for your specific use case.
C++ code = SIGGRAPH 2025: Hands-on Vulkan Ray Tracing with Dynamic Rendering
Overview
Welcome! In this series, we enhance a Vulkan renderer with ray tracing features to implement real-
time pixel-perfect shadows (with and without transparency) and a bonus reflection effect. You will
work with provided scaffolded code (based on the Vulkan Tutorial) and fill in key shader functions
following step-by-step instructions.
• Use Vulkan dynamic rendering (no pre-defined render passes) and verify it with RenderDoc.
• Create bottom-level and top-level Acceleration Structures (BLAS and TLAS) for ray tracing.
289
• Implement ray query based shadow rays (first with all-opaque geometry, then with alpha-test
transparency).
Prerequisites: This series targets intermediate Vulkan programmers. We assume you have
completed the basic Vulkan Tutorial (graphics pipeline, descriptor sets, etc.). If not, you can still
follow along conceptually. A Windows machine with up-to-date Vulkan SDK (1.4.311), a GPU
supporting ray tracing (Vulkan Ray Query), plus RenderDoc and Nsight Graphics is provided.
• We use VK_KHR_dynamic_rendering (core in Vulkan 1.3) instead of traditional render passes. This
not only simplifies the API but also, with the VK_KHR_dynamic_rendering_local_read extension,
enables tile-local storage reads similar to subpasses, a big deal for mobile tile-based GPUs to
save bandwidth.
• We use Ray Queries (from VK_KHR_ray_query) within fragment shaders instead of a separate ray
tracing pipeline. On mobile, ray queries are far more widely supported and often (depending on
the use case) more efficient than the full ray tracing pipeline. Ray queries integrate nicely into
fragment shading, benefiting from on-chip compression and avoiding context switches.
Provided Code: The provided code is a Vulkan renderer that already implements a basic graphics
pipeline with dynamic rendering. It is based on the Vulkan Tutorial and it is self-contained in a
single C++ source file and a Slang shader file:
• 38_ray_tracing.cpp
• 38_ray_tracing.slang
• If you get stuck on shader tasks, you can refer to the provided reference solution:
38_ray_tracing_complete.slang
The source code is structured to guide you through the steps, with hints and boilerplate provided. It
also sets up all the required extensions and features, including VK_KHR_acceleration_structure and
VK_KHR_ray_query. You will find sections of code marked with // TASKxx which we reference in each
chapter.
#define LAB_TASK_LEVEL 1
At certain intervals, you will be instructed to update this variable and re-build to verify the effect of
key changes, no need to write new code.
290
• Dynamic rendering
• Acceleration structures
• TLAS animation
• Shadow transparency
• Reflections
• Conclusion
Note that the above hasn’t changed from the base tutorial, and you may continue to build on other
platforms as you have for the rest of the tutorial.
Navigation
• Next: Dynamic rendering = Dynamic Rendering
Objective: Ensure the base project uses dynamic rendering and understand how to verify it using
RenderDoc.
291
.colorAttachmentCount = 1,
.pColorAttachmentFormats = &swapChainImageFormat,
.depthAttachmentFormat = depthFormat
};
vk::GraphicsPipelineCreateInfo pipelineInfo{
.pNext = &pipelineRenderingCreateInfo,
.stageCount = 2,
.pStages = shaderStages,
.pVertexInputState = &vertexInputInfo,
.pInputAssemblyState = &inputAssembly,
.pViewportState = &viewportState,
.pRasterizationState = &rasterizer,
.pMultisampleState = &multisampling,
.pDepthStencilState = &depthStencil,
.pColorBlendState = &colorBlending,
.pDynamicState = &dynamicState,
.layout = pipelineLayout,
.renderPass = nullptr
};
And later on, the command buffer recording where we begin rendering:
vk::RenderingAttachmentInfo colorAttachmentInfo = {
.imageView = swapChainImageViews[imageIndex],
.imageLayout = vk::ImageLayout::eColorAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.clearValue = clearColor
};
vk::RenderingAttachmentInfo depthAttachmentInfo = {
.imageView = depthImageView,
.imageLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eDontCare,
.clearValue = clearDepth
};
292
// The vk::RenderingInfo structure combines these attachments with other rendering
parameters.
vk::RenderingInfo renderingInfo = {
.renderArea = { .offset = { 0, 0 }, .extent = swapChainExtent },
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachmentInfo,
.pDepthAttachment = &depthAttachmentInfo
};
In the Event Browser, you should see the calls that confirm that dynamic rendering is set up
correctly:
293
2. VkRenderingInfoKHR replacing the old render pass/framebuffer concept.
294
In RenderDoc’s Texture Viewer, you can inspect the color and depth attachments at various points:
After this step, you should be comfortable that dynamic rendering is set up correctly. We can now
move on to ray tracing features.
Navigation
• Previous: Overview
Objective: Create Bottom-Level Acceleration Structures (BLAS) for the model’s geometry, and a
Top-Level Acceleration Structure (TLAS) to instance those BLASes. Bind the TLAS to the shader so
we can use Ray Queries.
When casting a ray in a scene, we need an optimized structure that quickly identifies which
triangle the ray hits. GPUs use acceleration structures that group geometry into bounding boxes,
allowing large parts of the scene to be skipped. The ray traversal proceeds down a tree, efficiently
narrowing down to the intersected triangle. The exact implementation is GPU-dependent and
opaque to the user.
295
Our scene is a simple 3D model (a plant on a table) loaded from an OBJ file. The provided code
already loads the model’s vertices, indices, normals, and textures into buffers. It separates the
model into submeshes, each with its own material.
A BLAS holds the geometry (triangles) for one mesh or object. A TLAS holds instances of BLASes
(with transforms) to form the full scene. We’ll create one BLAS per distinct mesh/material and one
TLAS that references them. The ray query will use the TLAS. In Vulkan, building an AS involves a
few steps: describe geometry, query build sizes, allocate buffers, create the AS handle, then issue a
296
build command.
vk::AccelerationStructureGeometryDataKHR geometryData(trianglesData);
vk::AccelerationStructureGeometryKHR blasGeometry{
.geometryType = vk::GeometryTypeKHR::eTriangles,
.geometry = geometryData,
.flags = vk::GeometryFlagBitsKHR::eOpaque
};
vk::AccelerationStructureBuildGeometryInfoKHR blasBuildGeometryInfo{
.type = vk::AccelerationStructureTypeKHR::eBottomLevel,
.mode = vk::BuildAccelerationStructureModeKHR::eBuild,
.geometryCount = 1,
.pGeometries = &blasGeometry,
};
// TASK02: Query the memory sizes that will be needed for this BLAS
vk::AccelerationStructureBuildSizesInfoKHR blasBuildSizes =
[Link](
vk::AccelerationStructureBuildTypeKHR::eDevice,
blasBuildGeometryInfo,
{ primitiveCount }
297
);
This helper function uses vkGetAccelerationStructureBuildSizesKHR() and returns the memory sizes
needed for the BLAS. We need to allocate:
2. Another buffer for the scratch space used during the build process.
We can then create these buffers and store them in persistent arrays as they will be needed later.
We also need to create the BLAS handle itself, which is done with
vk::AccelerationStructureCreateInfoKHR and this device function helper that uses
vkCreateAccelerationStructureKHR(). The handle is stored in a vector for later use (remember that
we need one for each submesh):
blasHandles.emplace_back([Link](blasCreateInfo));
The following diagram summarizes all the structures and buffers we have created so far:
298
To put it all together, we need to submit a command buffer to build the BLAS on the GPU. This is
done with vkCmdBuildAccelerationStructuresKHR(), which takes the build info and a range. The
range adds flexibility to build multiple geometries in one go, but here we only have one geometry
per BLAS so it is kept simple:
Finally, prepare and submit a command buffer, which saves a valid handle for the bottom level
acceleration structure:
299
Now you have a BLAS for each model in the scene. Next we need to put them all together into a
single TLAS which will then be consumed by our fragment shader.
We can create an instance in the same submesh loop where we created the BLASes. For each
submesh, we will create an instance that references the corresponding BLAS handle. The
vk::AccelerationStructureInstanceKHR struct is used for this purpose:
vk::AccelerationStructureInstanceKHR instance{
.transform = tm,
.mask = 0xFF,
.accelerationStructureReference = blasDeviceAddr
};
instances.push_back(instance);
Note how we needed to get the device address of the BLAS using
vkGetAccelerationStructureDeviceAddressKHR(). We also set the transform matrix as the identity
300
matrix for now, we will revisit this later in the lab.
Now that all instances are stored in a vector, we need to prepare the instance data for the TLAS.
This involves creating a buffer that holds the instance data.
Using a very similar approach as for the BLAS, we need to prepare the data for the TLAS build,
query buffer sizes, allocate buffers, create the TLAS handle, and issue a build command. The
diagram below highlights the main changes needed for the TLAS:
To prepare the geometry data for the TLAS we will use vk::GeometryTypeKHR::eInstances to indicate
that we are building a TLAS from instances of BLASes:
vk::AccelerationStructureGeometryDataKHR geometryData(instancesData);
vk::AccelerationStructureGeometryKHR tlasGeometry{
.geometryType = vk::GeometryTypeKHR::eInstances,
.geometry = geometryData
};
301
This is then recorded in the build info structure:
vk::AccelerationStructureBuildGeometryInfoKHR tlasBuildGeometryInfo{
.type = vk::AccelerationStructureTypeKHR::eTopLevel,
.mode = vk::BuildAccelerationStructureModeKHR::eBuild,
.geometryCount = 1,
.pGeometries = &tlasGeometry
};
// TASK03: Query the memory sizes that will be needed for this TLAS
vk::AccelerationStructureBuildSizesInfoKHR tlasBuildSizes =
[Link](
vk::AccelerationStructureBuildTypeKHR::eDevice,
tlasBuildGeometryInfo,
{ primitiveCount }
);
tlas = [Link](tlasCreateInfo);
And one more time, we need to prepare the build range for the TLAS. This is similar to the BLAS,
but now we use the instance count. Then we can submit the command buffer to build the TLAS:
302
// TASK03: Build the TLAS
auto cmd = beginSingleTimeCommands();
cmd->buildAccelerationStructuresKHR({ tlasBuildGeometryInfo }, { &tlasRangeInfo });
endSingleTimeCommands(*cmd);
Done! You have now created a TLAS that references all the BLASes for the submeshes in the model.
The TLAS is ready to be used in ray queries in the fragment shader.
Next, we need to update the descriptor set to bind the TLAS. This is done in the
updateDescriptorSets() function:
vk::WriteDescriptorSetAccelerationStructureKHR asInfo{
.accelerationStructureCount = 1,
.pAccelerationStructures = {&*tlas}
};
vk::WriteDescriptorSet asWrite{
.pNext = &asInfo,
.dstSet = globalDescriptorSets[i],
.dstBinding = 1,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk::DescriptorType::eAccelerationStructureKHR
};
303
And later on call vkUpdateDescriptorSets() with the TLAS included in the list:
[Link](descriptorWrites, {});
#define LAB_TASK_LEVEL 4
You will see no visual difference, but rest assured, your Acceleration Structures are now set up and
ready to be used in the fragment shader.
Navigation
• Previous: Dynamic rendering
Objective: Add a simple shadow test in the fragment shader using a ray query. We will cast a ray
from each fragment point toward the light and darken the fragment if something is hit (hard
shadow).
Congratulations: you have a valid TLAS/BLAS for the scene! Now, let’s use it to cast some rays.
304
// TASK05: Implement ray query shadows
bool in_shadow(float3 P)
{
bool hit = false;
return hit;
}
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
float4 baseColor = textures[[Link]].Sample(textureSampler,
[Link]);
float3 P = [Link];
// Darken if in shadow
if (inShadow) {
[Link] *= 0.2;
}
return baseColor;
}
For this, you will implement a helper in_shadow() function that performs the ray query. Start by
defining a ray description and initializing it with the fragment position and light direction:
bool in_shadow(float3 P)
{
// Build the shadow ray from the world position toward the light
RayDesc shadowRayDesc;
[Link] = P;
[Link] = normalize(lightDir);
[Link] = EPSILON;
305
[Link] = 1e4;
TMin and TMax define the minimum and maximum distance the ray will travel from its origin.
EPSILON is a small value to avoid self-intersection, and 1e4 is a large value to ensure we can hit
distant objects.
Next, we will initialize a RayQuery object which will be used to perform the ray traversal. Note the
choice of flags that we use to make it faster:
Then we will start the ray tracing operation which combines our ray description, RayQuery object,
and acceleration structure:
[Link]();
Proceed() advances the state of the RayQuery object to the next intersection "candidate" along the
ray. Each call to Proceed() checks if there is another intersection to process. If so, it updates the
query’s internal state so that you may access information about the current candidate intersection.
This allows you to implement custom logic for handling intersections, such as skipping transparent
surfaces (which we will revisit later in this lab) or stopping at the first opaque hit. It is typically
called within a loop to iterate through all potential intersections, but for shadows we only need the
first hit:
return hit;
}
That’s it! You have implemented a basic shadow test using ray queries. The in_shadow() function
will return true if the ray hits any geometry before reaching the light, indicating that the fragment
is in shadow.
306
Re-build and run using:
#define LAB_TASK_LEVEL 5
The object is rotating, but the shadows are static. This is because we have not yet updated the TLAS
to account for the object’s animation. The TLAS needs to be rebuilt whenever the object moves or
animates, so let’s implement that next.
Navigation
• Previous: Acceleration structures
Objective: Ensure shadows update when the object animates by rebuilding the TLAS with updated
instance transforms each frame.
To account for the object’s animation, we need to update the TLAS whenever the object moves or
changes. This involves updating the instance transforms and rebuilding the TLAS. We will do this in
the updateTopLevelAS() function, which is called every frame with the current model matrix.
vk::TransformMatrixKHR tm{};
auto &M = model;
[Link] = std::array<std::array<float,4>,3>{{
std::array<float,4>{M[0][0], M[1][0], M[2][0], M[3][0]},
std::array<float,4>{M[0][1], M[1][1], M[2][1], M[3][1]},
std::array<float,4>{M[0][2], M[1][2], M[2][2], M[3][2]}
}};
Next, we need to prepare the geometry data for the TLAS build. This is similar to what we did when
creating the TLAS, but now we will use the updated instance buffer. We also need to change the
build mode to eUpdate, and define a source TLAS as well as a destination TLAS. This instructs the
307
implementation to update the existing TLAS in-place instead of creating a new one. This is more
efficient when only minor changes (like transforms) have occurred:
vk::AccelerationStructureGeometryDataKHR geometryData(instancesData);
vk::AccelerationStructureGeometryKHR tlasGeometry{
.geometryType = vk::GeometryTypeKHR::eInstances,
.geometry = geometryData
};
We may keep re-using the same scratch buffer. Note that another implementation hint is needed, in
the form of the flag eAllowUpdate, to specify that we intend to update this TLAS. We also need to
revisit the createAccelerationStructures() function to add this flag the first time we create the
TLAS:
vk::AccelerationStructureBuildGeometryInfoKHR tlasBuildGeometryInfo{
.type = vk::AccelerationStructureTypeKHR::eTopLevel,
.flags = vk::BuildAccelerationStructureFlagBitsKHR::eAllowUpdate, // <----
TASK06
.mode = vk::BuildAccelerationStructureModeKHR::eBuild,
.geometryCount = 1,
.pGeometries = &tlasGeometry
};
Next, we need to prepare the build range for the TLAS. This is similar to what we did when creating
308
the TLAS:
Finally, we can issue the command to rebuild the TLAS. A main change is required here though,
regarding synchronization. Since we are calling updateTopLevelAS() every frame, we need a pre-
build memory barrier to ensure that any previous writes to the acceleration structure transfers, or
shader reads of previous frames, are completed before the build begins:
// Pre-build barrier
vk::MemoryBarrier preBarrier {
.srcAccessMask = vk::AccessFlagBits::eAccelerationStructureWriteKHR |
vk::AccessFlagBits::eTransferWrite | vk::AccessFlagBits::eShaderRead,
.dstAccessMask = vk::AccessFlagBits::eAccelerationStructureReadKHR |
vk::AccessFlagBits::eAccelerationStructureWriteKHR
};
cmd->pipelineBarrier(
vk::PipelineStageFlagBits::eAccelerationStructureBuildKHR |
vk::PipelineStageFlagBits::eTransfer | vk::PipelineStageFlagBits::eFragmentShader, //
srcStageMask
vk::PipelineStageFlagBits::eAccelerationStructureBuildKHR, // dstStageMask
{}, // dependencyFlags
preBarrier, // memoryBarriers
{}, // bufferMemoryBarriers
{} // imageMemoryBarriers
);
cmd->buildAccelerationStructuresKHR({ tlasBuildGeometryInfo }, {
&tlasRangeInfo });
Similarly, we need a post-build barrier to ensure that all writes to the acceleration structure during
the build are visible to subsequent reads or shader accesses:
// Post-build barrier
vk::MemoryBarrier postBarrier {
.srcAccessMask = vk::AccessFlagBits::eAccelerationStructureWriteKHR,
.dstAccessMask = vk::AccessFlagBits::eAccelerationStructureReadKHR |
vk::AccessFlagBits::eShaderRead
309
};
cmd->pipelineBarrier(
vk::PipelineStageFlagBits::eAccelerationStructureBuildKHR, // srcStageMask
vk::PipelineStageFlagBits::eAccelerationStructureBuildKHR |
vk::PipelineStageFlagBits::eFragmentShader, // dstStageMask
{}, // dependencyFlags
postBarrier, // memoryBarriers
{}, // bufferMemoryBarriers
{} // imageMemoryBarriers
);
endSingleTimeCommands(*cmd);
These barriers are crucial for correct synchronization, preventing race conditions and ensuring the
acceleration structure is in a valid state for ray tracing shaders.
Verify that the function is called in drawFrame() after the model matrix is updated:
updateUniformBuffer(frameIndex);
// TASK06: Update the TLAS with the current model matrix
updateTopLevelAS([Link]);
#define LAB_TASK_LEVEL 6
Now the shadows should correctly update since the acceleration structure and geometry
animations are in sync:
For reference, here is how the full shader should look like at this stage:
struct VSInput {
float3 inPosition;
float3 inColor;
float2 inTexCoord;
float3 inNormal;
};
struct UniformBuffer {
float4x4 model;
float4x4 view;
float4x4 proj;
float3 cameraPos;
310
};
[[vk::binding(0,0)]]
ConstantBuffer<UniformBuffer> ubo;
[[vk::binding(2,0)]]
StructuredBuffer<uint> indexBuffer;
[[vk::binding(3,0)]]
StructuredBuffer<float2> uvBuffer;
struct InstanceLUT {
uint materialID;
uint indexBufferOffset;
};
[[vk::binding(4,0)]]
StructuredBuffer<InstanceLUT> instanceLUTBuffer;
struct VSOutput
{
float4 pos : SV_Position;
float3 fragColor;
float2 fragTexCoord;
float3 fragNormal;
float3 worldPos;
};
[shader("vertex")]
VSOutput vertMain(VSInput input) {
VSOutput output;
[Link] = mul([Link], mul([Link], mul([Link], float4([Link],
1.0))));
[Link] = [Link];
[Link] = [Link];
[Link] = [Link];
[Link] = mul([Link], float4([Link], 1.0)).xyz;
return output;
}
[[vk::binding(0,1)]]
SamplerState textureSampler;
[[vk::binding(1,1)]]
Texture2D<float4> textures[];
struct PushConstant {
uint materialIndex;
};
311
[push_constant]
PushConstant pc;
[Link]();
return hit;
}
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
float4 baseColor = textures[[Link]].Sample(textureSampler,
[Link]);
float3 P = [Link];
// Darken if in shadow
if (inShadow) {
[Link] *= 0.2;
}
return baseColor;
312
}
Ray Query vs Ray Tracing Pipeline: Notice how we added a ray tracing effect
(shadows) directly in the fragment shader. We did not need a separate ray
generation shader or any new pipeline. This is the power of ray queries (also known
as inline ray tracing): we integrate ray traversal into our existing rendering
NOTE pipeline. This keeps the shader logic unified and avoids extra GPU shader launches.
On many mobile GPUs, this approach is not only more convenient but necessary: as
mentioned, current mobile devices mostly support ray queries and not the full ray
pipeline, and they run ray queries efficiently in fragment shaders. This is a key
reason we focus on ray queries in this lab.
Navigation
• Previous: Ray query shadows
Objective: Add support for transparent objects in the scene. We will implement a simple alpha test
to discard fragments with low alpha values, and replicate this in their corresponding ray traced
shadows.
So far we have treated every geometry as opaque. Note however that the leaves are rendered with
a texture that uses the alpha channel to define transparency, and currently that is not taken into
account, so that we have some dark pixels around the edges of the leaves:
313
Task 7: Alpha-cut transparency
To implement alpha-cut transparency, we will discard fragments with low alpha values in the
fragment shader. This is a common technique to handle transparent textures without needing
complex blending or sorting:
// Alpha test
if (baseColor.a < 0.5) discard;
314
Note that the shadows remain unchanged for now. The first thing we need to do is to only use the
eOpaque flag if the BLAS has no alpha transparency, in createAccelerationStructures():
vk::AccelerationStructureGeometryKHR blasGeometry{
.geometryType = vk::GeometryTypeKHR::eTriangles,
.geometry = geometryData
};
#define LAB_TASK_LEVEL 7
315
You will see that the shadows for the leaves are now completely missing! This is because out of all
the intersection candidates, only opaque geometry triangles are automatically committed. We need
to implement a way to handle transparency in the ray query shadows and conditionally commit the
candidate intersection.
Before we do that, let’s first inspect the acceleration structures we have built so far, to understand
how they are structured and what information is available for each triangle.
To use Nsight:
316
Then capture a frame and click on "Start Graphics Debugger":
Similar to RenderDoc, you can inspect the events and find the acceleration structure build
commands, which will then take us to the acceleration structure visualization tool:
1. Use the search function in the Event browser to filter and find the draw calls.
2. Select any of the draw calls, which opens the API Inspector.
4. Click on the acceleration structure binding (this will be our TLAS), which opens the "Ray Tracing
Inspector".
317
In the new window, you can see the TLAS and its instances:
1. You can expand each instance to see the BLAS it references, and inspect the geometry data.
2. You may find the 'Orbit Camera' more comfortable for navigating the scene.
3. You may need to set the "Up Direction" to "Z Axis" to match the coordinate system used in the
model.
4. Use filters for 'Opaque' gemoetry (as expected, the leave models will be greyed out).
318
Task 9: Bindless resources and instance look-
up table
Until now, we did not need to know what triangle our ray intersected, we only cared about whether
it hit something or not. But to implement transparency, we need to know the alpha value of the
texture used to shade the point on the triangle we hit. This way we can determine if we hit a
transparent pixel and we need to continue the traversal, in case we hit some other opaque triangle
behind it on the way towards the light.
Before introducing the ray query logic needed for this, let’s first observe how the renderer binds
everything we need in the shader, and implement a simple look-up table to map acceleration
structure instances to their geometry and texturing data.
Note how the renderer does not bind separate material textures for each submesh. Instead, it binds
a single array of textures and uses a material index to look up the texture for each submesh. We use
push constants to pass the material index:
commandBuffers[frameIndex].drawIndexed([Link], 1, [Link],
0, 0);
}
Then, in the shader, we use the material index to sample the texture array. They all share the same
sampler:
[[vk::binding(0,1)]]
SamplerState textureSampler;
[[vk::binding(1,1)]]
Texture2D<float4> textures[];
struct PushConstant {
uint materialIndex;
};
[push_constant]
PushConstant pc;
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
319
float4 baseColor = textures[[Link]].Sample(textureSampler,
[Link]);
This is a common technique called "bindless resources", which allows us to reduce the number of
descriptor sets and bindings needed, and makes it easier to manage materials in a scene with many
objects. It requires the descriptor indexing extension, which is core to Vulkan since 1.2.
We cannot use push constants in our ray traversal, because our ray may hit any geometry in the
scene, not the one we are shading now. We can however tag each acceleration structure instance
with a custom index, and later use this index with a look-up table (LUT) to find the geometry and
texture for the hit instance.
vk::AccelerationStructureInstanceKHR instance{
.transform = identity,
.mask = 0xFF,
.accelerationStructureReference = blasDeviceAddr
};
instances.push_back(instance);
instances[i].instanceCustomIndex = static_cast<uint32_t>(i);
If you run the application now and capture it with Nsight Graphics, you will be able to color by
"Instance Custom Index" to see the indices assigned to each instance, whereas before they were all
the same:
320
Then, populate a vector of LUT entries. Using the same submesh index, we need to store the
material ID and the index buffer offset for each submesh:
The rest of the code related to creating the LUT buffer can be found in createDescriptorSets() and
createInstanceLUTBuffer(). Note that the corresponding binding was already defined in the shader:
Now we will see how we can use these resources with ray query to handle transparent
intersections.
First, replace the call with a loop, and retrieve the necessary attributes from the candidate hit. We
will then pass these over to a helper function, intersection_uv, which will retrieve the texture
coordinates for the point we hit within the triangle:
321
while ([Link]())
{
uint instanceID = [Link]();
uint primIndex = [Link]();
1. instanceID allows us to retrieve indexBufferOffset and materialID from the instance LUT.
2. indexBufferOffset is used to find the index buffer for the instance. Note that the index buffer
contains the indices for all the models in the scene, so we need to narrow it down to the hit
model (e.g. leaves).
3. primIndex is the index of the triangle within the instance’s portion of the index buffer.
4. NonUniformResourceIndex() indicates that a resource index may vary across different shader
invocations within a single draw or dispatch call, preventing unwanted compiler optimizations.
Once we have narrowed the hit down to a specific triangle within the model, we can retrieve the
texture coordinates for it in the uvBuffer, which contains the UV coordinates for all vertices in the
scene.
Finally, it interpolates the texture coordinates for the hit triangle based on the barycentric
coordinates of the intersection.
We can then use these UV coordinates to sample the texture and retrieve the alpha value:
322
uint materialID =
instanceLUTBuffer[NonUniformResourceIndex(instanceID)].materialID;
float4 intersection_color =
textures[NonUniformResourceIndex(materialID)].SampleLevel(textureSampler, uv, 0);
And based on the alpha value, we can decide whether to continue tracing or commit the hit:
while ([Link]())
{
uint instanceID = [Link]();
uint primIndex = [Link]();
uint materialID =
instanceLUTBuffer[NonUniformResourceIndex(instanceID)].materialID;
float4 intersection_color =
textures[NonUniformResourceIndex(materialID)].SampleLevel(textureSampler, uv, 0);
Note that opaque hits are committed automatically, and never enter the loop.
#define LAB_TASK_LEVEL 10
At this point, you have robust shadows with transparency via ray queries! This is a significant
323
feature, something that would be difficult with traditional shadow mapping for fine alpha details:
With everything set in place to support transparency in shadows, implementing other effects like
reflections is very straightforward!
Navigation
• Previous: TLAS animation
• Next: Reflections
324
hits, simulating reflective materials (like a mirror or shiny surface).
Reflections are implemented similarly to shadow rays, but we cast a ray from the shaded point
along the mirror direction and sample the hit surface color.
Let’s pretend that it is been a rainy day, and the table is covered in water, so it reflects the
environment.
We need to update the PushConstant struct to include a reflective flag, both in the renderer:
struct PushConstant {
uint32_t materialIndex;
uint32_t reflective;
};
struct PushConstant {
uint materialIndex;
uint reflective;
};
[push_constant]
PushConstant pc;
And update the values that we assign to it before issuing the draw call:
PushConstant pushConstant = {
.materialIndex = [Link] < 0 ? 0u : static_cast<uint32_t>([Link]),
.reflective = [Link]
};
commandBuffers[frameIndex].pushConstants<PushConstant>(pipelineLayout,
vk::ShaderStageFlagBits::eFragment, 0, pushConstant);
We will then retrieve this in the fragment shader, before we apply the shadow effect, to call a
helper function that will modify the fragment color in-place, based on the reflection ray query:
float3 P = [Link];
float3 N = [Link];
325
if ([Link] > 0) {
apply_reflection(P, N, baseColor);
}
The implementation of the apply_reflection() function will be similar to the in_shadow() function.
The Proceed() loops is no longer optional, as we do not only need to check for any intersection, we
need the full color of the closest hit triangle to apply the reflection effect.
Note how it requires the normal direction (N). This is because reflections are a function of the
surface normal and the view direction V. The reflection direction R is calculated easily with the
built-in reflect() function:
We then define the ray description, similar to how we did for shadows:
RayDesc reflectionRayDesc;
[Link] = P;
[Link] = R;
[Link] = EPSILON;
[Link] = 1e4;
And initialize the RayQuery object. In this case however, we cannot use the
RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH flag, because we need to retrieve the full color of the
closest triangle, not just any triangle:
326
let rayFlags = RAY_FLAG_SKIP_PROCEDURAL_PRIMITIVES;
while ([Link]())
{
uint instanceID = [Link]();
uint primIndex = [Link]();
uint materialID =
instanceLUTBuffer[NonUniformResourceIndex(instanceID)].materialID;
float4 intersection_color =
textures[NonUniformResourceIndex(materialID)].SampleLevel(textureSampler, uv, 0);
The only additional logic we need is to retrieve the color of the hit triangle and apply it to the base
color of the fragment. Note how the logic is almost the same as in the loop, but this time we use the
Committed version of the functions, rather than Candidate:
if (hit)
{
uint instanceID = [Link]();
uint primIndex = [Link]();
uint materialID =
instanceLUTBuffer[NonUniformResourceIndex(instanceID)].materialID;
float4 intersectionColor =
327
textures[NonUniformResourceIndex(materialID)].SampleLevel(textureSampler, uv, 0);
As an exercise, you could extend this function to sample a skybox if the ray misses all
TIP
the geometry in the scene and there is no committed triangle hit.
#define LAB_TASK_LEVEL 11
With all this in place, you should now see some shiny reflections on the table:
328
Navigation
• Previous: Shadow transparency
In this course, you’ve implemented ray traced effects into a Vulkan rasterization pipeline using
dynamic rendering and ray queries. Let’s summarize the key points:
• Dynamic rendering: Simplifies render pass setup and is now the preferred way to start
rendering in Vulkan. We verified its usage via RenderDoc. Especially for mobile, it’s a boon
when combined with extensions for local attachment reads.
• Acceleration structures: We created BLASes and a TLAS from a loaded model. Using Nsight
Graphics, we confirmed the structures were built correctly.
• Ray queries for shadows: We cast shadow rays in the fragment shader. Initially considering
only opaque geometry, then we refined it to handle alpha-tested transparency by manually
checking texture alpha for intersections.
• Ray queries for reflections: As a bonus, we shot reflection rays and used the hit result to
modulate the fragment color for reflective materials. This leveraged the same acceleration
structure and used similar Proceed() loop logic. You can imagine extending this to refractions,
ambient occlusion rays, etc.
We hope this lab gave you a hands-on taste of hybrid rendering with Vulkan’s latest features.
Happy rendering with Vulkan, and enjoy creating more advanced ray traced effects in your
applications!
References
• Complete the full Vulkan Tutorial at [Link]
The 3D assets were provided by Poly Haven and combined using Blender:
• [Link]
• [Link]
• [Link]
329
Navigation
• Previous: Reflections = Frequently Asked Questions
This page lists solutions to common problems that you may encounter while developing Vulkan
applications.
• Delete the Steam overlay Vulkan layer entry in the registry under
HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ImplicitLayers
Example:
330
vkCreateInstance fails with
VK_ERROR_INCOMPATIBLE_DRIVER
If you are using MacOS with the latest MoltenVK SDK then vkCreateInstance may return the
VK_ERROR_INCOMPATIBLE_DRIVER error. This is because Vulkan SDK version 1.3.216 or newer requires
you to enable the VK_KHR_PORTABILITY_subset extension to use MoltenVK, because it is currently not
fully conformant.
Code example:
...
requiredExtensions.emplace_back(vk::KHRPortabilityEnumerationExtensionName);
vk::InstanceCreateInfo
createInfo(vk::InstanceCreateFlagBits::eEnumeratePortabilityKHR, &appInfo, {},
requiredExtensions);
instance = std::make_unique<vk::raii::Instance>(context, createInfo);
Conclusion
1. Conclusion
Congratulations on completing the Core Vulkan tutorial series! You’ve built a solid foundation in
Vulkan development that will serve you well in your graphics programming journey.
1. Vulkan Fundamentals - Understanding the core concepts of Vulkan, its architecture, and how
it differs from other graphics APIs.
331
3. Drawing Operations - Rendering triangles, working with vertex buffers, and understanding the
Vulkan rendering process.
5. Asset Management - Loading 3D models and textures for use in your Vulkan applications.
9. Modern Graphics Techniques - Migrating to glTF and KTX2 formats for improved asset
management.
1. Building a Simple Game Engine - Ready to take your Vulkan skills to the next level? This
tutorial series will teach you how to structure your Vulkan code into a reusable and
maintainable engine architecture. You’ll learn engine architecture and design patterns, scene
management with hierarchical object systems, camera systems and controls, efficient resource
and memory management, Entity Component System (ECS) patterns, render system abstraction,
input handling, and robust game loop design with proper timing. The series also covers
essential math and rendering concepts needed for advanced techniques like Forward+
rendering with tiled lighting, shadow mapping techniques, HRTF spatial audio integration, GPU-
accelerated physics simulation using compute shaders, and Ray Query for hybrid rendering
effects. This series builds upon the fundamentals you’ve learned here to help you create more
structured and reusable rendering solutions, and assumes you’ve completed all the chapters in
this Core Vulkan series.
Future, planned tutorials will guide you through implementing more sophisticated rendering
techniques and architectures.
Remember that Vulkan development is a continuous learning process. The graphics programming
landscape is constantly evolving, and there’s always more to learn and explore.
1. Khronos Slack - Join the official Khronos Group Slack workspace and the #vulkan channel for
direct interaction with Vulkan developers and experts. You can get an invitation at [Link]/slack.
332
2. Vulkan Discord - The community-run Vulkan Discord server is a great place for real-time
discussions, troubleshooting, and connecting with other Vulkan developers. Join at
[Link]/vulkan.
3. Reddit - The r/vulkan subreddit ([Link]/r/vulkan) is an active community for sharing news,
asking questions, and discussing Vulkan development.
4. Stack Overflow - For specific programming questions, use the vulkan tag on Stack Overflow.
5. Vulkan Specification - When in doubt, refer to the official Vulkan Specification for
authoritative information.
Don’t hesitate to reach out to these communities - they’re filled with developers who are passionate
about Vulkan and eager to help others succeed.
Thank you for following along with this tutorial series. You’ve taken a big first step in a long
journey.
333