Vulkan descriptor indexing and bindless resources explained

Classic Vulkan forces a choice per draw: bind this texture set, draw the objects using it, bind that set, draw those objects. With thousands of distinct materials you either batch by material (draw-call explosion) or bake atlases (painful). VK_EXT_descriptor_indexing (core in Vulkan 1.2) removes the constraint: a descriptor set becomes a large, partially-populated array, shaders index into it at runtime, and “bindless” becomes a first-class pattern.

What the extension adds

feature-flags.txt
shaderSampledImageArrayNonUniformIndexing   — texture[i] with runtime i
descriptorBindingPartiallyBound           — array slots may be unset
descriptorBindingVariableDescriptorCount  — set sized at alloc time
descriptorBindingUpdateAfterBind          — update while cmd uses it
runtimeDescriptorArray                    — unsized array in GLSL

Five independent feature bits — you only need the ones your pattern uses. The most transformative is the combination of runtimeDescriptorArray + NonUniformIndexing: a set can declare layout(set=0, binding=0) texture2D textures[]; — unsized — and the shader indexes it with a value that differs per fragment.

A bindless texture array

layout.cpp
// One giant binding — thousands of textures, update any slot at any time:
vk::DescriptorSetLayoutBinding b{};
b.setBinding(0)
 .setDescriptorType(vk::DescriptorType::eCombinedImageSampler)
 .setDescriptorCount(4096)                        // the "bindless" pool
 .setStageFlags(vk::ShaderStageFlagBits::eFragment);

// Binding flags travel via pNext on the layout create info, not on
// the binding itself:
vk::DescriptorBindingFlags flags =
    vk::DescriptorBindingFlagBits::ePartiallyBound |
    vk::DescriptorBindingFlagBits::eUpdateAfterBind;
vk::DescriptorSetLayoutBindingFlagsCreateInfo bfc{};
bfc.setBindingFlags(flags);

vk::DescriptorSetLayoutCreateInfo lci{};
lci.setPNext(&bfc).setBindings(b)
   .setFlags(vk::DescriptorSetLayoutCreateFlagBits::eUpdateAfterBindPool);
auto layout = device.createDescriptorSetLayoutUnique(lci);
shader.frag
#extension GL_EXT_nonuniform_qualifier : require
layout(set = 0, binding = 0) uniform texture2D textures[];
layout(push_constant) uniform PC { uint texIdx; } pc;

void main() {
    color = texture(sampler2D(textures[pc.texIdx], s), uv);
    //   — texIdx varies per draw; no rebinding needed
}

One descriptor set for the entire material table. Per-draw the only data is the index — a push constant or SSBO read — so switching materials is free.

NonUniformIndexing — the critical detail

textures[i] where i differs within a single draw call needs nonuniformEXT:

nonuniform.frag
texture(sampler2D(textures[nonuniformEXT(pc.texIdx)], s), uv)

Without the qualifier the compiler assumes a uniform index and may hoist/vectorize incorrectly — wrong results or validation errors. Add nonuniformEXT whenever the index comes from anything other than a uniform constant expression.

Update-after-bind

descriptorBindingUpdateAfterBind lifts the “don’t touch a set that’s in use” rule: with VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT the pool can rebind descriptors that in-flight command buffers reference. Combined with a per-frame descriptor generation count this enables streaming: new frames upload textures into slots the GPU hasn’t reached yet, without stalling.

update-pattern.txt
frame N   → alloc descriptor set from pool, write textures, record cmd
frame N+1 → write DIFFERENT slots while frame N still in-flight ✓
            (only safe because UPDATE_AFTER_BIND relaxes the rule)

When to use it

  • Material systems: one array per resource type (albedo, normal, …), index per draw — replaces per-material rebinding entirely.
  • Texture arrays without fixed size: the runtime array pattern fits megascenes and dynamic atlases.
  • DDGI / bindless ray tracing: the same array feeds compute and ray tracing shaders — one binding, all stages.

Cost & caveats

  • Each element is still a full descriptor — a 4096-entry set costs pool memory whether or not you use the slots.
  • Validation catches OOB indexing only when robustBufferAccess style guarantees apply — index bugs crash the GPU.
  • descriptorBindingPartiallyBound requires robustness-style tolerance: an unset slot must never be reached by a shader.
  • Older drivers expose the extension without all feature bits — query VkPhysicalDeviceDescriptorIndexingFeatures per-bit, not just the extension presence.

Check out similar posts by category: Vulkan C++