Recursive AI

One pipeline layout for many shaders

All oneshader recipes

Share one Vulkan descriptor set layout, or one WebGPU bind group layout, across many HLSL shaders with PipelineLayout.TryCreateUnion, even where one shader binds a 2D texture and another a cube map at the same slot. Includes the target-neutral binding list from ToBindings.

A renderer that binds its per-frame resources once, as one descriptor set for every pipeline, needs one layout that fits all of them. PipelineLayout.TryCreateUnion merges the layouts of any number of entry points. A slot used by several gets the union of their stages, and a real conflict comes back as a value that names both shaders. Since 1.3.0, UnionOptions also accepts the differences that one Vulkan descriptor covers anyway.

Three files share set 0. Shaders/Common.hlsli:

HLSL
cbuffer Frame : register(b0) { float4x4 ViewProjection; float3 CameraPosition; };
struct Light { float3 Position; float Radius; float3 Color; uint Visible; };

Shaders/Forward.hlsl reads a 2D albedo texture at binding 1 and the lights, read-only, at binding 3:

HLSL
#include "Common.hlsli"
[[vk::binding(1)]] Texture2D<float4> Albedo;
[[vk::binding(2)]] SamplerState Linear;
[[vk::binding(3)]] StructuredBuffer<Light> Lights;
 
struct VSOut { float4 Position : SV_Position; float3 World : POSITION; float2 Uv : TEXCOORD0; };
 
[shader("vertex")]
VSOut VSMain(float3 position : POSITION, float2 uv : TEXCOORD0)
{
    VSOut o;
    o.Position = mul(ViewProjection, float4(position, 1));
    o.World = position;
    o.Uv = uv;
    return o;
}
 
[shader("pixel")]
float4 PSMain(VSOut i) : SV_Target
{
    float3 c = 0;
    for (uint n = 0; n < 16; n++)
        if (Lights[n].Visible != 0)
            c += Lights[n].Color * saturate(1 - distance(Lights[n].Position, i.World) / Lights[n].Radius);
    return Albedo.Sample(Linear, i.Uv) * float4(c, 1);
}

Shaders/Sky.hlsl reads a cube map at the same binding 1:

HLSL
#include "Common.hlsli"
[[vk::binding(1)]] TextureCube<float4> Environment;
[[vk::binding(2)]] SamplerState Linear;
 
struct VSOut { float4 Position : SV_Position; float3 Direction : TEXCOORD0; };
 
[shader("vertex")]
VSOut VSSky(float3 position : POSITION)
{
    VSOut o;
    o.Position = mul(ViewProjection, float4(position + CameraPosition, 1)).xyww;
    o.Direction = position;
    return o;
}
 
[shader("pixel")]
float4 PSSky(VSOut i) : SV_Target { return Environment.Sample(Linear, i.Direction); }

Shaders/Cull.hlsl writes the lights at binding 3:

HLSL
#include "Common.hlsli"
[[vk::binding(3)]] RWStructuredBuffer<Light> Lights;
 
[shader("compute")]
[numthreads(16, 1, 1)]
void CSCull(uint3 id : SV_DispatchThreadID)
{
    Light l = Lights[id.x];
    Lights[id.x].Visible = distance(l.Position, CameraPosition) < 100 + l.Radius ? 1 : 0;
}

The project references RecursiveAI.OneShader.Build 1.4.0 with OneShaderTargets set to SpirV;Msl;Wgsl. Program.cs:

C#
using OneShader.Layouts;
using OneShader.Layouts.WebGpu;
using Union.Shaders;
 
// One descriptor set for the forward pass, the sky and the light-culling compute shader.
EntryLayout[] all =
[
    Forward.VSMain.Layout, Forward.PSMain.Layout,
    Sky.VSSky.Layout, Sky.PSSky.Layout,
    Cull.CSCull.Layout,
];
 
// By default every slot must agree exactly.
if (!PipelineLayout.TryCreateUnion(all, out _, out var error))
    Console.WriteLine(error);
Console.WriteLine();
 
// Allow what one Vulkan descriptor covers: a 2D or cube image, read-only or read-write storage.
var options = new UnionOptions { IgnoreTextureDimension = true, MergeStorageAccess = true };
if (!PipelineLayout.TryCreateUnion(all, options, out var shared, out error))
{
    Console.WriteLine(error);
    return;
}
 
// Target-neutral: what a renderer needs to create the layout in any API.
foreach (BindingDescription b in shared!.ToBindings())
{
    string dims = b.Dimensions.Count > 0 ? $" ({string.Join(", ", b.Dimensions)})" : "";
    Console.WriteLine($"set {b.Set} binding {b.Binding}: {b.Name}, {b.Type}{dims}, stages {b.Stages}");
}
Console.WriteLine();
 
