Alain Galvan

May 26, 2017

18 min read

Raw Vulkan

Vulkan is a new low level Graphics API released February 2016 by the Khronos Group that maps directly to the design of modern GPUs.

Vulkan is used by Game Developers, Rendering Engineers and Scientists looking to do real-time rendering, raytracing, data visualization, GPGPU computations, machine learning, physics simulations, etc.

Graphic Processing Units (GPUs) were originally simple Application Specific Integrated Circuits (ASICs), but since then they have become programmable computational units of their own with a focus on throughput over latency. Older APIs like OpenGL or DirectX 11 and below were designed for hardware that’s drastically changed since the early 90s when they were first released, so Vulkan was designed from scratch to match the way GPUs are engineered today.

Currently Vulkan 1.x supports the following platforms:

  • 🖼️ Windows
  • 🐧 Linux
  • 🤖 Android

With Apple MacOS, iOS, and iPad OS supporting Vulkan through MoltenVK, a Vulkan-Metal compatibility layer that’s licensed as Apache 2.0.

  • 🍎 Mac OS
  • 📱 iOS / iPad OS

In addition to other surprising platforms such as TVs, game consoles, etc.

  • 🎮 Nintendo Switch
  • 📺 NVIDIA Shield
  • 🌐 Google Stadia
  • And many more!

And languages such as:

  • C — Through the official bindings for Vulkan, as C is Vulkan’s official language.
  • C++ — Through Vulkan-Hpp the official Vulkan C++ library.
  • Rust — Through Vulkano, an intuitive Rust wrapper with a heavy focus on compile time safety.
  • JavaScript — Through Node Vulkan, node.js bindings for native web applications.
  • Python — Through pyVulkan, a Python FFI to the C implementation of Vulkan.

I’ve prepared a Github Repo with everything we need to get started. We’re going to walk through a Hello Triangle app in modern C++ 17, a program that creates a triangle, processes it with a shader, and displays it on a window.

Setup

First install:

Then type the following in your terminal.

Refer to this blog post on designing C++ libraries and apps for more details on CMake, Git Submodules, etc.

Project Layout

As your project becomes more complex, you’ll want to separate files and organize your application to something more akin to a game or renderer, check out this post on game engine architecture and this one on real time renderer architecture for more details.

Dependencies

  • CrossWindow — A cross platform system abstraction library written in C++ for managing windows and performing OS tasks.
  • CrossWindow-Graphics — A library to simplify creating an Vulkan Surface with CrossWindow.
  • Vulkan SDK — The official Vulkan SDK distributed by LunarG. This should be installed separately.
  • GLM — A C++ library that allows users to write glsl like C++ code, with types for vectors, matrices, etc.

We’ll be writing our application using Vulkan’s C++ API through vulkan.hpp, a type safe abstraction of vulkan.h.

Overview

In this application we will need to do the following:

  1. Initialize the API — Create a Vulkan Instance to access inner functions of the Vulkan API. Pick the best Physical Device from every device that supports Vulkan on your machine. Create a Logical Device , Surface, Queue, Command Pool, Semaphores, Fences.
  2. Create Commands — Describe everything that’ll be rendered on the current frame in your command buffers.
  3. Initialize Resources — Create a Descriptor Pool, Descriptor Set Layout, Pipeline Layout, Vertex Buffer/Index Buffer and send it to GPU Accessible Memory, describe our Input Attributes, create a Uniform Buffer, Render Pass, Frame Buffers, Shader Modules, and Pipeline State.
  4. Setup Commands for each command buffer to set the GPU state to render the triangles.
  5. Render — Use an Update Loop to switch between different frames in your swapchain as well as to poll input devices/window events.
  6. Destroy any data structures once the application is asked to close.

The following will explain snippets that can be found in the Github repo, with certain parts omitted, and member variables (mMemberVariable) declared inline without the m prefix so their type is easier to see and the examples here can work on their own.

Window Creation

We’re using CrossWindow to handle cross platform window creation, so creating a window and updating it is very easy:

Initialize API

Instances

