<< ../

Resources in Vulkan vs DX12

Vulkan and DX12 are the first two modern explicit APIs that came to market.
They have more or less the same functionality and, as HW provides new capabilities, the APIs add extensions to take advantage of them.

Now, despite both APIs can do more or less the same, the interfaces are quite different.
Also, the differences in terminology can get confusing: sometimes they use different words to refer to the same thing, sometimes they use the same word to refer to different things, sometimes there is no exact equivalent.

I have been learning Vulkan for several years and, due to my work, now I’ve been recently exposed to DX12 too (although I’m a total noob in DX12 TBH).
When you are learning something new, it’s always helpful to find analogies and similarities to something you already know.
For example, when I was reading about root signatures in DX12 I was having trouble understanding them until my brain clicked: hold on, this is actually very similar to VkDescriptorSetLayout! And then I was able to hold it in my brain more easily.

So, in this post, I wanted to lay out those similarities (and differences) I found between Vulkan and DX12 in relation to resources.
I write this down to help others in the same situation: you already know one API; you want to learn the other. And also, since I’m very forgetful, I’m sure I will find this post helpful myself in the future :)

Disclaimer:
I’m a noob in DX12, so please forgive me for any mistakes (and please let me know in the comments so I can fix them).

Creating resources

The most basic resources are: buffers, images and samplers.
Samplers are pretty similar in both APIs, so I will only talk about them briefly.

Creating resources in Vulkan

In Vulkan, you have VkBuffer and VkImage as two separate objects.
There are two functions for creating them: vkCreateBuffer and vkCreateImage.

The creation of a VkBuffer or a VkImage doesn’t assign memory to these resources.
The memory needs to be assigned explicitly. We will cover this in chapter Assigning Memory to Resources.

Creating resources in DX12

In DX12, ID3D12Resource is used for both buffers and textures.
The same functions can be used to create buffers and images:

As you can see, we don’t have separate functions for creating buffers and images, like in Vulkan.
In DX12, whether we are creating a buffer or an image is determined by the D3D12_RESOURCE_DESC. More precisely, D3D12_RESOURCE_DESC::Dimension is an enum (D3D12_RESOURCE_DIMENSION):

typedef enum D3D12_RESOURCE_DIMENSION {
  D3D12_RESOURCE_DIMENSION_UNKNOWN = 0,
  D3D12_RESOURCE_DIMENSION_BUFFER = 1,
  D3D12_RESOURCE_DIMENSION_TEXTURE1D = 2,
  D3D12_RESOURCE_DIMENSION_TEXTURE2D = 3,
  D3D12_RESOURCE_DIMENSION_TEXTURE3D = 4
} ;

Notice that in DX12 the term “texture” is used instead of “image”.
In Vulkan, the word “texture” is used sometimes to refer to an image that is going to be sampled. But there is no formal definition of “texture” in the Vulkan spec.
In GLSL, the keyword “texture” is a built-in function that allows sampling an image+sampler.

As we just saw, there are 3 different functions to create buffers and images in DX12: this has to do with the way memory is assigned to the resource. We will cover that in chapter Assigning Memory to Resources.

Assigning Memory to Resources

Here there are important differences in terminology.

In Vulkan, the term used for a memory chunk allocation is “memory” and the handle type is VkDeviceMemory.
In DX12, we call it “heap” and the handle type is ID3D12Heap.

Now, in Vulkan, the term “heap” means a different thing.
In Vulkan, a heap refers to actual physical memory sources. The number of heaps and their properties depend on the particular system. Some of the most important properties of the different heaps are: whether the memory attached to CPU or GPU memory (could be both), whether it’s visible by the CPU or the GPU (again could be both), the size, and the cache coherency model.

