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:
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:
#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:
#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:
#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:
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:
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.
IgnoreTextureDimensionlets them share the slot, since Vulkan’sSAMPLED_IMAGEdescriptor holds either.ToBindingskeeps both dimensions, so a host knows which views it may bind there.MergeStorageAccessmerges the read-onlyStructuredBufferand theRWStructuredBufferat binding 3 into read-write storage.ToBindingsreportsStorageBuffer, notReadOnlyStorageBuffer, 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 itRW, or, when no shader writes it, if you name it inReadOnlyStorageBuffers.
Good to know
ToBindingsworks on anyPipelineLayout, merged or not. EachBindingDescriptionhas the set and binding,Type(uniform, read-only or read-write storage, sampled or storage texture, sampler, texel buffers),Count,Stages, the textureDimensionandSampleKind,IsComparison,IsDynamicand theMetalIndex.BindingOptions.DepthTexturesnames depth textures, which HLSL doesn’t tell apart from float textures.- Several vertex buffers. Also since 1.3.0,
VertexLayout.TrySplitspreads one vertex input over several buffers, for example a per-vertex stream and a per-instance stream, withToVulkan,ToMetalandToWebGpufor each. - Metal numbers resources per entry point, so
TryToMetalon 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.