Vulkan and you – Khronos’ successor to OpenGL

Vulkan and you – Khronos’ successor to OpenGL

By Andy Thomason

Overload, 25(139):12-14, June 2017


Various graphics APIs exist. Andy Thomason unravels the mysteries of Vulkan, the latest 3D Graphics API from Khronos, the custodians of OpenGL.

I love it when you get a new toy, unwrapping the box and staring for hours at the instructions while you try to put it together. Vulkan, the new graphics API from the lovely people at Khronos was a bit like that a year ago when I started to get to grips with it and like an self-assembly wardrobe, it took a lot of head scratching before it finally clicked and I was able to start making some real applications. I would not call myself an expert yet, but I may be able to explain how it works to someone who is just getting started like I was.

If you feel enthusiastic, the real reference to this is the Vulkan Spec which comes in several flavours including this one with extensions: https://www.khronos.org/registry/vulkan/specs/1.0-extensions/html/vkspec.html

Vulkan is derived from the latest OpenGL standard. Early versions of OpenGL used a fixed function pipeline and this kind of code (now obsolete) will have been familiar:

  glBegin(GL_TRIANGLES); // Begin drawing triangles
    glVertex3f(-1, -1, 0); // Add a vertex
    glVertex3f( 0,  1, 0);
    glVertex3f( 1, -1, 0);
  glEnd();

Later versions of OpenGL moved from fixed function pipelines to programmable shaders and the vertices moved into buffers held on the GPU and the shader parameters became uniforms: values that stayed the same for the whole object we are drawing.

  glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
  glEnableClientState(GL_VERTEX_ARRAY);
  glVertexPointer(3, GL_FLOAT, sizeof(Vertex),
    (void*)0);
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,
    indexBuffer);
  glDrawElements(GL_TRIANGLES, 3,
    GL_UNSIGNED_SHORT, (void*)0);

Vulkan’s history started in 2014 when after a meeting at Valve, Khronos announced the project at Siggraph. Since then it has had contributions from Samsung, AMD and ARM to name but a few. Vulkan makes OpenGL completely stateless, more like Microsoft's DirectX, so that descriptions of objects can be made in memory and drawn in any order on multiple CPU cores if necessary. Vulkan does very little that OpenGL can't do, but it does everything in a much more modern way.

There is now the choice of a C interface (vulkan.h) or a modern C++ interface (vulkan.hpp). In my humble opinion I prefer the C++ interface, but virtually all the examples on the internet use the rather verbose C API. I have a library called Vookoo which uses the C++ API and adds a few classes to make setting up Vulkan data structures a bit easier: https://github.com/andy-thomason/Vookoo

The raison d’etre of Vulkan is to make as few calls as possible to the API and this is achieved by wrapping mighty data structures up into single objects. This is good for performance but can be very daunting to new users. Vookoo is designed to let beginners get used to the Vulkan API in stages, taking more responsibility as time goes by.

3D graphics 101

For those of you who don’t work in 3D graphics, here is a helpful introduction.

The one thing that Call of Duty and Angry Birds have in common is that everything is made of triangles. That is absolutely everything, the characters, the environment, the text displaying the scores, everything. This makes it very easy to understand 3D graphics because once you can draw one triangle, you can draw everything.

To draw a triangle we need two things, a set of points in 2D or 3D space to tell us where the corners of the triangles are called ‘vertices’ and a bunch of numbers to tell us which of the vertices to use called the ‘indices’. We have three indices per triangle we draw and can describe everything from a sprite to Lara Croft using this model. When we want to draw a 3D model on the 2D screen we use a little chunk of code that runs on the GPU called a ‘vertex shader’ that converts these vertices and indices to 2D triangles and calculates the lighting. After this, the GPU takes three vertices and creates a bunch of pixels. The colour of each of these is determined by a ‘fragment shader’ which is a function that returns red, green and blue values for each pixel.

In the very early days of 3D graphics, we worked out the positions of the vertices using graph paper, but now we have special tools like Blender to do this for us. Listing 1 is the vertex shader for our triangle; Listing 2 is the fragment shader for our triangle.

#version 450

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColour;
layout(location = 0) out vec3 fragColour;

void main() {
  // Copy 2D position to 3D + depth
  gl_Position = vec4(inPosition, 0.0, 1.0);
  // Copy colour to the fragment shader.
  fragColour = inColour;
}
			
Listing 1
#version 450

