Part 3 · Chapter 08 · ~9 min prose skim
Descriptors and materials
How a shader's set and binding numbers get connected to real buffers
and textures, and how the renderer packages that up as reusable materials.
Overview The big picture
Chapter 6 showed shaders asking for resources by number: "set 0, binding 0 is a uniform buffer", "set 1, binding 0 is a texture". The shader doesn't know which buffer or which texture. Something has to fill in those slots before each draw. In Vulkan that something is a descriptor: a small, driver-defined record that points at a resource and says how it will be used. Three objects and one action are involved:
- A descriptor set layout is the shape of a group of slots: "binding 0 is a uniform buffer used by both stages; binding 1 is a sampled image used by the fragment shader".
- A descriptor pool is the memory that descriptor sets are allocated from, sized up front for how many sets and descriptors of each type you'll need. Pools let the driver hand out sets cheaply and free a whole group at once.
- A descriptor set is one filled-in instance of a layout: an actual table of pointers.
- Binding a set at draw time plugs that table into a numbered slot, so the shader's
set = 0now means this frame's lighting and shadow map.
For descriptors that change with every draw, there's a lighter option. A push descriptor set has a layout but no pool and no set object: you write its descriptors straight into the command buffer just before drawing, much like push constants. Each pipeline layout can have one such set, and this renderer uses it for the textures a draw samples.
Grouping by frequency
Why two sets? Because some data is shared by everything drawn in a frame (the lighting and the shadow map) while some changes with every material (its textures). Keeping them in separate sets means switching material only pushes a new set 1. (Set numbers only mean something relative to a pipeline's layout: the sky and the grass have their own small sets with different contents.) And because the GPU may still be reading last frame's set 0 while the CPU prepares the next, there's one set 0 (and one uniform buffer) per swapchain image.
Two other small pieces complete the picture. A sampler describes how to read a texture (smooth or blocky filtering, whether coordinates wrap) and is a separate object from the image. A combined image sampler descriptor pairs one of each, and is what every texture binding in this renderer uses. And push constants, for a few bytes of per-draw data such as an object's matrices, skip descriptors entirely: their values are written straight into the command buffer.
Materials
RustyVulkan bundles "shaders + pipeline + textures" into a material. A material manager makes sure each named material is created only once, and a texture cache makes sure each image file is loaded only once, even if several materials use it. The cache owns the images; materials just hold copies of their handles.
The GLSL declaration of the shared set 0 uniform block lives in assets/shaders/global.glsl; its Rust counterpart is GlobalUbo in src/graphics/gpu_data.rs. All six lit fragment shaders include that declaration.
The global set: layout, pool, sets
VulkanBase::new defines the shape of set 0:
// Binding 1 contains one sampled 2D-array view of all shadow cascades.
let ubo_binding = vk::DescriptorSetLayoutBinding {
binding: 0,
descriptor_type: vk::DescriptorType::UNIFORM_BUFFER,
descriptor_count: 1,
stage_flags: vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
p_immutable_samplers: std::ptr::null(),
..Default::default()
};
let shadow_binding = vk::DescriptorSetLayoutBinding {
binding: 1,
descriptor_type: vk::DescriptorType::COMBINED_IMAGE_SAMPLER,
descriptor_count: 1,
stage_flags: vk::ShaderStageFlags::FRAGMENT,
p_immutable_samplers: std::ptr::null(),
..Default::default()
};
let bindings = [ubo_binding, shadow_binding];
let set0_info = vk::DescriptorSetLayoutCreateInfo {
binding_count: bindings.len() as u32,
p_bindings: bindings.as_ptr(),
..Default::default()
};
let set0_global_layout = unsafe { device.create_descriptor_set_layout(&set0_info, None)? };Each binding states its number, its type, how many descriptors it holds (1; arrays of textures are
possible), and which shader stages may use it. (p_immutable_samplers could bake a sampler into
the layout itself; it's unused here.) Then create_set0_descriptor_pool_and_sets makes
a pool with room for one set per swapchain image and allocates them:
let pool_sizes = [
vk::DescriptorPoolSize {
ty: vk::DescriptorType::UNIFORM_BUFFER,
descriptor_count: count,
},
vk::DescriptorPoolSize {
ty: vk::DescriptorType::COMBINED_IMAGE_SAMPLER,
descriptor_count: count,
},
];
let pool_info = vk::DescriptorPoolCreateInfo {
pool_size_count: pool_sizes.len() as u32,
p_pool_sizes: pool_sizes.as_ptr(),
max_sets: count,
..Default::default()
};
let pool = unsafe { device.create_descriptor_pool(&pool_info, None)? };
// ...
let layouts = vec![layout; count as usize];
let alloc_info = vk::DescriptorSetAllocateInfo {
descriptor_pool: pool,
descriptor_set_count: count,
p_set_layouts: layouts.as_ptr(),
..Default::default()
};The pool is sized precisely: count uniform-buffer descriptors, count
image-sampler descriptors, and at most count sets. vec![layout; count] repeats
the layout once per set to allocate. Finally, binding 0 of each set is pointed at that image's uniform
buffer:
let mut writes: Vec<vk::WriteDescriptorSet> = Vec::with_capacity(count as usize);
for i in 0..(count as usize) {
writes.push(vk::WriteDescriptorSet {
dst_set: sets[i],
dst_binding: 0,
dst_array_element: 0,
descriptor_count: 1,
descriptor_type: vk::DescriptorType::UNIFORM_BUFFER,
p_buffer_info: &buf_infos[i],
..Default::default()
});
}
unsafe {
device.update_descriptor_sets(&writes, &[]);
}A WriteDescriptorSet says "in this set, at this binding, put this resource". Nothing is
copied: the descriptor records where the buffer is. When the CPU later writes new lighting values into the
buffer (Chapter 5), the shader sees them without the descriptor changing.
Binding 1, the shadow map, is filled in later, once the shadow pass has created its image. Writing a
descriptor isn't free, and (without the optional "update after bind" feature, which this code doesn't
use) a descriptor set must not be changed while a submitted frame may still be using it. So
update_shadow_descriptor only writes when the view has actually changed:
/// Update set=0 binding=1 to point at the current frame's shadow image view
pub fn update_shadow_descriptor(&mut self, image_index: usize, image_view: vk::ImageView) {
// The view normally stays the same for an image, so avoid a redundant descriptor write.
if self.shadow_descriptor_views[image_index] == image_view {
return;
}
let set = self.set0_descriptor_sets[image_index];
let image_info = vk::DescriptorImageInfo {
sampler: self.shadow_sampler,
image_view,
image_layout: vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
};
let write = vk::WriteDescriptorSet {
dst_set: set,
dst_binding: 1,
dst_array_element: 0,
descriptor_count: 1,
descriptor_type: vk::DescriptorType::COMBINED_IMAGE_SAMPLER,
p_image_info: &image_info,
..Default::default()
};
unsafe { self.device.update_descriptor_sets(&[write], &[]) };
self.shadow_descriptor_views[image_index] = image_view;
}It's called each frame for the swapchain image being recorded. By then the frame loop has already waited for that image's previous frame to finish on the GPU (Chapter 9), and that wait is what makes the write safe.
The shared sampler
Every material texture is read with the same settings, so the texture cache creates a single sampler on first use:
let sampler_info = vk::SamplerCreateInfo {
mag_filter: vk::Filter::LINEAR,
min_filter: vk::Filter::LINEAR,
address_mode_u: vk::SamplerAddressMode::REPEAT,
address_mode_v: vk::SamplerAddressMode::REPEAT,
address_mode_w: vk::SamplerAddressMode::REPEAT,
anisotropy_enable: vk::FALSE,
max_anisotropy: 1.0,
border_color: vk::BorderColor::INT_OPAQUE_BLACK,
unnormalized_coordinates: vk::FALSE,
compare_enable: vk::FALSE,
compare_op: vk::CompareOp::ALWAYS,
mipmap_mode: vk::SamplerMipmapMode::LINEAR,
min_lod: 0.0,
max_lod: 0.0,
..Default::default()
};Linear filtering, repeating coordinates (needed for the tiled ground textures), no anisotropic
filtering (a quality setting for surfaces seen at grazing angles), and max_lod 0, which means
only ever sample the full-size image, since there are no mipmaps
(Chapter 5). The shadow map has its own sampler, created in
VulkanBase::new, which clamps at the edges instead of repeating.
A material's textures
Material::new first decides whether the material is textured at all, filling in defaults
so a textured material always has both a colour texture and a normal map (a plain white image, or a flat
"straight up" normal map). It requests both from the cache and pairs each image view with the shared
sampler:
let textures = if diffuse_texture_path.is_some() || normalmap_texture_path.is_some() {
let diffuse_path =
diffuse_texture_path.unwrap_or_else(|| DEFAULT_DIFFUSE_TEXTURE_PATH.to_string());
let normal_path =
normalmap_texture_path.unwrap_or_else(|| DEFAULT_NORMAL_TEXTURE_PATH.to_string());
// The cache records copies now; MaterialManager submits the batch after scene setup.
let diffuse_view = texture_cache.load(vb, &diffuse_path)?;
let normal_view = texture_cache.load(vb, &normal_path)?;
let image_info = |image_view| vk::DescriptorImageInfo {
sampler: texture_cache.sampler(),
image_view,
image_layout: vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
};
Some([image_info(diffuse_view), image_info(normal_view)])
} else {
None
};A DescriptorImageInfo is exactly what a texture descriptor holds, so the material keeps
these two and needs nothing else to draw. The layout for set 1 has two image-sampler bindings, and the
PUSH_DESCRIPTOR_KHR flag marks it as a push-descriptor layout:
fn create_texture_set_layout(device: &Device) -> Result<vk::DescriptorSetLayout, vk::Result> {
let binding = |binding| vk::DescriptorSetLayoutBinding {
binding,
descriptor_type: vk::DescriptorType::COMBINED_IMAGE_SAMPLER,
descriptor_count: 1,
stage_flags: vk::ShaderStageFlags::FRAGMENT,
..Default::default()
};
let bindings = [binding(0), binding(1)];
let layout_info = vk::DescriptorSetLayoutCreateInfo::default()
.flags(vk::DescriptorSetLayoutCreateFlags::PUSH_DESCRIPTOR_KHR)
.bindings(&bindings);
unsafe { device.create_descriptor_set_layout(&layout_info, None) }
}The pipeline layout then lists the set layouts in order. Set numbers are positions in this list, which is why the comment insists they be contiguous from 0:
// Pipeline layout must be contiguous sets starting at 0.
// Always include the global set=0 layout first, then set=1 texture layout if used.
let layouts = if textures.is_some() {
vec![vb.set0_global_layout, texture_set_layout] // set 0, set 1
} else {
vec![vb.set0_global_layout] // set 0 only
};
let pipeline = Pipeline::new(&vb.device, &layouts, depth_write)?;An untextured material has only set 0, and its shaders (vertex_color.*) declare only set
0. A textured one records its two descriptors into the command buffer whenever it's selected for drawing:
/// Records this material's textures as set 1 for the draws that follow.
pub fn push_textures(&self, vb: &VulkanBase, cmd: vk::CommandBuffer) {
let Some(textures) = &self.textures else {
return;
};
let writes = [0, 1].map(|binding| {
vk::WriteDescriptorSet::default()
.dst_binding(binding)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(std::slice::from_ref(&textures[binding as usize]))
});
unsafe {
vb.push_descriptor.cmd_push_descriptor_set(
cmd,
vk::PipelineBindPoint::GRAPHICS,
self.pipeline.vk_layout,
1,
&writes,
);
}
}std::slice::from_ref turns a reference to one value into a one-element slice, which is
what the builder's image_info expects. The layout given is SHADER_READ_ONLY_OPTIMAL,
the layout the texture will be in. When the material is created, its upload is only recorded, not run (Chapter 5).
That's fine: the image view is already a valid object, and the image only has to hold the right pixels, in
the right layout, when a submitted draw actually reads it. The batch is flushed before the first frame.
Deduplication
The material manager stores materials in a Vec and hands out indices. Asking for a
material by a name it has seen before returns the existing index:
if let Some(idx) = self.materials.iter().position(|m| m.name == props.name) {
Ok(idx)
} else {
// `props` is consumed here: its owned Strings move into `Material::new`
// without cloning. New textures are queued and submitted after scene setup.
let mat = Material::new(
props.name,
vb,
&mut self.texture_cache,
props.vertex_shader,
props.fragment_shader,
props.diffuse_texture_path,
props.normalmap_texture_path,
props.depth_write,
props.uv_tiling.unwrap_or([1.0, 1.0]),
)?;
self.materials.push(mat);
Ok(self.materials.len() - 1)
}The texture cache does the same for images, keyed by the file's canonical path, so two spellings of the same path share one texture:
let key = Self::key_for_path(image_path);
// No decode, allocation, or GPU work for a texture already used by a material.
if let Some(texture) = self.textures.get(&key) {
return Ok(texture.image_view);
}
// ...
let texture = self
.upload_batch
.as_mut()
.expect("texture upload batch")
.upload(
&vb.device,
vb.allocator.as_ref().expect("allocator"),
image_path,
)?;
let image_view = texture.image_view;
self.textures.insert(key, texture);
Ok(image_view)It returns an image view: a Copy handle the material can store, while the
Texture itself, image and memory included, stays in the cache's HashMap. That's
the single-owner convention from Chapter 2 in
action.
Binding at draw time
The main pass (Chapter 12) switches pipeline and descriptors only when the material changes, then pushes per-object constants and draws:
if current_pipeline_id != part.material_id {
let material = &ctx.material_manager.materials[part.material_id];
device.cmd_bind_pipeline(
cmd,
vk::PipelineBindPoint::GRAPHICS,
material.pipeline.vk_pipeline,
);
device.cmd_bind_descriptor_sets(
cmd,
vk::PipelineBindPoint::GRAPHICS,
material.pipeline.vk_layout,
0,
&[ctx.vulkan_base.set0_descriptor_sets[image_index]],
&[],
);
material.push_textures(ctx.vulkan_base, cmd);
current_pipeline_id = part.material_id;
}
device.cmd_push_constants(
cmd,
material.pipeline.vk_layout,
vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
0,
bytemuck::bytes_of(&push),
);cmd_bind_descriptor_sets binds the given sets starting at slot 0, against the pipeline's
layout, which they must match (Chapter 7). set0_descriptor_sets[image_index] picks the global set belonging to the swapchain image
being drawn. push_textures then fills set 1. Because pushed descriptors are copied into the
command buffer, a material never has a set that a frame in flight might still be reading. cmd_push_constants writes the 144 bytes of matrices and UV tiling directly into
the command buffer.
Teardown order
for material in &mut self.materials {
println!("🗑️ Cleaning up material {}", material.name);
material.cleanup(device);
}
// The ordering is a Vulkan lifetime rule that raw handles cannot encode:
// materials use image views, so destroy their descriptors before the cache images.
self.materials.clear();
self.texture_cache.cleanup(device, allocator, command_pool);Materials go first: each destroys its layout and pipeline, and
clear() then drops them, freeing their SPIR-V. Only then does the cache
destroy the images the materials pointed at, and the material sampler. (The shadow sampler belongs to
VulkanBase.) That's the rule from
DEVELOPMENT.md: materials must be destroyed before cache teardown.
Recap
- Descriptors connect shader
set/bindingslots to real resources: layouts give the shape, pools provide the memory, sets are filled tables, and binding plugs them in. Push descriptors skip the pool and set, writing descriptors straight into the command buffer. - For scene materials, set 0 is global (lighting uniform buffer and shadow map), one per swapchain image, and set 1 is per material (colour and normal textures), pushed when the material is selected. The sky and grass push their own textures too. Push constants carry per-draw matrices.
- One shared sampler serves all material textures; the shadow map has its own.
- The material manager dedupes materials by name, and the texture cache dedupes images by path and owns them. Materials are torn down before the cache.