foreach (var set in shared.ToVulkan().SetLayouts)
    foreach (var bb in set.Bindings)
        Console.WriteLine($"Vulkan set {set.Set} binding {bb.Binding}: {bb.DescriptorType}, stages {bb.StageFlags}");
Console.WriteLine();
 
// WebGPU checks the view dimension and the storage access against each shader.
if (!shared.TryToWebGpu(out _, out var webError))
    Console.WriteLine($"WebGPU: {webError}");
var pick = new WebGpuLayoutOptions
{
    TextureDimensions = new Dictionary<(uint, uint), TextureViewDimension> { [(0, 1)] = TextureViewDimension.D2 },
};
if (!shared.TryToWebGpu(out _, out webError, pick))
    Console.WriteLine($"WebGPU with a 2D pick: {webError}");

dotnet run prints:

Text
Set 0 binding 1 is 'Albedo' (SampledTexture, Texture2D of Float, count 1) in entry point 'PSMain' and 'Environment' (SampledTexture, TextureCube of Float, count 1) in entry point 'PSSky'. Renumber one of them with [[vk::binding]] or declare them identically. If the textures only differ in dimension, pass UnionOptions.IgnoreTextureDimension.
 
set 0 binding 0: Frame, UniformBuffer, stages Vertex, Compute
set 0 binding 1: Albedo, SampledTexture (D2, Cube), stages Pixel
set 0 binding 2: Linear, Sampler, stages Pixel
set 0 binding 3: Lights, StorageBuffer, stages Pixel, Compute
 
Vulkan set 0 binding 0: UniformBuffer, stages Vertex, Compute
Vulkan set 0 binding 1: SampledImage, stages Fragment
Vulkan set 0 binding 2: Sampler, stages Fragment
Vulkan set 0 binding 3: StorageBuffer, stages Fragment, Compute
 
WebGPU: Group 0 binding 1 ('Albedo') is declared as Texture2D and TextureCube by different entry points. WebGPU checks the view dimension against the shader, so one layout cannot serve both. Choose one in WebGpuLayoutOptions.TextureDimensions (and build a layout per dimension for the pipelines that need the other), or declare the same texture type everywhere.
WebGPU with a 2D pick: Group 0 binding 3 ('Lights') is a read-only buffer in some entry points and a read-write buffer in others. WebGPU's storage binding only fits shaders that declare it read-write. Declare it RW in every entry point, or name it in WebGpuLayoutOptions.ReadOnlyStorageBuffers if no shader writes it.
  • Without options, the 2D texture and the cube map at binding 1 are a conflict, reported as a value that says what to change. Nothing throws.
  • IgnoreTextureDimension lets them share the slot, since Vulkan’s SAMPLED_IMAGE descriptor holds either. ToBindings keeps both dimensions, so a host knows which views it may bind there.
  • MergeStorageAccess merges the read-only StructuredBuffer and the RWStructuredBuffer at binding 3 into read-write storage. ToBindings reports StorageBuffer, not ReadOnlyStorageBuffer, a distinction Vulkan’s layout can’t express and WebGPU needs.
  • Stages are united: the frame constants are visible to the vertex and compute stages, the lights to the pixel and compute stages.
  • WebGPU is stricter. It checks the view dimension and the storage access of each binding against each shader. One layout can pick one dimension per slot with TextureDimensions, and the pipelines that use the other dimension need a layout of their own. A merged read-only and read-write buffer only fits WebGPU if every shader declares it RW, or, when no shader writes it, if you name it in ReadOnlyStorageBuffers.

Good to know

  • ToBindings works on any PipelineLayout, merged or not. Each BindingDescription has the set and binding, Type (uniform, read-only or read-write storage, sampled or storage texture, sampler, texel buffers), Count, Stages, the texture Dimension and SampleKind, IsComparison, IsDynamic and the MetalIndex. BindingOptions.DepthTextures names depth textures, which HLSL doesn’t tell apart from float textures.
  • Several vertex buffers. Also since 1.3.0, VertexLayout.TrySplit spreads one vertex input over several buffers, for example a per-vertex stream and a per-instance stream, with ToVulkan, ToMetal and ToWebGpu for each.
  • Metal numbers resources per entry point, so TryToMetal on a union reports a slot where two entry points of one stage disagree.
  • Create layouts when shaders load, not every frame: they allocate.

All of these helpers, with their options, are in the compiler package. For a single pipeline, see Build pipeline layouts in Getting started.