---
title: "Bindless textures in HLSL for Vulkan and Metal"
url: https://recursiveai.org/oneshader/cookbook/bindless-textures/
description: "Index an unbounded texture array (Texture2D Textures[]) or the Shader Model 6.6 ResourceDescriptorHeap from HLSL, compiled to SPIR-V with descriptor…"
version: "1.4.0"
---

# Bindless textures in HLSL for Vulkan and Metal

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:

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

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

```text
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 `Bindings` and in reflection (`ResourceInfo.IsUnbounded`).
- **Device features.** `RequiredFeatures` lists what to enable when you create the Vulkan device: runtime descriptor arrays, and non-uniform indexing of sampled images because of `NonUniformResourceIndex`. A heap read as several kinds of resource adds `MutableDescriptorType`.
- **The Vulkan binding** gets a descriptor count, 4096 unless you set `UnboundedMaxCount` (or `UnboundedCounts` per binding), and the flags bindless needs. `VariableDescriptorCount` goes on the highest binding of its set only, as Vulkan requires. Options `PartiallyBound`, `VariableDescriptorCount` and `UpdateAfterBind` turn 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:

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

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

```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);
}
```

```sh
oneshader -T ps_6_6 -E PSMain --reflect heap.json Heap.hlsl -Fo heap.spv
```

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

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

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

- **`NonUniformResourceIndex`** is 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](https://recursiveai.org/oneshader/docs/spirv-backend/), the Metal encoding in [Metal Shading Language](https://recursiveai.org/oneshader/docs/msl-backend/), and the generated members in [Generated bindings](https://recursiveai.org/oneshader/docs/generated-bindings/).
