Recursive AI

Read shader reflection in C#: bindings, buffer layouts and vertex inputs

All oneshader recipes

Read what a compiled HLSL shader binds, where, in which stages, its buffer layouts and its vertex inputs, from C# or as JSON, and turn it into Vulkan descriptor set layouts, with no native SPIRV-Reflect.

Every compile returns reflection next to the code: resources with their slots per target and their byte layouts, push-constant blocks, and each entry point’s inputs, outputs, used resources and thread-group size. It comes from the compiler, not from parsing the SPIR-V afterwards, and it’s the same model for all three targets.

From C#

With RecursiveAI.OneShader 1.2.0 and the Shaders/Sprite.hlsl of Getting started:

C#
using OneShader;
using OneShader.Layouts;
using OneShader.Layouts.Vulkan;
using OneShader.Reflection;
 
CompileResult result = ShaderCompiler.CompileFile("Shaders/Sprite.hlsl");
if (!result.Success) { foreach (var d in result.Diagnostics) Console.WriteLine(d); return; }
ShaderReflection reflection = result.Reflection!;
 
// Resources: kind, slot, stages, and the byte layout of buffers.
foreach (ResourceInfo r in reflection.Resources)
{
    TargetSlot slot = r.Slots.First(s => s.Target == CompileTarget.SpirV);
    Console.WriteLine($"{r.Name}: {r.Kind}, set {slot.Set} binding {slot.Binding}, stages {r.Stages}");
    if (r.Layout is { Kind: ReflectedTypeKind.Struct } layout)
        foreach (ReflectedMember m in layout.Members)
            Console.WriteLine($"    {m.Name} at {m.Offset}, {m.Type.Size} bytes");
}
 
// Entry points: stage, resources used, and vertex inputs with their formats.
foreach (EntryPointInfo e in reflection.EntryPoints)
{
    Console.WriteLine($"{e.Name}: {e.Stage}, uses {string.Join(", ", e.UsedResources)}");
    foreach (StageVariable v in e.Inputs.Where(v => v.Format is not null))
        Console.WriteLine($"    in location {v.Location}: {v.Semantic}{v.SemanticIndex} {v.Format}");
}
 
// From reflection to a Vulkan pipeline layout.
EntryLayout.TryCreate(reflection, "VSMain", out EntryLayout? vs, out _);
EntryLayout.TryCreate(reflection, "PSMain", out EntryLayout? ps, out _);
if (!PipelineLayout.TryCreate([vs!, ps!], out PipelineLayout? pipeline, out LayoutError? error))
{
    Console.WriteLine(error);
    return;
}
foreach (DescriptorSetLayoutInfo set in pipeline!.ToVulkan().SetLayouts)
    foreach (DescriptorSetLayoutBindingInfo b in set.Bindings)
        Console.WriteLine($"VkDescriptorSetLayoutBinding {{ binding = {b.Binding}, descriptorType = {(uint)b.DescriptorType}, descriptorCount = {b.DescriptorCount}, stageFlags = 0x{(uint)b.StageFlags:x} }}");
Text
Frame: ConstantBuffer, set 0 binding 0, stages Vertex
    ViewProjection at 0, 64 bytes
    Tint at 64, 16 bytes
Atlas: SampledTexture, set 0 binding 1, stages Pixel
AtlasSampler: Sampler, set 0 binding 2, stages Pixel
VSMain: Vertex, uses Frame
    in location 0: POSITION0 Float32x2
    in location 1: TEXCOORD0 Float32x2
    in location 2: COLOR0 Unorm8x4
PSMain: Pixel, uses Atlas, AtlasSampler
VkDescriptorSetLayoutBinding { binding = 0, descriptorType = 6, descriptorCount = 1, stageFlags = 0x1 }
VkDescriptorSetLayoutBinding { binding = 1, descriptorType = 2, descriptorCount = 1, stageFlags = 0x10 }
VkDescriptorSetLayoutBinding { binding = 2, descriptorType = 0, descriptorCount = 1, stageFlags = 0x10 }

descriptorType 6, 2 and 0 are VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, SAMPLED_IMAGE and SAMPLER. stageFlags 0x1 and 0x10 are VK_SHADER_STAGE_VERTEX_BIT and FRAGMENT_BIT.

  • Slots has one entry per compiled target. Vulkan and WGSL use the same set and binding, and the Metal slot is the argument index.
  • Layout is the byte layout of a buffer, members, offsets, array and matrix strides included, equal for every target.
  • PipelineLayout.TryCreate merges entry points that run together: a binding used by both gets the union of their stages, and a conflict comes back as a LayoutError value. Unused sets are empty, as Vulkan needs them.
  • Projects that use RecursiveAI.OneShader.Build don’t need this at run time: the generated class has the same data as constants, and Sprite.Reflection holds the reflection it was generated from.

As JSON from the command line

Terminal
oneshader -T vs_6_0 -E VSMain --reflect sprite.vs.json Shaders/Sprite.hlsl -Fo sprite.vs.spv

The start of the entry point in sprite.vs.json:

JSON
  "entryPoints": [
    {
      "name": "VSMain",
      "stage": "Vertex",
      "vertexStride": 20,
      "inputs": [
        {
          "name": "i.Position",
          "semantic": "POSITION",
          "semanticIndex": 0,
          "location": 0,
          "builtIn": "None",
          "scalar": "Float",
          "components": 2,
          "format": "Float32x2",
          "offset": 0,
          "interpolation": "Default"
        },

The JSON is schema version 1: keys in camelCase, enums by name, a fixed order, \n line ends and no machine paths, so the same compile gives the same bytes. Breaking changes raise schemaVersion.

The build task’s own reflection file has a different, line-based format, described in Reflection file format. To check a recompiled shader against the reflection the C# was built from, see Reload shaders while the app runs.