Index an unbounded texture array (Texture2D Textures[]) or the Shader Model 6.6 ResourceDescriptorHeap from HLSL, compiled to SPIR-V with descriptor indexing and to a Metal argument buffer, with the Vulkan and Metal layouts generated for you.
Bindless rendering binds every texture once and lets each draw or pixel pick one by index, instead of rebinding per material. OneShader compiles it since 1.4.0. SPIR-V gets a runtime descriptor array with SPV_EXT_descriptor_indexing, as DXC emits it, and Metal gets one argument buffer. WebGPU has no binding arrays, so a bindless shader can’t target WGSL.
An unbounded texture array
Shaders/Scene.hlsl, where each vertex carries the index of its material’s texture:
cbuffer Frame : register(b0) { float4x4 ViewProjection; }; Texture2D<float4> Textures[] : register(t0, space1); // every texture the scene uses SamplerState Linear : register(s1); struct VSIn { float3 Position : POSITION; float2 Uv : TEXCOORD0; uint Material : MATERIAL; }; struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; nointerpolation uint Material : MATERIAL; }; [shader("vertex")] VSOut VSMain(VSIn i) { VSOut o; o.Position = mul(ViewProjection, float4(i.Position, 1)); o.Uv = i.Uv; o.Material = i.Material; return o; } [shader("pixel")] float4 PSMain(VSOut i) : SV_Target { // The index differs between pixels of one draw, so mark it non-uniform. return Textures[NonUniformResourceIndex(i.Material)].Sample(Linear, i.Uv); }
The project references RecursiveAI.OneShader.Build 1.4.0 with OneShaderTargets set to SpirV;Msl. Program.cs:
using OneShader.Layouts; using OneShader.Layouts.Metal; using OneShader.Layouts.Vulkan; using Bindless.Shaders; // The generated binding says the array has no fixed size. Console.WriteLine($"Textures: set {Scene.Bindings.Textures.Set}, binding {Scene.Bindings.Textures.Binding}, " + $"count {Scene.Bindings.Textures.Count}, unbounded {Scene.Bindings.Textures.IsUnbounded}"); PipelineLayout pipeline = Scene.Pipeline; Console.WriteLine($"Device features: {pipeline.RequiredFeatures}"); // Vulkan: a partially bound, variable-count binding, capped at 1024 descriptors. var vulkan = pipeline.ToVulkan(new VulkanLayoutOptions { UnboundedMaxCount = 1024 }); foreach (var set in vulkan.SetLayouts) foreach (var b in set.Bindings) Console.WriteLine($"set {set.Set} binding {b.Binding}: {b.Name}, {b.DescriptorType} x{b.DescriptorCount}, flags {b.BindingFlags}"); foreach (var set in vulkan.SetLayouts) if (set.HasVariableCount) Console.WriteLine($"set {set.Set} needs VkDescriptorSetVariableDescriptorCountAllocateInfo"); // Metal: one argument buffer of 8-byte descriptors. if (pipeline.TryToMetal(out MetalPipelineLayout? metal, out var metalError)) foreach (MetalStageLayout stage in metal!.Stages) foreach (MetalArgument arg in stage.Arguments) Console.WriteLine($"Metal {stage.Stage}: {arg.Name} -> {arg.Kind}({arg.Index})" + (arg.IsArgumentBuffer ? $", argument buffer, {arg.DescriptorStride} bytes per descriptor" : "")); else Console.WriteLine(metalError); // WebGPU has no binding arrays. if (!pipeline.TryToWebGpu(out _, out var webgpuError)) Console.WriteLine($"WebGPU: {webgpuError}");
dotnet run prints:
Textures: set 1, binding 0, count 0, unbounded True Device features: RuntimeDescriptorArray, NonUniformIndexing set 0 binding 0: Frame, UniformBuffer x1, flags None set 0 binding 1: Linear, Sampler x1, flags None set 1 binding 0: Textures, SampledImage x1024, flags PartiallyBound, VariableDescriptorCount set 1 needs VkDescriptorSetVariableDescriptorCountAllocateInfo Metal Vertex: Frame -> Buffer(0) Metal Pixel: Textures -> Buffer(1), argument buffer, 8 bytes per descriptor Metal Pixel: Linear -> Sampler(0) WebGPU: 'Textures' is an unbounded resource array; WebGPU has no binding arrays or descriptor indexing, so it cannot be bound. Build the shader for Vulkan or Metal only, or declare the resources separately.
- Count 0 marks the array as unbounded, in the generated
Bindingsand in reflection (ResourceInfo.IsUnbounded). - Device features.
RequiredFeatureslists what to enable when you create the Vulkan device: runtime descriptor arrays, and non-uniform indexing of sampled images because ofNonUniformResourceIndex. A heap read as several kinds of resource addsMutableDescriptorType. - The Vulkan binding gets a descriptor count, 4096 unless you set
UnboundedMaxCount(orUnboundedCountsper binding), and the flags bindless needs.VariableDescriptorCountgoes on the highest binding of its set only, as Vulkan requires. OptionsPartiallyBound,VariableDescriptorCountandUpdateAfterBindturn them on or off. - Allocate bindless sets one at a time. Chain the variable descriptor count allocate info only to a set whose layout has a variable-count binding (
HasVariableCount), not to an allocation that also holds a set without one.
The SPIR-V passes spirv-val --target-env vulkan1.2. It declares the capabilities RuntimeDescriptorArray, ShaderNonUniform and SampledImageArrayNonUniformIndexing with the extension SPV_EXT_descriptor_indexing, and decorates the index, the access chain and the loaded image NonUniform.
On Metal
The array is one argument buffer of 8-byte descriptors, read through a one-member struct. The pixel shader’s MSL:
// One 8-byte descriptor of the argument buffer 'Textures'. struct Textures_entry { texture2d<float, access::sample> v; }; fragment PSMain_out PSMain( PSMain_in stage_in [[stage_in]], float4 frag_coord [[position]], constant Textures_entry* Textures [[buffer(1)]], sampler Linear [[sampler(0)]]) ... float4 v5 = Textures[v4].v.sample(Linear, v1);
The host fills a plain MTLBuffer with no argument encoder: descriptor i is at byte offset 8 * i and holds the texture’s gpuResourceID. Call useResource or useResources for every texture it points to, then bind the buffer at the index the layout reports. This needs argument buffers tier 2 and Metal 3 (macOS 13, iOS 16).
On WebGPU
Select Wgsl in OneShaderTargets and the build fails on the declaration:
/tmp/oneshader-docs/cookbook/Bindless/Shaders/Scene.hlsl(2,19): error OSH5202: The unbounded resource array 'Textures' is not available in WGSL: WebGPU has no binding arrays or descriptor indexing. Declare the elements of 'Textures' as separate resources, use a texture array (Texture2DArray) when the elements are textures of one size, or build this shader for SPIR-V or MSL only.To ship one project to every API, give the bindless shader its own targets with item metadata, <OneShader Include="Shaders/Scene.hlsl" Targets="SpirV;Msl" />, and keep a non-bindless path for the web.
Descriptor heaps (Shader Model 6.6)
ResourceDescriptorHeap[i] and SamplerDescriptorHeap[i] compile too, with DXC’s default mapping for Vulkan. Heap.hlsl:
struct DrawIndices { uint Texture; uint Sampler; }; [[vk::push_constant]] DrawIndices Draw; [shader("pixel")] float4 PSMain(float4 position : SV_Position, float2 uv : TEXCOORD0) : SV_Target { Texture2D<float4> tex = ResourceDescriptorHeap[Draw.Texture]; SamplerState s = SamplerDescriptorHeap[Draw.Sampler]; return tex.Sample(s, uv); }
oneshader -T ps_6_6 -E PSMain --reflect heap.json Heap.hlsl -Fo heap.spvThe output passes spirv-val. Each resource type read from a heap is an unbounded array, and every array of one heap shares one binding. The resource heap takes the first binding of set 0 that nothing else uses, and the sampler heap the next, so here they’re bindings 0 and 1. An excerpt of the reflection JSON:
"name": "ResourceDescriptorHeap", "kind": "SampledTexture", "count": 0, "unbounded": true, "heap": "Resource", "heapKinds": [ "SampledTexture" ],
-fvk-bind-resource-heap <binding> <set> and -fvk-bind-sampler-heap <binding> <set> (or CompileOptions.ResourceHeapBinding and SamplerHeapBinding) put them elsewhere, as in DXC. A heap element has no type until you give it one, so assign it to a variable, cast it or pass it to a function. Calling a method on it directly is an error that says so:
Misuse.hlsl(4,12): error OSH0336: 'Sample' cannot be called on ResourceDescriptorHeap[...] directly: it has no type until it is assigned. Assign it to a variable of the resource type you want, for example 'Texture2D<float4> t = ResourceDescriptorHeap[i];'.Good to know
NonUniformResourceIndexis needed whenever the index can differ within a draw or a wave, as with a per-pixel material. Without it the result is undefined on some GPUs. A per-draw index from a push constant, as in the heap example, is uniform.- Other resource kinds. Samplers, structured, byte-address and constant buffers, and read-write textures can be unbounded arrays too.
- Limits. The counter heap (
-fvk-bind-counter-heap) isn’t supported, so append and consume buffers can’t come from a heap. A read-write texture from a heap has no declared image format.
The SPIR-V mapping is in SPIR-V, the Metal encoding in Metal Shading Language, and the generated members in Generated bindings.