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
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 GLSLFive 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
// 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);#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:
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.
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
robustBufferAccessstyle guarantees apply — index bugs crash the GPU. descriptorBindingPartiallyBoundrequiresrobustness-style tolerance: an unset slot must never be reached by a shader.- Older drivers expose the extension without all feature bits — query
VkPhysicalDeviceDescriptorIndexingFeaturesper-bit, not just the extension presence.