In DX12, there is no exact equivalent to what they call “heap” in Vulkan. The closest thing would be D3D12_HEAP_PROPERTIES, which is passed as a parameter to CreateCommittedResource`

Assigning Memory to Resources - Vulkan

You need to query how much memory is needed by the resource, allocate a VkDeviceMemory, and assign a memory range to the resource with vkBindBufferMemory and vkBindImageMemory.
Efficient implementations won’t allocate a VkDeviceMemory for each resource… instead, they will allocate large chunks of memory and assign subranges of the chunk to multiple resources. As you can imagine, GPU memory management in Vulkan can be complicated, so people like to use the VMA library. VMA is a general-purpose GPU memory allocator for Vulkan; it should work pretty well for most applications, but you can always make your own allocation strategies to get better performance.

Assigning Memory to Resources - DX12

In DX12, the memory used by a resource can be assigned automatically if you use CreateCommittedResource.
In this regard, DX12 is much simpler than Vulkan, as you can just forget about memory management for resources.

However, if you want, you can go into hardcore mode and do manual memory management, just like in Vulkan (although it’s recommended to start with the easy way and then optimize incrementally if needed).
Instead of calling CreateCommittedResource, you would call CreateHeap, and then assign sub-ranges to your resources using CreatePlacedResource.
Read this tutorial if you want to learn more about manual memory management in DX12 (Ctrl+F: “Advanced Model”): https://logins.github.io/graphics/2020/07/31/DX12ResourceHandling.html
There is the DX12 equivalent of VMA: https://gpuopen.com/d3d12-memory-allocator.

Shaders

In this section we will see how resources are used in shaders.

Vulkan and DX12 don’t recognize high-level shader code such as GLSL or HLSL; you need to provide them IR (intermediate representation) bytecode.
Vulkan uses SPIR-V (Standard Portable Intermediate Representation - Vulkan).
DX12 uses DXIL (DirectX Intermediate Language).

Vulkan DX12
High-Level Language GLSL HLSL
Low-Level IR SPIR-V DXIL

Even though GLSL and HLSL are respectively the official high-level languages for Vulkan and DX12, it’s not mandatory to use them.

Both GLSL and HLSL can be compiled to SPIR-V.

Having so many HW vendors, operating systems, and graphics APIs adds a lot of work for developers. Companies usually try to impose their own thing, and they want to be the ones that define “the standard”, so we end up with many different competing standards.

In the realm of shaders, however, something wonderful (and surprising) happened: SPIR-V has become a true standard.

The first thing that contributed to this miracle is SPIRV-Cross. SPIRV-Cross is a tool/library made by Khronos that can convert SPIR-V to other languages such as GLSL, MSL, and HLSL.

SPIR-V contributed to the following scenario:

The second thing that stablished SPIR-V is… when Microsoft anounced that they will be officially supporting SPIR-V in DX12: https://devblogs.microsoft.com/directx/directx-adopting-spir-v/

spirv ecosystem

So now SPIR-V is the standard interchange format.

Ok, so SPIR-V is very cool, but you won’t be programming directly in SPIR-V because it’s designed for tools and drivers, not for humans.

In this post we will cover how resources are used in GLSL and HLSL.

Shader Stages

Here’s a table that matches the names of the shader stages in Vulkan and DX12.

Vulkan DX12
Graphics Pipeline
Vertex Shader Vertex Shader
Fragment Shader Pixel Shader
Geometry Shader Geometry Shader
Tessellation Control Shader Hull Shader
Tessellation Evaluation Shader Domain Shader
Compute Pipeline
Compute Shader Compute Shader
Mesh Shading Pipeline
Task Shader Amplification Shader
Mesh Shader Mesh Shader

Pipelines

Vulkan DX12
Pipeline PSO (Pipeline State Object)
PipelineLayout Root Signature
set space
binding slot

Shaders are used to create Pipeline objects.
Pipeline objects contain state that shaders need to know: the format of the vertex buffers, configuration of the rasterizer, configuration of color blending, configuration of the depth testing, the resources that can be bound, and much much more

In Vulkan they use the term “pipeline”; in DX12 they use the term “PSO” (pipeline state object).

Resource layouts

For creating a pipeline object, a resource layout needs to be specified.
The resource layout defines what resources can be bound to a pipeline, and what stages can access each of the bound resources.
In Vulkan they use the term “pipeline layout”; in DX12 they use the term “root signature”.

Resource layouts are organized in groups.
In Vulkan, these groups are called “descriptor sets”.
In DX12, these groups are called “spaces”.

Inside each of these groups (set/space), we have slots for our resource descriptors.
We will cover descriptors extensively later. For now, you can just think of them as pointers to resources with some metadata.
In Vulkan they use the term “binding” for the individual descriptor slots; in DX12, they just call it “slot”.

The criteria for grouping descriptors into the same set/space is update frequency.
For example, you could have 3 different sets:

This way, we only need to rebind the descriptor set that changes (better performance and code organization).

Sets/spaces and bindings/slots can be assigned arbitrary numbers, meaning you don’t need to assign them consecutive numbers. Example resource layout:

Set Binding Name
2 1 lightsBuffer
5 shadowMap
2 environmentMap
5 0 albedoTexture
1 normalTexture
2 pbrUniformBuffer

Also, as you can see, you can have the same binding number in two different sets/spaces. For example, lightsBuffer and normalTexture both have binding number “1”.

Here’s an important distinction: in DX12 the slots are per resource type, meaning you can have a texture in slot 0 and, at the same time, a buffer in slot 0. In Vulkan that won’t work; slots are shared for all resource types.
Check out the following shader code:

// GLSL
layout (set = 1, binding = 0) uniform texture2D u_albedoTexture;
layout (set = 1, binding = 1) uniform sampler u_albedoSampler; 
// HLSL
Texture2D<float4> u_albedoTexture : register(t0, space0);
SamplerState u_albedoSampler : register(s0, space0);

As you can see, in GLSL the sampler and the texture have different binding slots.
In HLSL, both the texture and the sampler can be assigned to slot 0, because they are different types of resource.
Also, you can see that, in HLSL, each type of resource has a different prefix:

Resource Views

Buffers and images can have views to them.
Views can:

  1. Reference parts of a resource.
  2. Interpret the resource with a different format.

For example, you could have an ImageView that references a specific mip within an Image.
Or you could have a buffer view that references a range of bytes within a buffer, and interprets them as RGB8 colors.

In Vulkan, we have just two types of view:

In DX12, we have all these:

When I initially learned about all the resource view types in DX12, I thought it was unnecessarily complex. Why so many types of view?
It turns out that the term “view” doesn’t mean exactly the same in Vulkan and DX12.
In DX12, the concepts of “view” and “descriptor” are tied together.

Descriptors

Descriptors are handles that can be bound to the slots of resource sets.
Descriptors are lightweight objects that reference the resources we want to bind to pipelines/shaders.

In Vulkan, views and descriptors are two completely separate things.
Descriptors don’t reference buffers/images directly; descriptors reference views, and the view references the buffer/image.

In DX12, views and descriptors are closely related.
Views can be used directly as descriptors. That’s why in DX12 there are so many types of view: the view has information about how the resource is used, so it can be used as a descriptor.

These are the types of descriptor in Vulkan:

In Vulkan and GLSL, the term “uniform” denotes that a binding is read-only.

These are the types of descriptor in DX12:

As you can see, in DX12 there is a peculiar overlap between descriptors and views. For example:

The following table tries to match the descriptor types in Vulkan/DX12, and how they are used in GLSL/HLSL.
After the table, there will be more detailed discussion.

DX12
descriptor
type
Vulkan
descriptor
type
HLSL GLSL
sampler sampler
SamplerState u_sampler
: register(...);
layout (...)
uniform sampler u_sampler;
sampler sampler
SamplerComparisonState
u_sampler : register(...);
layout (...)
uniform samplerShadow u_sampler;
- combined
image+sampler
-
layout (...)
uniform sampler2D u_texture;
CBV Uniform
Buffer
cbuffer Uniforms :
register(...) {
  float4 A;
  float4 B;
};

or:

struct Uniforms {
  float4 A;
  float4 B;
};
ConstantBuffer<Uniforms>
uniforms : register(...);
layout(...)
uniform Uniforms {
    vec4 A;
    vec4 B;
};

or:

layout(...)
uniform Uniforms {
    vec4 A;
    vec4 B;
} uniforms;
SRV Uniform
Texel
Buffer
Buffer<float3> colors
: register(...); 
layout (...)
uniform samplerBuffer colors;
UAV Storage
Texel
Buffer
RWBuffer<float3> colors
: register(...); 
layout (...)
uniform imageBuffer colors;
SRV Sampled
Image
Texture2D albedo
: register(...); 
layout (...)
uniform texture2D albedo;
UAV Storage
Image
RWTexture2D albedo
: register(...); 
layout (...)
uniform image2D albedo;
UAV Storage
Buffer
struct Particle {
  float4 position;
  float4 speed;
};
RWStructuredBuffer<Particle> particles
: register(...); 
struct Particle {
  vec4 position;
  vec4 speed;
};
layout (std430, ...)
buffer Particles {
  Particle particles[];
};
SRV Storage
Buffer
ByteAddressBuffer data
: register(...); 
layout (std430, ...)
readonly buffer Data {
  uint data[];
};
UAV Storage
Buffer
RWByteAddressBuffer data
: register(...); 
layout (std430, ...)
buffer Data {
  uint data[];
};
UAV Storage
Buffer
AppendStructuredBuffer<T> list
: register(...);

// ...
list.Append(X);
layout (std430, set = 0, binding = 0)
buffer List {
    uint count;
    T elems[];
} list;

// ...
list.elems[atomicAdd(list.count, 1)] = X;
UAV Storage
Buffer
ConsumeStructuredBuffer<T> list
: register(...);

// ...
X = list.Consume();
layout (std430, set = 0, binding = 0)
buffer List {
    uint count;
    T elems[];
} list;

// ...
X = list.elems[atomicAdd(list.count, -1) - 1];

I have deliberately skipped DX12’s TextureBuffer/tbuffer: they are weird. They are basically the same as a constant buffer, but they are supposedly optimized for “arbitrarily indexed data”. There is very little information around about tbuffer; it doesn’t look like people are using them.

Sampler

Sampler descriptors are fairly simple. In both APIs, these kinds of descriptors are handles that reference a sampler.

In HLSL:

// declare sampler
SamplerState u_sampler : register(s0, space0);
// use sampler
float4 color = u_sampler.Sample(myTexture, uv);

In GLSL:

// declare sampler
uniform sampler u_sampler;
// use sampler
vec4 color = texture(sampler2D(myTexture, u_sampler), uv);

Compare Sampler

Compare samplers are used to fetch texels and compare them against a reference value.
It’s commonly used in shadow mapping to figure out if a pixel is in shadow or not.
When the texture is sampled we get 1.0 if the comparison is true, and 0.0 if the comparison is false.
However, you can get values in between [0, 1] when using multiple samples (e.g when using linear filtering), then you get the percentage of comparisons that passed.

In HLSL:

// declaration
SamplerComparisonState u_sampler : register(s0, space0);
// usage
float visible = shadowMap.SampleCmpLevelZero(u_sampler, uv, referenceDepth);

In GLSL:

// declaration
layout (set = 0, binding = 0) uniform samplerShadow u_sampler;
// usage
float visible = texture(sampler2DShadow(shadowMap, u_sampler), vec3(uv, referenceDepth));

Combined Image-Sampler

Combined image-samplers are exclusive to Vulkan.

In GLSL:

// declare sampler
layout (...) uniform sampler2D u_texture;
// use sampler
vec4 color = texture(u_texture, uv);

This also applies to shadow samplers:

In GLSL, with combined image-sampler:

// declaration
layout (...) uniform sampler2DShadow u_shadowTex;
// usage
float visible = texture(u_shadowTex, vec3(uv, referenceDepth));

Uniform Buffer

Uniform Buffer in Vulkan.
ConstantBuffer in DX12.

A buffer that contains a set of constants that can be read from the shader.
Usually, these kind of buffers are limited in size. Keeping them small can allow them to be placed in local, small, fast-to-access memory types.

In GLSL:

// declaration
layout(...)
uniform Uniforms {
    vec4 A;
    vec4 B;
};

// usage
vec4 AB = A + B;

or:

// declaration
layout(...)
uniform Uniforms {
    vec4 A;
    vec4 B;
} uniforms;

// usage
vec4 AB = uniforms.A + uniforms.B;

In HLSL:

// declaration
cbuffer Uniforms :
register(...) {
  float4 A;
  float4 B;
};

// usage
float4 AB = A + B;

or:

// declaration
struct Uniforms {
  float4 A;
  float4 B;
};
ConstantBuffer<Uniforms>
uniforms : register(...);

// usage
float4 AB = uniforms.A + uniforms.B;

Warning: in both APIs there are alignment rules that need to be taken into account. This applies to all kinds of buffers, but it’s even more restrictive with uniforms buffers. I will not cover alignment rules here, as that could make it’s own post.

Uniform Texel Buffer

Uniform Texel Buffers, according to Vulkan, allow to access buffers with texture-like operations in shaders. I think this definition is misleading. To me they are just 1D arrays, but with a fixed color format for it’s elements.

In GLSL:

// declaration
layout (...) uniform samplerBuffer colors;
// usage
vec4 c = texelFetch(colors, 123);

In HLSL the equivalent would be Buffer:

// declaration
Buffer<float4> colors;
// usage
float4 c = colors[123];

This post talks about the difference between Buffer and StructuredBuffer: https://darkcorners.dev/buffers-vs-structuredbuffers

Storage Texel Buffer

The same as uniform texel buffer, but allows for writing.

In GLSL:

// declaration
layout (...) uniform imageBuffer colors;
// usage
imageStore(colors, 123, c);

In HLSL the equivalent would be RWBuffer:

// declaration
RWBuffer<float4> colors;
// usage
colors[123] = c;

Sampled Image

An image which can be sampled using a sampler, or fetched using integer coordinates.

In GLSL:

// declaration
layout(...) uniform texture2D albedo;
// usage
vec4 c = texelFetch(albedo, ivec2(12, 34));

In HLSL:

// declaration
Texture2D albedo : register(...);
// usage
float4 c = albedo.Load(int2(12, 34));

Storage Image

An image that can be read & written.

In GLSL:

// declaration
layout(...) uniform image2D albedo;
// usage (read)
vec4 c = imageLoad(albedo, ivec2(12, 34));
// usage (write)
imageStore(albedo, ivec2(12, 34), 0.5*c);

In HLSL:

// declaration
RWTexture2D albedo : register(...);
// usage (read)
float4 c = albedo.Load(int2(12, 34));
// usage (write)
albedo.Store(int2(12, 34), 0.5*c);

Storage Buffer

Vulkan’s Storage Buffer is very versatile.
In GLSL, we can just use the “buffer” keyword.
In HLSL, for convenience, there are multiple types for doing different things. But we can just simulate those in GLSL.

The thing that makes GLSL’s buffer so versatile, is the fact that you can add an unspecified-length array as the last member.

Here follows DX12’s buffer types that can be emulated with Vulkan’s Storage Buffers:

StructuredBuffer

A StructuredBuffer is a buffer that is interpreted as an array of a given type.
The type can be any of the basic types (e.g int2, float4x4, etc), or a struct.
In HLSL, there is StructuredBuffer and RWStructuredBuffer. In GLSL, the buffer is read&write by default, but you can prepend the keyword readonly to make it… read-only.

In HLSL:

// declaration
struct Particle {
  float4 position;
  float4 speed;
};
RWStructuredBuffer<Particle> particles : register(...);

// usage
for (int i = 0; i < 1000; i++)
  particles[i].position += particles[i].speed * dt;

In GLSL:

// declaration
struct Particle {
  vec4 position;
  vec4 speed;
};
layout (std430, ...) buffer Particles {
  Particle particles[];
};


// usage
for (int i = 0; i < 1000; i++)
  particles[i].position += particles[i].speed * dt;

ByteAddressBuffer

A buffer interpreted as an array of bytes.

In GLSL there isn’t any direct equivalent, but it can be emulated with an array of uint and bitmasks.
This is a bit cumbersome, yeah.
However, it’s a fairly good way to emulate it since, in HLSL, the ByteAddressBuffer needs to be 4-byte aligned anyway.

AppendStructuredBuffer

It’s a type of StructuredBuffer that can be used to build a list by appending elements.

In GLSL, this can be emulated with an unspecified-length array, and an atomic counter.

In HLSL:

// declaration
AppendStructuredBuffer<T> list : register(...);

// usage
list.Append(X);
// declaration
layout (std430, set = 0, binding = 0) buffer List {
    uint count;
    T elems[];
} list;

// usage
list.elems[atomicAdd(list.count, 1)] = X;

atomicAdd adds some number (1 in this case) to list.count, and returns the old value.

ConsumeStructuredBuffer

It’s a type of StructuredBuffer that appears as a stream that you can consume values from.

In GLSL, this can be emulated with an unspecified-length array, and an atomic counter.

// declaration
ConsumeStructuredBuffer<T> list : register(...);

// usage
X = list.Consume();
// declaration
layout (std430, set = 0, binding = 0) buffer List {
    uint count;
    T elems[];
} list;

// usage
X = list.elems[atomicAdd(list.count, -1) - 1];

Conclusion

I hope this post has helped you in some way.
Let me know in the comments if you have any feedback or questions.

Links

>> Home