Book Image

Mastering Graphics Programming with Vulkan

By : Marco Castorina, Gabriel Sassone
5 (2)
Book Image

Mastering Graphics Programming with Vulkan

5 (2)
By: Marco Castorina, Gabriel Sassone

Overview of this book

Vulkan is now an established and flexible multi-platform graphics API. It has been adopted in many industries, including game development, medical imaging, movie productions, and media playback but learning it can be a daunting challenge due to its low-level, complex nature. Mastering Graphics Programming with Vulkan is designed to help you overcome this difficulty, providing a practical approach to learning one of the most advanced graphics APIs. In Mastering Graphics Programming with Vulkan, you’ll focus on building a high-performance rendering engine from the ground up. You’ll explore Vulkan’s advanced features, such as pipeline layouts, resource barriers, and GPU-driven rendering, to automate tedious tasks and create efficient workflows. Additionally, you'll delve into cutting-edge techniques like mesh shaders and real-time ray tracing, elevating your graphics programming to the next level. By the end of this book, you’ll have a thorough understanding of modern rendering engines to confidently handle large-scale projects. Whether you're developing games, simulations, or visual effects, this guide will equip you with the skills and knowledge to harness Vulkan’s full potential.
Table of Contents (21 chapters)
1
Part 1: Foundations of a Modern Rendering Engine
7
Part 2: GPU-Driven Rendering
13
Part 3: Advanced Rendering Techniques

Understanding the code structure

In this section, we will deep dive into the foundational code used throughout the book and we will explain the rationale behind some of the decisions we made.

When we started thinking about the code to be used, the objective was clear: there was the need for something lightweight, simple, and basic enough to give us the possibility to build upon it. A fully fledged library would have been too much.

Also, we needed something that we were familiar with to make the development process smoother and give us confidence.

