-
Notifications
You must be signed in to change notification settings - Fork 568
Reflection API user guide
The reflection API of SPIRV-Cross is quite comprehensive, but for more exotic use cases, it's not always obvious how to go about reflecting stuff. There are some idiosyncrasies with SPIR-V which are mirrored in the API to some degree.
The first step you will always take is creating the reflection object.
#include "spirv_cross.hpp"
vector<uint32_t> spirv = load_spirv_from_file();
spirv_cross::Compiler comp(move(spirv)); // const uint32_t *, size_t interface is also available.
The most common thing to do is to collect which resources are used in a SPIR-V module. Resources are things like images and buffer and the variations of these things. It also contains information about the input and output interfaces of your shaders.
Resources have decorations attached to them. These decorations tell where a resource is bound. The most common ones you want are:
-
DecorationDescriptorSet
maps tolayout(set = N)
in Vulkan GLSL orregister(spaceN)
in HLSL. -
DecorationBinding
maps tolayout(binding = N)
in GLSL or: register(bN)
in HLSL. -
DecorationLocation
maps tolayout(location = N)
in GLSL. This is used for in/out variables. The mapping to HLSL is not as obvious.
SPIRV-Cross has a convenient function for gathering all this information from a shader:
ShaderResources res = comp.get_shader_resources();
In this struct you will find arrays of all resource types.
GLSL declaration | HLSL declaration | ShaderResources member | Vulkan concept |
---|---|---|---|
sampler2D | N/A | sampled_images |
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER |
texture2D | Texture2D | separate_images |
VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE |
image2D | RWTexture2D | storage_images |
VK_DESCRIPTOR_TYPE_STORAGE_IMAGE |
samplerBuffer | Buffer | separate_images (type.image.dim = DimBuffer) |
VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER |
imageBuffer | RWBuffer | storage_images (type.image.dim = DimBuffer) |
VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER |
sampler / samplerShadow | SamplerState / SamplerComparisonState | separate_samplers |
VK_DESCRIPTOR_TYPE_SAMPLER |
uniform UBO {} | cbuffer buf {} | uniform_buffers |
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER or VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC (up to you) |
layout(push_constant) uniform Push | N/A (root constants?) | push_constant_buffers |
VkPushConstantRange in VkPipelineLayoutCreateInfo
|
subpassInput | N/A | subpass_inputs |
VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT |
in vec2 uv (vertex shaders) |
void VertexMain (in uv : TEXCOORD0) |
stage_inputs |
VkVertexInputAttributeDescription |
out vec4 FragColor (fragment shaders) |
float4 FragmentMain() : SV_Target0 |
stage_outputs |
Several things, but particularly useful to set write mask to 0 in VkPipelineColorBlendAttachmentState if a location is not written to in shader. |
buffer SSBO {} | StructuredBuffer / RWStructuredBuffer / etc | storage_buffer |
VK_DESCRIPTOR_TYPE_STORAGE_BUFFER or VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC (up to you) |
In certain cases, you might want to only check for resources which are statically used by the SPIR-V module. I.e., you don't care about textures or buffers which are never accessed by the shader. Use the following:
auto active = comp.get_active_interface_variables();
ShaderResources = comp.get_shader_resources(active);
comp.set_enabled_interface_variables(move(active));
For each resource, there are usually four things a user will care about:
- Name
Resource::name
, as declared withOpName
in the SPIR-V module. Do note that this name has zero significance on Vulkan, so normally, you should not care about Name here unless your backend assigns bindings based on name, or for debugging purposes. - Descriptor sets and bindings
Compiler::get_decoration(Resource::id, spv::DecorationDescriptorSet)
. These can be used to automatically deduce a vkDescriptorSetLayout (and vkPipelineLayout) for Vulkan if you combine information from all shader stages. - The types
Resource::base_type_id
andResource::type_id
. In most cases, this does not matter, since the descriptor type is sorted out for you inShaderResources
already, but in certain scenarios you need more information. How to query and deal with types will be discussed in greater detail later. - The ID. This ID represents the resource variable, and can be used with other reflection features in the API.
Use Compiler::get_decoration(id, decoration)
.
ShaderResources res = ...;
for (const Resource &resource : res.sampled_images)
{
unsigned set = comp.get_decoration(resource.id, spv::DecorationDescriptorSet);
unsigned binding = comp.get_decoration(resource.id, spv::DecorationBinding);
}
for (const Resource &resource : res.subpass_inputs)
{
unsigned attachment_index = comp.get_decoration(resource.id, spv::DecorationInputAttachmentIndex);
}
In general, the name of an object maps directly to how it is declared in the shader.
uniform sampler2D MySampler;
has a name MySampler
assuming your toolchain emits debug information.
However, block types are more interesting, as they have "two" names, e.g. for GLSL:
-
layout(std140) uniform UBO { ... } ubo;
. Which name to use here? -
layout(std430) buffer SSBO { ... } ssbo;
. Which name to use here? -
layout(push_constant) uniform Push { ... } push;
.
and for HLSL:
RWStructuredBuffer<T> UAV;
cbuffer cbuf { ... };
The name returned by SPIRV-Cross aims to return the most significant name. However, this leads to some weirdness in some cases.
- GLSL UBO returns the block name, i.e. "UBO" in the above list.
- GLSL SSBO returns the block name, i.e. "SSBO".
- GLSL push constant returns the instance name, i.e. "push". The rationale here is that push constants will be translated to
uniform Push push;
in GLSL, and thus "push" becomes the relevant name for purposes of dealing withglUniform*()
interfaces in GL backends. - (OLD BEHAVIOR, SEE BELOW) RWStructuredBuffer returns a meaningless name, or misleading name. HLSL as emitted by at least glslang does not really care much for the Block names, the instance name will be UAV ...
- cbuffer cbuf { ... } returns "cbuf" here.
New heuristics from (2019-06-10)
For newer versions of SPIRV-Cross, it will use heuristics to determine how to report names for SSBO/UAVs. First, we check OpSource. If it is reported as HLSL, UAVs are reported based on their instance name rather than block name. This matches up with behavior in glslangValidator and DXC.
If not HLSL, we use the old method of looking at block name rather than instance name.
Finally, if OpSource is not declared (which is probably since it's technically a debug instruction), we employ a heuristic to figure out if the source was GLSL or HLSL. If there are UAVs which refer to the same OpType, that is the common case when HLSL code declares multiple StructuredBuffers with same type. GLSL codegen does not do this.
You can always bypass the heuristic by explicitly looking at the Resource::id
or Resource::base_type_id
names.
If you want to see "UAV" for RWStructuredBuffer you need to look at the instance name, and you can use the direct name querying interface for this:
const string &uav_name = compiler.get_name(res.storage_buffers[0].id);
You can ignore Resource::name
and just use the get_name()
API instead if you know better what kind of naming convention the SPIR-V is using. To query the block name, you would use:
const string &block_name = compiler.get_name(res.storage_buffers[0].base_type_id);
Eventually, you might want to start inspecting types in more detail.
In the Resource
struct, you will find base_type_id
and type_id
, why two?
This is one of the larger idiosyncrasies with SPIR-V. The type system is built up hierarchically. The way it works is that you start with a base type and build up new types by adding modifiers to other types, here in pseudo-asm:
%block = TypeStruct %float %int <-- base_type_id
MemberName %block 0 "my_float" <-- Debug information like names apply to base_type_id
MemberName %block 1 "my_int"
MemberDecorate Offset %block 0 0 <-- Buffer memory layout information as well
MemberDecorate Offset %block 0 4
%array-block = TypeArray %block 10 <-- Information like arrays apply to type_id
%ptr-array-block = TypePointer Uniform %array-block <-- type_id
%variable = Variable %ptr-array-block <-- id
For this reason, there are times you will want to use one or the other. You can get an internal representation of a type using:
const SPIRType &base_type = comp.get_type(resource.base_type_id);
const SPIRType &type = comp.get_type(resource.type_id);
SPIRType
is defined in spirv_common.hpp
.
SPIRType::basetype
contains the basic type. Here you can check for things like:
- Boolean
- Int
- UInt
- Float
- Struct
- Image
texture2D or Texture2D<T>
- SampledImage
sampler2D
- Sampler
sampler or SamplerState
For vectors and matrices, look at SPIRType::vecsize
and SPIRType::columns
. 1 column means it's a vector.
SPIRType::array
is a vector, where each element denotes size of each dimension. For non-array types, this vector will be empty.
In SPIR-V, array size can come from specialization constants, so there's an equally large array array_size_literal
which tells if each element is a specialization constant variable ID or not. Except for really odd scenarios, this will always just contain true (literal array sizes).
Unsized arrays, e.g. as part of SSBO or StructuredBuffer, will have 0 as their size.
This is mostly used for querying arrays of resources, e.g.:
uniform sampler2D uSampler[10];
for (const Resource &resource : res.sampled_images)
{
const SPIRType &type = comp.get_type(resource.type_id); // Notice how we're using type_id here because we need the array information and not decoration information.
print(type.array.size()); // 1, because it's one dimension.
print(type.array[0]); // 10
print(type.array_size_literal[0]); // true
}
E.g. a declaration like int a[4][6]
, will be declared like:
array = { 6, 4 };
array_size_literal = { true, true };
i.e. backwards.
C-style array declarations are a bit backward in this sense. The way this would be declared in pseudo-SPIR-V is:
%int = OpTypeInt
%int6 = OpTypeArray %int 6
%int4 = OpTypeArray %int6 4
If SPIRType::basetype
is SPIRType::Struct
, the member types are found in SPIRType::member_types
.
Use comp.get_type(type.member_types[i])
to dig deeper.
If SPIRType::basetype
is Image
or SampledImage
, you can peek at SPIRType::image
for more information about the image. This struct closely mirrors SPIR-V.
-
type
: This is the type ID returned when the image is sampled or read from. E.g.Texture2D<float4>
. IIRC, it will always have 4 components. -
dim
: Dimensionality. Here you can check for 1D, 2D, 3D, Cube, Buffer, etc. -
depth
: If the image will be used for comparison sampling. Unfortunately, this information is not very reliable for separate images, only SampledImage. -
arrayed
: sampler2DArray vs sampler2D, etc. -
ms
: sampler2DMS vs sampler2D -
sampled
: 1 means that the image can be sampled from, e.g.sampler*
,texture*
, (or SRV in HLSL). 2 means image load/store (UAV in HLSL). 0 means nothing. Unfortunately, there is no convenient enum for this in SPIR-V headers. -
format
: For image load/store (UAV) images. This is the format declared in the layout, e.g.:layout(r32f, ...) uniform image2D Image;
. HLSL doesn't really have this, so the format will generally be undefined. -
access
: Irrelevant.
Using this information you can distinguish between some confusing types:
- samplerBuffer vs texture2D (sampled = 1, dim = DimBuffer vs Dim2D)
separate_images
- imageBuffer vs image2D (sampled = 2, dim = DimBuffer vs Dim2D)
storage_images
- Buffer vs Texture2D (sampled = 1, dim = DimBuffer vs Dim2D)
separate_images
- RWBuffer vs RWTexture2D (sampled = 2, dim = DimBuffer vs Dim2D)
storage_images
SPIR-V doesn't clearly make a distinction between read-write and read-only types, unlike HLSL. HLSL for example has variants like: RWStructuredBuffer vs StructuredBuffer, RWBuffer vs Buffer. In SPIR-V, these are decorations on the block rather than being part of the type system.
For RWStructuredBuffer vs StructuredBuffer and friends:
Bitset buffer_flags = comp.get_buffer_block_flags(res.storage_buffers[0].id);
if (buffer_flags.get(spv::DecorationNonWritable))
print("StructuredBuffer");
else
print("RWStructuredBuffer");
RWBuffer vs Buffer is quite different, because they are actually different types in SPIR-V.
RWBuffer is placed in the storage_images
vector. The type is OpTypeImage where sampled = 1, and type.image.dim = DimBuffer. It is essentially the sibling type of RWTexture2D.
Buffer is an SRV, so it will be found in separate_images
(even if it's a buffer, I know). To distinguish Buffer from Texture2D, check for type.image.dim = DimBuffer.
Counter buffers are a legacy artifact of HLSL. When using Append/ConsumeBuffer, or the builtin counter for a StructuredBuffer, SPIR-V will have two separate buffers, which are completely unrelated. However, this makes using the resulting SPIR-V rather annoying because you need to tie the two buffers together somehow if your API abstraction treats this as a single buffer ...
On older toolchains, the way SPIRV-Cross deals with this is basically pure magic. glslang emits a specially crafted OpName, which we use to deduce which buffer objects belong to an other object. Do not strip SPIR-V modules if you need counter buffer support unless you can use the method below. It's also only tested on glslang, not DXC/spiregg, so beware.
However, using the new SPIR-V decorations for counter buffers, there is a more "robust" way to detect these things without having to rely on shaky name parsing. The API does not change, but it's something to be aware of.
bool is_counter_buffer = comp.buffer_is_hlsl_counter_buffer(res.storage_buffers[0].id);
uint32_t counter_buffer_id;
if (comp.buffer_get_hlsl_counter_buffer(res.storage_buffers[0].id, counter_buffer_id))
print("Found AppendBuffer, with a counter buffer.");
This part of the API is mostly to figure out buffer offsets, how struct members are packed in memory, etc.
If you want to check how big a buffer is:
for (const Resource &resource : res.uniform_buffers)
{
const SPIRType &type = comp.get_type(resource.base_type_id);
size_t size = comp.get_declared_struct_size(type);
print("UBO size: %zu\n", size);
}
Similar can be done for push constant blocks, and SSBOs/UAVs. It's pretty useful for crafting VkPushConstantRange
structs automatically.
If you have runtime sized arrays, which is the common case for SSBO/UAVs, you will likely get
back 0 when you try to call get_declared_struct_size()
. To deal with runtime sized arrays,
you can use get_declared_struct_size_runtime_array()
and pass in your intended array size
to check how big the buffer will be.
Buffer objects are always declared as structs, and you can query them deeply. For example, here's how to query name, offset, array stride and matrix strides for all members of a buffer block. You can recurse as necessary.
for (const Resource &resource : res.uniform_buffers)
{
auto &type = comp.get_type(resource.base_type_id);
unsigned member_count = type.member_types.size();
for (unsigned i = 0; i < member_count; i++)
{
auto &member_type = comp.get_type(type.member_types[i]);
size_t member_size = comp.get_declared_struct_member_size(type, i);
// Get member offset within this struct.
size_t offset = comp.type_struct_member_offset(type, i);
if (!member_type.array.empty())
{
// Get array stride, e.g. float4 foo[]; Will have array stride of 16 bytes.
size_t array_stride = comp.type_struct_member_array_stride(type, i);
}
if (member_type.columns > 1)
{
// Get bytes stride between columns (if column major), for float4x4 -> 16 bytes.
size_t matrix_stride = comp.type_struct_member_matrix_stride(type, i);
}
const string &name = comp.get_member_name(type.self, i);
}
}
For certain cases like push constants, it might be useful to isolate which part of a buffer block is statically accessed by the shader. However, beware that this tracking only applies to the top-level members of the block, so if the top level member is a lone struct, this will not be very helpful.
vector<BufferRange> ranges = comp.get_active_buffer_ranges(res.push_constant_buffers[0].id);
for (auto &range : ranges)
{
print(range.index); // Struct member index
print(range.offset); // Offset into struct
print(range.range); // Size of struct member
}
If you have declared specialization constants in your shader, you can query them here.
layout(constant_id = 20) const int Const = 40;
vector<SpecializationConstant> consts = comp.get_specialization_constants();
for (auto &c : consts)
{
print(c.id); // The ID of the spec constant, useful for further reflection.
print(c.constant_id); // 20
const SPIRConstant &value = comp.get_constant(c.id);
print(value.scalar_i32()); // 40
print(comp.get_name(c.id)); // Const
}
SPIRConstant
is declared in spirv_common.hpp
.
If you have multiple entry points, you might want to pick the relevant one for you before reflecting, as some functions like "active variables" depends on the current entry point.
std::vector<EntryPoint> entry_points = comp.get_entry_points_and_stages(); // query
comp.set_entry_point("MySpecialMain", spv::ExecutionModelFragment); // set