---
title: "One pipeline layout for many shaders"
url: https://recursiveai.org/oneshader/cookbook/union-pipeline-layouts/
description: "Share one Vulkan descriptor set layout, or one WebGPU bind group layout, across many HLSL shaders with PipelineLayout.TryCreateUnion, even where one…"
version: "1.4.0"
---

# One pipeline layout for many shaders

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`:

```csharp
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](https://recursiveai.org/oneshader/docs/compiler/). For a single pipeline, see Build pipeline layouts in [Getting started](https://recursiveai.org/oneshader/docs/getting-started/).