Similar to the OpenGL context, a Vulkan application begins when you create an instance. This instance must be loaded with some information about the program such as its name, engine, and minimum Vulkan version, as well any extensions and layers you want to load.

  • Extension — Anything that adds extra functionality to Vulkan, such as support for Win32 windows, or enabling drawing onto a target.
  • Layer — Middleware between existing Vulkan functionality, such as checking for errors. Layers can range from runtime debugging checks like LunarG’s Standard Validation tools to hooks to the Steam renderer so your game can behave better when you Ctrl + Shift to switch to the Steam overlay.

You’ll want to begin by determining which extensions/layers you want, and compare that with which are available to you by Vulkan.

Physical Devices

In Vulkan, you have access to all enumerable devices that support it, and can query for information like their name, the number of heaps they support, their manufacturer, etc.

Note — This is useful for choosing the fastest device to use, however you could use the KHX_device_group extension presented at GDC 2017 to help with multi-gpu processing.

Logical Devices

You can then create a logical device from a physical device handle. A logical device can be loaded with its own extensions/layers, can be set to work with graphics, GPGPU computations, handle sparse memory and/or memory transfers by creating queues for that device.

A logical device is your interface to the GPU, and allows you to allocate data and queue up tasks.

Queue

Once you have a virtual device, you can access the queues you requested when you created it:

If your application is idle for too long, the Vulkan API will throw a vk::OutOfDateKHRError error, requiring you to re-initialize your graphics API.

Command Pool

A command pool is a means of allocating command buffers. Any number of command buffers can be made from command pools, with you as the developer responsible for managing when and how they’re created and what is loaded in each.

A command pool cannot be used in multiple threads, but you can create one for each thread and manage them on a per thread level.

Descriptor Pool

A descriptor pool is a means of allocating Descriptor Sets, a set of data structures containing implementation-specific descriptions of resources. to make a descriptor pool, you need to describe exactly how many of each type of descriptor you need to allocate.

To do that you need to provide a collection of the size of each descriptor type.

Like command buffers, we’ll come back to descriptor sets later.

Color Formats

Knowing what Color formats your GPU supports will play a crucial role in determining what you can display and what kind of buffers you can allocate.

Swapchain

A Swapchain is a structure that manages the allocation of frame buffers to be cycled through by your application. It’s here that your application sets up V-Sync via double buffering or triple buffering.

One approach to setting this up is to take in a JSON file at the start of your application, say config.json, which determines if you'll be using V-Sync, your screen resolution, any any other global data you want to configure.

View Structures

A View in Vulkan is a handle to a particular resource on a GPU, such as an Image or a Buffer, and provides information on how that resource should be processed.

Render Pass

A render pass describes the attachments that are expected to be used when executing a graphics pipeline and their relationship with each other.

Frame Buffers

A frame buffer in Vulkan is a container of Image Views that are bound to a specific render pass.

Synchronization

Vulkan was designed with concurrency in mind, so you’re free to use mutexes, and built in Vulkan Semaphores and Fences for GPU level Synchronization.

Semaphores coordinate operations within the graphics queue and ensure correct command ordering.

You should try to have the minimum number of command buffers possible in your application.

One possible setup could be taking a flat collection of renderable objects (like a scene), distributing it across as many threads as the computer’s CPU allows, allocating a command buffer for each object, creating a pipeline for each object, and finishing by sending a ending buffer to start up the process.

We’ll come back to the command buffers we made here later in our app.

Initialize Resources

Vertex Buffers

The fundamental problem of graphics is how to manage large sets of data. A vertex buffer is an array of rows of relevant vertex information, such as its position, normal, color, etc. Unlike OpenGL where it would handle allocation and handling memory for you, in Vulkan, you must:

  1. Allocate all the memory related to your buffer.
  2. Map that data to a host visible handle.
  3. Copy that data to your GPU.
  4. Bind your buffer to that block of memory.

For buffers that you want as GPU accessible only, you’ll need to also copy that buffer to a GPU exclusive buffer.

Descriptor Sets

Descriptor Sets store the resources bound to the binding points in a shader (Basically Uniforms). They connect the binding points of a shader with the buffers and images used for those bindings.