There are different great libraries out there, such as Sokol (https://github.com/floooh/sokol) or BGFX (https://github.com/bkaradzic/bgfx), and a few more, but they all have some drawbacks that seemed problematic.

Sokol, for example, even though it is a great library, does not support the Vulkan API, and has an interface still based on older graphics APIs (such as OpenGL and D3D11).

BGFX is a more complete library, but it is a little too generic and feature-fledged to give us the possibility to build upon it.

After some research, we leaned toward the Hydra Engine – a library that Gabriel developed in the last couple of years as code to experiment with and write articles on rendering.

Here are some advantages of starting from the Hydra Engine (https://github.com/JorenJoestar/DataDrivenRendering) and evolving it into the Raptor Engine:

  • Code familiarity
  • Small and simple code base
  • Vulkan-based API
  • No advanced features, but strong building blocks

The Hydra Engine seemed perfect, being small but usable and familiar. From an API design perspective, it was a clear advantage compared to other libraries that both authors had used in the past.

Being designed from scratch by Gabriel, evolving the code through this book is done with the full knowledge of the underlying architecture.

Starting from the Hydra Engine, we changed some code to be more Vulkan-focused, and thus the Raptor Engine was born. In the following sections, we will have a brief look at the code architecture to familiarize you with the building blocks that will be used throughout all the chapters.

We will also look at the glTF data format used to import meshes, textures, and materials into the Raptor Engine.

Layers of code

The Raptor Engine is created with a layer-based mentality for code, in which a layer can interact only with lower ones.

This choice was made to simplify communication between layers and simplify the API design and the expected behavior for the final user.

There are three layers in Raptor:

  • Foundation
  • Graphics
  • Application

The foundation and application layers are common to all chapters and can be found at source/raptor.

Each chapter has its own implementation of the graphics layer. This makes it easier to introduce new features in each chapter without having to worry about maintaining multiple code paths across all chapters. For instance, the code for the graphics layer for this chapter can be found at source/chapter1/graphics.

While developing the Raptor Engine, we enforced the communication direction based on the layer we were on, so that a layer could interact with the code within the same layer and the bottom layer only.

In this case, the foundation layer can interact only with the other code inside the layer, the graphics layer can interact with the foundation layer, and the application layer interacts with all the layers.

There will be possible situations where we need to have some communication from a bottom layer to an upper layer, and the solution to that is to create code in the upper layer to drive the communication between the lower layers.

For example, the Camera class is defined in the foundation layer, and it is a class that contains all the mathematical code to drive a rendering camera.

What if we need user input to move the camera, say with a mouse or gamepad?

Based on this decision, we created GameCamera in the application layer, which contains the input code, takes the user input, and modifies the camera as needed.

This upper layer bridging will be used in other areas of the code and will be explained when needed.

The following sections will give you an overview of the main layers and some of their fundamental code so that you will be familiar with all the available building blocks that will be used throughout the book.

Foundation layer

The foundation layer is a set of different classes that behave as fundamental bricks for everything needed in the framework.

The classes are very specialized and cover different types of needs, but they are required to build the rendering code written in this book. They range from data structures to file operations, logging, and string processing.

While similar data structures are provided by the C++ standard library, we have decided to write our own as we only need a subset of functionality in most cases. It also allows us to carefully control and track memory allocations.

We traded some comfort (that is, automatic release of memory on destruction) for more fine-tuned control over memory lifetime and better compile times. These all-important data structures are used for separate needs and will be used heavily in the graphics layer.

We will briefly go over each foundational block to help you get accustomed to them.

Memory management

Let’s start with memory management (source/raptor/foundation/memory.hpp).

One key API decision made here is to have an explicit allocation model, so for any dynamically allocated memory, an allocator will be needed. This is reflected in all classes through the code base.

This foundational brick defines the main allocator API used by the different allocators that can be used throughout the code.

There is HeapAllocator, based on the tlsf allocator, a fixed-size linear allocator, a malloc-based allocator, a fixed-size stack allocator, and a fixed-size double stack allocator.

While we will not cover memory management techniques here, as it is less relevant to the purpose of this book, you can glimpse a more professional memory management mindset in the code base.

Arrays

Next, we will look at arrays (source/raptor/foundation/array.hpp).

Probably the most fundamental data structure of all software engineering, arrays are used to represent contiguous and dynamically allocated data, with an interface similar to the better-known std::vector (https://en.cppreference.com/w/cpp/container/vector).

The code is simpler compared to the Standard Library (std) implementation and requires an explicit allocator to be initialized.

The only notable difference from std::vector can be seen in the methods, such as push_use(), which grows the array and returns the newly allocated element to be filled, and the delete_swap() method, which removes an element and swaps it with the last element.

Hash maps

Hash maps (source/raptor/foundation/hash_map.hpp) are another fundamental data structure, as they boost search operation performance, and they are used extensively in the code base: every time there is the need to quickly find an object based on some simple search criteria (search the texture by name), then a hash map is the de facto standard data structure.

The sheer volume of information about hash maps is huge and out of the scope of this book, but recently a good all-round implementation of hash maps was documented and shared by Google inside their Abseil library (code available here: https://github.com/abseil/abseil-cpp).

The Abseil hash map is an evolution of the SwissTable hash map, storing some extra metadata per entry to quickly reject elements, using linear probing to insert elements, and finally, using Single Instruction Multiple Data (SIMD) instructions to quickly test more entries.

Important note

For a good overview of the ideas behind the Abseil hash map implementation, there are a couple of nice articles that can be read. They can be found here:

Article 1: https://gankra.github.io/blah/hashbrown-tldr/

Article 2: https://blog.waffles.space/2018/12/07/deep-dive-into-hashbrown/

Article 1 is a good overview of the topic and Article 2 goes a little more in-depth about the implementation.

File operations

Next, we will look at file operations (source/raptor/foundation/file.hpp).

Another common set of operations performed in an engine is file handling, for example, to read a texture, a shader, or a text file from the hard drive.

These operations follow a similar pattern to the C file APIs, such as file_open being similar to the fopen function (https://www.cplusplus.com/reference/cstdio/fopen/).

In this set of functions, there are also the ones needed to create and delete a folder, or some utilities such as extrapolating the filename or the extension of a path.

For example, to create a texture, you need to first open the texture file in memory, then send it to the graphics layer to create a Vulkan representation of it to be properly usable by the GPU.

Serialization

Serialization (source/raptor/foundation/blob_serialization.hpp), the process of converting human-readable files to a binary counterpart, is also present here.

The topic is vast, and there is not as much information as it deserves, but a good starting point is the article https://yave.handmade.network/blog/p/2723-how_media_molecule_does_serialization, or https://jorenjoestar.github.io/post/serialization_for_games.

We will use serialization to process some human-readable files (mostly JSON files) into more custom files as they are needed.

The process is done to speed up loading files, as human-readable formats are great for expressing things and can be modified, but binary files can be created to suit the application’s needs.

This is a fundamental step in any game-related technology, also called asset baking.

For the purpose of this code, we will use a minimal amount of serialization, but as with memory management, it is a topic to have in mind when designing any performant code.

Logging

Logging (source/raptor/foundation/log.hpp) is the process of writing some user-defined text to both help understand the flow of the code and debug the application.

It can be used to write the initialization steps of a system or to report some error with additional information so it can be used by the user.

Provided with the code is a simple logging service, providing the option of adding user-defined callbacks and intercepting any message.

An example of logging usage is the Vulkan debug layer, which will output any warning or error to the logging service when needed, giving the user instantaneous feedback on the application’s behavior.

String processing

Next, we will look at strings (source/raptor/foundation/string.hpp).

Strings are arrays of characters used to store text. Within the Raptor Engine, the need to have clean control of memory and a simple interface added the need for custom-written string code.

The main class provided is the StringBuffer class, which lets the user allocate a maximum fixed amount of memory, and within that memory, perform typical string operations: concatenation, formatting, and substrings.

A second class provided is the StringArray class, which allows the user to efficiently store and track different strings inside a contiguous chunk of memory.

This is used, for example, when retrieving a list of files and folders. A final utility string class is the StringView class, used for read-only access to a string.

Time management

Next is time management (source/raptor/foundation/time.hpp).

When developing a custom engine, timing is very important, and having some functions to help calculate different timings is what the time management functions do.

For example, any application needs to calculate a time difference, used to advance time and calculations in various aspects, often known as delta time.

This will be manually calculated in the application layer, but it uses the time functions to do it. It can be also used to measure CPU performance, for example, to pinpoint slow code or gather statistics when performing some operations.

Timing methods conveniently allow the user to calculate time durations in different units, from seconds down to milliseconds.

Process execution

One last utility area is process execution (source/raptor/foundation/process.hpp) – defined as running any external program from within our own code.

In the Raptor Engine, one of the most important usages of external processes is the execution of Vulkan’s shader compiler to convert GLSL shaders to SPIR-V format, as seen at https://www.khronos.org/registry/SPIR-V/specs/1.0/SPIRV.html. The Khronos specification is needed for shaders to be used by Vulkan.

We have been through all the different utilities building blocks (many seemingly unrelated) that cover the basics of a modern rendering engine.

These basics are not graphics related by themselves, but they are required to build a graphical application that gives the final user full control of what is happening and represents a watered-down mindset of what modern game engines do behind the scenes.

Next, we will introduce the graphics layer, where some of the foundational bricks can be seen in action and represent the most important part of the code base developed for this book.

Graphics layer

The most important architectural layer is the graphics layer, which will be the main focus of this book. Graphics will include all the Vulkan-related code and abstractions needed to draw anything on the screen using the GPU.

There is a caveat in the organization of the source code: having the book divided into different chapters and having one GitHub repository, there was the need to have a snapshot of the graphics code for each chapter; thus, graphics code will be duplicated and evolved in each chapter’s code throughout the game.

We expect the code to grow in this folder as this book progresses after each chapter, and not only here, as we will develop shaders and use other data resources as well, but it is fundamental to know where we are starting from or where we were at a specific time in the book.

Once again, the API design comes from Hydra as follows:

  • Graphics resources are created using a creation struct containing all the necessary parameters
  • Resources are externally passed as handles, so they are easily copiable and safe to pass around

The main class in this layer is the GpuDevice class, which is responsible for the following:

  • Vulkan API abstractions and usage
  • Creation, destruction, and update of graphics resources
  • Swapchain creation, destruction, resize, and update
  • Command buffer requests and submission to the GPU
  • GPU timestamps management
  • GPU-CPU synchronization

We define graphics resources as anything residing on the GPU, such as the following:

  • Textures: Images to read and write from
  • Buffers: Arrays of homogeneous or heterogeneous data
  • Samplers: Converters from raw GPU memory to anything needed from the shaders
  • Shaders: SPIR-V compiled GPU executable code
  • Pipeline: An almost complete snapshot of GPU state

The usage of graphics resources is the core of any type of rendering algorithm.

Therefore, GpuDevice (source/chapter1/graphics/gpu_device.hpp) is the gateway to creating rendering algorithms.

Here is a snippet of the GpuDevice interface for resources:

struct GpuDevice {
  BufferHandle create_buffer( const BufferCreation& bc );
  TextureHandle create_texture( const TextureCreation& tc
  );
  ...
  void destroy_buffer( BufferHandle& handle );
  void destroy_texture( TextureHandle& handle );

Here is an example of the creation and destruction to create VertexBuffer, taken from the Raptor ImGUI (source/chapter1/graphics/raptor_imgui.hpp) backend:

GpuDevice gpu;
// Create the main ImGUI vertex buffer
BufferCreation bc;
bc.set( VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
  ResourceUsageType::Dynamic, 65536 )
  .set_name( "VB_ImGui" );
BufferHandle imgui_vb = gpu.create(bc);
…
// Destroy the main ImGUI vertex buffer
gpu.destroy(imgui_vb);

In the Raptor Engine, graphics resources (source/chapter1/graphics/gpu_resources.hpp) have the same granularity as Vulkan but are enhanced to help the user write simpler and safer code.

Let’s have a look at the Buffer class:

struct Buffer {
    VkBuffer                        vk_buffer;
    VmaAllocation                   vma_allocation;
    VkDeviceMemory                  vk_device_memory;
    VkDeviceSize                    vk_device_size;
    VkBufferUsageFlags              type_flags      = 0;
    u32                             size            = 0;
    u32                             global_offset   = 0;
    BufferHandle                    handle;
    BufferHandle                    parent_buffer;
    const char* name                = nullptr;
}; // struct Buffer

As we can see, the Buffer struct contains quite a few extra pieces of information.

First of all, VkBuffer is the main Vulkan struct used by the API. Then there are some members related to memory allocations on the GPU, such as device memory and size.

Note that there is a utility class used in the Raptor Engine called Virtual Memory Allocator (VMA) (https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator), which is the de facto standard utility library to write Vulkan code.

Here, it is reflected in the vma_allocation member variable.

Furthermore, there are usage flags – size and offset, as well as global offsets – current buffer handle and parent handle (we will see their usage later in the book), as well as a human-readable string for easier debugging. This Buffer can be seen as the blueprint of how other abstractions are created in the Raptor Engine, and how they help the user to write simpler and safer code.

They still respect Vulkan’s design and philosophy but can hide some implementation details that can be less important once the focus of the user is exploring rendering algorithms.

We had a brief overview of the graphics layer, the most important part of the code in this book. We will evolve its code after each chapter, and we will dwell deeper on design choices and implementation details throughout the book.

Next, there is the application layer, which works as the final step between the user and the application.

The application layer

The application layer is responsible for handling the actual application side of the engine – from window creation and update based on the operating system to gathering user input from the mouse and keyboard.

In the layer is also included a very handy backend for ImGui (https://github.com/ocornut/imgui), an amazing library to design the UI to enhance user interaction with the application so that it is much easier to control its behavior.

There is an application class that will be the blueprint for any demo application that will be created in the book so that the user can focus more on the graphics side of the application.

The foundation and application layers’ code is in the source/raptor folder. This code will be almost constant throughout the book, but as we are writing mainly a graphics system, this is put in a shared folder between all the chapters.

In this section, we have explained the structure of the code and presented the three main layers of the Raptor Engine: foundation, graphics, and application. For each of these layers, we highlighted some of the main classes, how to use them, and the reasoning and inspiration behind the choices we have made.

In the next section, we are going to present the file format we selected to load 3D data from and how we have integrated it into the engine.