Skip to content

Reflection API user guide

Hans-Kristian Arntzen edited this page Jun 10, 2019 · 19 revisions

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.

Creating the reflection object

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.

Gathering resources

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 to layout(set = N) in Vulkan GLSL or register(spaceN) in HLSL.
  • DecorationBinding maps to layout(binding = N) in GLSL or : register(bN) in HLSL.
  • DecorationLocation maps to layout(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.

Mapping reflection output to Vulkan cheat sheet

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)

Querying statically accessed resources

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));

Inspecting a resource

For each resource, there are usually four things a user will care about:

  • Name Resource::name, as declared with OpName 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 and Resource::type_id. In most cases, this does not matter, since the descriptor type is sorted out for you in ShaderResources 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.

Querying decorations

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);
}

A note on names

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 with glUniform*() 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 probable 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.

Older versions

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);

Type system and deep reflection

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.

Querying fundamental types

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.

Querying array types

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
}

Arrays of arrays

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

Querying structs

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.

Querying image types

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

Read-write vs read-only resources for HLSL

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 and HLSL

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.");

Reflecting buffer blocks

This part of the API is mostly to figure out buffer offsets, how struct members are packed in memory, etc.

Querying size for buffer blocks

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.

Struct member reflection

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);
    }
}

Query statically accessed members

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
}

Specialization constants

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.

Query entry points

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