layout(location = 0) in vec3 fragColour;
layout(location = 0) out vec4 outColour;

void main() {
  // Copy interpolated colour to the screen.
  outColour = vec4(fragColour, 1);
}
			
Listing 2

The pipeline

Graphics programming is all about the care and feeding of shaders. This is the same in all APIs be it DirectX 12, OpenGL 4.5 or Apple’s proprietary Metal. The old OpenGL and DirectX APIs did a lot of their work in software, but modern graphics APIs are about getting to the hardware as quickly as possible without burning millions of cycles in drivers. Vulkan works natively on pretty much every device except for iOS and OSX but there is a proprietary adaptor from Vulkan to Metal called Molten on these.

When you execute a draw command, the index buffer selects which vertices from your model you want to assemble into a triangle and all three vertices go through the vertex stage to get moved to the right place on the screen. Say you have a model of a teapot, for example, then it consists of a few thousand (x, y, z) positions for the vertices and a few thousand indices such as (0, 1, 2). This instructs the pipeline to draw the triangles in the right place to make the teapot show up on screen in the right place.

The Vulkan pipeline is quite complex and has a few hundred parameters such as the layout of the vertices in memory and how we handle transparency. But we don’t need to worry about all the detail as there are sensible defaults that just work.

SPIR-V

The shaders in Vulkan are defined by an intermediate language called SPIR-V that deserves a whole article by itself as it is both at the sharp end of Vulkan and forms the core of OpenCL, the Khronos GPU compute API. SPIR-V is a binary format with a rigid specification and that makes it easy for developers to write portable shaders. You can use any Shader language, GLSL, HLSL, CG or even C++ via LLVM to generate SPIR-V and once compiled, the core specification works for all Vulkan enabled hardware. There is a tool called ‘glslangvalidator’ that comes with the LunarG Vulkan SDK. This compiles GLSL shaders into SPIR-V binaries.

Shaders are fed with constants either through ‘Push constants’ or via memory buffers with Uniform and Vertex buffers. Push constants are good for small variables, buffers are for bigger things such as meshes or arrays of matrices for skinning characters. Shaders can also write to buffers via Storage buffers which support atomic variables. Textures are a special kind of buffer that contain images that are formatted in an opaque, optimal way such as a Hilbert curve layout to make memory accesses more local when drawing 2D images.

In Vulkan, all the textures and buffers passed to the shaders are wrapped up in a ‘Descriptor Set’ which is a list of handles to buffers that can be passed as a single object to the GPU, reducing the number of calls to the API.

A "hello triangle" example

This is a description of the "helloTriangle" example from Vookoo: https://github.com/andy-thomason/Vookoo/blob/master/examples/helloTriangle.cpp

You will need to install the Vulkan SDK from here: https://www.lunarg.com/vulkan-sdk/

Before we can draw a triangle, we must set up the Vulkan API. In Vookoo there is a convenient framework for the examples that will do this for you. We also create a window using the GLFW framework. Later you can explore how to do this yourself. There is a good tutorial on doing this here: https://vulkan-tutorial.com/

 vku::Framework fw{title};
  if (!fw.ok()) {
    std::cout << "Framework creation failed"
      << std::endl;
    exit(1);
  }
  vku::Window window{fw.instance(), fw.device(),
    fw.physicalDevice(),
    fw.graphicsQueueFamilyIndex(), glfwwindow};

The vk::Device object ( fw.device() ) is a handle to a logical device which we can use to create Vulkan objects and send commands to the GPU. vk::PhysicalDevice ( fw.physicalDevice() ) is the actual device and gives you information about resources available on your graphics card or phone. Each logical device supports several queues ( fw.graphicsQueueFamilyIndex() ) to send commands to the GPU. Some queues are for graphics, some for transfer etc.

Next up we need to set up the shaders.

  vku::ShaderModule vert_{device,
    BINARY_DIR "helloTriangle.vert.spv"};
  vku::ShaderModule frag_{device,
    BINARY_DIR "helloTriangle.frag.spv"};

These we load from binary files compiled by glslangvalidator.

  vku::PipelineLayoutMaker plm{};
  auto pipelineLayout_ = plm.createUnique(device);

The pipeline layout is a description of the descriptor sets used to pass buffers to shaders. In this case, we don’t use one as we only pass vertices to the vertex shader.