In React Fiber there’s the idea of a frequently updated view and a not frequently updated view. Unreal Engine 4 shares this with two global uniform families for frequently (called variable parameters) and not frequently (constant parameters) updated uniforms. Descriptor Sets are where you would make this distinction in Vulkan.

Descriptor sets are composed of Descriptor Set Layouts, which are then composed of Descriptor Set Bindings, the individual bindings a uniform struct has.

In Vulkan, Uniforms must be contiguous structs of data that are multiples of 128 bits (So SIMD vector sized blocks).

Pipeline Layouts

Pipeline layouts are a collection of descriptor sets, the bindings to a shader program. In OpenGL in order to bind a shader to a set of data, you needed to describe how the inputs and outputs are organized in memory (their spacing, size, etc.)

Access to descriptor sets from a pipeline is accomplished through a pipeline layout. Zero or more descriptor set layouts and zero or more push constant ranges are combined to form a pipeline layout object which describes the complete set of resources that can be accessed by a pipeline. The pipeline layout represents a sequence of descriptor sets with each having a specific layout. This sequence of layouts is used to determine the interface between shader stages and shader resources. Each pipeline is created using a pipeline layout.

Pipeline State Objects

Pipelines are basically a mix of hardware and software functions that do a particular task on the GPU, in Vulkan, there’s 4 types:

  • Graphics Pipelines
  • Compute Pipelines
  • Ray-Tracing Pipelines
  • Tensor Pipelines

Graphics Pipeline

  • Color Blending — The function that controls how two objects draw on top of each other.
  • Depth Stencil — A extra piece of information that describes depth information.
  • Vertex Input — The actual vertex data you’ll be using in your shader.
  • Shaders — What shaders will be loaded in.

And many more. These can even be cached! These particular draw calls are grouped such that in older graphics APIs, they would trigger shader recompilation.

Pipeline Cache

A pipeline cache serves to cache previously created pipelines for reuse later. Since pipelines don’t change often, this you can quickly create another for use later.

You’re even able to compile the pipeline down into binary, and write the pipeline to a a file. This is part of the reason why DOOM 2016 takes a while to first start up when running it on Vulkan [Lottes 2016], with Doom Eternal downloading Vulkan binaries separately in Steam.

Shaders

Shaders must be passed to Vulkan as SPIR-V binary, so any compiler that can make SPIR-V is allowed. Shaders are pre-compiled, loaded into memory, transferred to a shader module, bundled in a set of pipelineShaderStages, which is then put into a graphics pipeline.

Shaders are compiled using the glslangvalidator bundled with the Vulkan SDK provided by LunarG.

Vulkan’s GLSL code is the same as OpenGL 4.5:

Shaders are loaded into Pipeline Layouts which are then executed by a command buffer.

Command Buffer

A command buffer is a container of GPU commands, this is where you would see commands similar to OpenGL’s state commands:

  • setViewport
  • setSissor
  • blitImage
  • bindPipeline

A common pattern for building a command buffer is:

  1. Start Render Pass
  2. Bind Resources
  3. Descriptor Sets
  4. Vertex and Index Buffers
  5. Pipeline State
  6. Modify Dynamic State
  7. Draw
  8. Repeat 2 Through 4 as Needed
  9. End Render Pass

Different command buffer pools allow multiple threads performing generating command buffers, thus you could allocate a thread for each core on the CPU, and split rendering tasks across each core. This could be used to distribute rendering individual objects, differed rendering passes, physics calculations with compute buffers, etc.

Conclusion

Vulkan is a pretty complicated API to wrap your head around, and while this post attempts to make it simple, there’s still a lot to bear in mind that other graphics APIs deal with for you. Aspects of the API like memory management, queue indices, descriptor sets, don’t exist in other APIs but exist here to make this API much faster at the cost of added complexity to your renderer.

You’ll find all the source code described in this post in the Github repo here.

https://Alain.xyz | Graphics Software Engineer @ AMD, Previously @ Marmoset.co. Guest lecturer talking about 🛆 Computer Graphics, ✍ tech author.

Love podcasts or audiobooks? Learn on the go with our new app.