Next we define our vertex format and make the three corners of our triangle.

  struct Vertex { glm::vec2 pos; glm::vec3 colour;
  };
  const std::vector<Vertex> vertices = {
    {{0.0f, -0.5f}, {1.0f, 0.0f, 0.0f}},
    {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
    {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
  };
  vku::VertexBuffer buffer(fw.device(),
    fw.memprops(), vertices);

Now we have a triangle, we build our pipeline. The Vulkan data structure for doing this is a little verbose, so Vookoo has another helper object to make this easy (see Listing 3).

vku::PipelineMaker pm{(uint32_t)width,
  (uint32_t)height};
pm.shader(vk::ShaderStageFlagBits::eVertex,
  vert_);
pm.shader(vk::ShaderStageFlagBits::eFragment,
  frag_);
pm.vertexBinding(0, (uint32_t)sizeof(Vertex));
pm.vertexAttribute(
  0, 0, vk::Format::eR32G32Sfloat,
  (uint32_t)offsetof(Vertex, pos));
pm.vertexAttribute(
  1, 0, vk::Format::eR32G32B32Sfloat,
  (uint32_t)offsetof(Vertex, colour));

auto renderPass = window.renderPass();
auto &cache = fw.pipelineCache();
auto pipeline = pm.createUnique(
  device, cache, *pipelineLayout_, renderPass);
			
Listing 3

The pipeline cache object holds the binary information that gets sent to the GPU and can be saved to speed up the process of building pipelines in the future. The renderPass object holds information about the frame buffer we are drawing to, in this case a set of special images which will be copied to the window. It describes how we clear the frame buffer, which images we are rendering to and what to do with the frame buffer after we are done drawing.

Instead of sending commands directly to the GPU, Vulkan records commands in command buffers which are then put into queues for later asynchronous execution on the GPU. Listing 4 is a code example for setting up a command buffer to draw a single triangle using the C++ interface. We have omitted quite a bit of code for clarity.

vk::CommandBuffer cb = ...;
vk::CommandBufferBeginInfo bi{};
cb.begin(bi);
cb.beginRenderPass(rpbi,
  vk::SubpassContents::eInline);
  cb.bindPipeline(vk::PipelineBindPoint::eGraphics,
  *pipeline);
cb.bindVertexBuffers(0, buffer.buffer(),
  vk::DeviceSize(0));
cb.draw(3, 1, 0, 0);
cb.endRenderPass();
cb.end();
			
Listing 4

Because Vulkan draws asynchronously, we usually use at least three almost identical command buffers to draw up to three frames in advance. Alternatively, we can allocate command buffers as we need them from a pool.

Finally we can just submit our command buffer to a queue and wait for the GPU to draw our triangle. This is done in the framework by the window.draw() call which also handles the synchronisation you need to do to prevent clashes on the GPU.

  while (!glfwWindowShouldClose(glfwwindow)) {
    glfwPollEvents();
    window.draw(fw.device(), fw.graphicsQueue());
    std::this_thread::sleep_for(
      std::chrono::milliseconds(16));
  }

If it works for you, you will be rewarded with this magnificent triangle:

The whole example is only 91 lines long, but the vku::framework and vku::window objects hide a lot of complexity. Like DirectX, Vulkan is challenging to set up at get started with but the reward is some very high performance and little drain on the CPU.

Debugging

Vulkan development would be almost impossible with the debugging layers that come with the SDK. If Vulkan encounters an error internally, it will likely crash or worse still just display nothing on the screen.

Vulkan has a system of layers that make development a lot more pleasant. You can add verification layers to detect errors in your setup or just warn you. Once your code runs without warnings and errors, you can let it loose and it will execute much faster. This is typical of the games industry where intense testing is done before releasing a title so that we can shave a few cycles off the frame time. These days with VR headsets, we want to be running at 90 frames per second without hiccups and so don’t have time for niceties like exceptions and runtime error checking. That means that your game must do all the physics, AI, networking, gameplay and rendering in 11ms or about 33 million cycles.

And so…

Vulkan is definitely fun once you have got past the pain of setting up the API. I think that we can teach it to students instead of OpenGL now despite the additional complexity. We hope that the remaining holdouts will support Vulkan on consoles and devices so that we can all code to a common standard.

Interestingly, Vulkan works especially well with Mobile devices as the renderPass structure is well suited to tiled GPUs such as we find on phones, tablets and increasingly TVs and VR headsets and so has a brilliant future there.

Live long and prosper, as they say…






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.