Part 3 · Chapter 07 · ~8 min prose skim
Pipelines and dynamic rendering
The single object that describes how a draw happens, from vertex layout to blending, and why Vulkan wants all of it decided up front.
Overview The big picture
In OpenGL, the way a draw call behaved depended on whatever state happened to be set at the time:
which shader program was in use, whether blending was on, how depth testing compared, whether back faces
were culled. You changed those with individual calls such as glEnable(GL_BLEND), and the
driver worked out the consequences at draw time. Some combinations required it to quietly recompile your
shaders, a common source of OpenGL's unpredictable hitches.
Vulkan gathers all of that into one immutable object, the graphics pipeline. You describe the shaders and most drawing state up front, and the driver builds a pipeline for that combination. At draw time you bind it. Some state can be declared dynamic and supplied in the command buffer; this renderer does that for the viewport and scissor. It keeps blending fixed, so objects with blending on and objects with blending off use different pipelines. Vulkan offers more dynamic state through optional features, but this renderer keeps those choices simple.
Its signature: the pipeline layout
A pipeline also needs to know the shape of the resources its shaders will read: which descriptor sets, and how many bytes of push constants (Chapter 6's three routes). That shape is a separate object, the pipeline layout. Think of it as a function signature: the layout lists the parameters, and at draw time you pass actual buffers and textures that fit it.
Dynamic rendering
With dynamic rendering, a pipeline declares only the formats of its attachments (the images it draws into, such as a colour image or a depth buffer), and you name the actual images when you start drawing. That's the feature Chapter 3 insisted on when choosing a GPU.
The cost: rebuilding when things change
Because a pipeline is baked, changing anything baked into it means building a new one. This renderer keeps the size of the window out of its pipelines (it's set per frame instead), so a resize does not require pipeline recreation. Rebuilding the swapchain and its images still costs time and requires waiting for GPU work to finish. But changing the anti-aliasing sample count, switching to wireframe, or a change in the screen's pixel format rebuilds every pipeline. A shared pipeline cache lets the driver reuse compilation work within a run when that happens.
Most of the renderer's pipelines are built by one helper, Pipeline in
src/graphics/pipeline.rs, used by materials, the sky and the grass. The shadow pass and ImGui
build theirs directly with the same calls (Chapter 11,
Chapter 15).
Creating the layout first
A Pipeline is made in two steps. The constructor creates only the layout:
fn new_with_options(
device: &ash::Device,
set_layouts: &[vk::DescriptorSetLayout],
depth_write: bool,
alpha_blending: bool,
depth_test: bool,
honor_wireframe: bool,
) -> Result<Self, Box<dyn Error>> {
// ...
let push_constant_range = vk::PushConstantRange {
stage_flags: vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
offset: 0,
size: PUSH_CONSTANT_BYTES,
};
// 2 - Build your PipelineLayoutCreateInfo with that push-constant baked in and descriptor set layouts
let layout_info = vk::PipelineLayoutCreateInfo {
set_layout_count: set_layouts.len() as u32,
p_set_layouts: set_layouts.as_ptr(),
push_constant_range_count: 1,
p_push_constant_ranges: &push_constant_range as *const _,
..Default::default()
};
// 3 - Create the layout as before
let vk_layout = unsafe { device.create_pipeline_layout(&layout_info, None)? };The push-constant range is 144 bytes: two 4×4 matrices and a vec4, exactly the
Push block of main.vert, visible to both shader stages. This is a renderer requirement,
not a universal Vulkan allowance: Vulkan 1.3 guarantees only 128 bytes. Device selection checks
max_push_constants_size and skips GPUs that cannot supply 144 bytes, with a diagnostic
explaining why. A renderer supporting those GPUs would need a smaller block or a buffer for some data. set_layouts comes from the
caller: for a material, set 0 is the global layout and, if it's textured, set 1 its texture layout
(Chapter 8); the sky and grass pass their own. Three constructors
choose the remaining options:
new for ordinary materials (blending on), new_opaque for grass, and
new_background for the sky, with no depth test, no blending and no wireframe.
Describing the vertices
Here is the description Chapter 5 promised, from
mesh.rs. One binding (a buffer stepped through once per vertex, 68 bytes at a time)
and six attributes, each a location, a format and a byte offset within the vertex:
pub fn binding_description() -> vk::VertexInputBindingDescription {
vk::VertexInputBindingDescription {
binding: 0,
stride: std::mem::size_of::<Vertex>() as u32,
input_rate: vk::VertexInputRate::VERTEX,
}
}
/// Returns the attribute descriptions: position, normal, color, uv, tangent and
/// bitangent at locations 0-5, in field order.
pub fn attribute_descriptions() -> [vk::VertexInputAttributeDescription; 6] {
[
vk::VertexInputAttributeDescription {
binding: 0,
location: 0,
format: vk::Format::R32G32B32_SFLOAT,
offset: offset_of!(Vertex, pos) as u32,
},The remaining five attributes follow the same pattern for normal, color,
uv, tangent and bitangent, at locations 1–5 in that order, matching
main.vert. offset_of! computes each field's offset from
the #[repr(C)] struct, so the description can't drift from the type. Nothing checks that the
shader's location numbers match; that's your job. The pipeline wraps these into its vertex
input state, and says the vertices form a list of separate triangles:
let vertex_input_info = vk::PipelineVertexInputStateCreateInfo {
vertex_binding_description_count: binding_descs.len() as u32,
p_vertex_binding_descriptions: binding_descs.as_ptr(),
vertex_attribute_description_count: attribute_descs.len() as u32,
p_vertex_attribute_descriptions: attribute_descs.as_ptr(),
..Default::default()
};
let input_assembly = vk::PipelineInputAssemblyStateCreateInfo {
topology: vk::PrimitiveTopology::TRIANGLE_LIST,
primitive_restart_enable: vk::FALSE,
..Default::default()
};The grass passes in a second, per-instance binding through the same function (Chapter 14), which is why the descriptions are parameters.
Dynamic state: viewport and scissor
// Viewport and scissor depend on the window size, not on shader compatibility.
// Making them dynamic lets a resized swapchain keep the existing pipelines.
let viewport_state = vk::PipelineViewportStateCreateInfo {
viewport_count: 1,
scissor_count: 1,
..Default::default()
};
let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
let dynamic_state = vk::PipelineDynamicStateCreateInfo {
dynamic_state_count: dynamic_states.len() as u32,
p_dynamic_states: dynamic_states.as_ptr(),
..Default::default()
};The pipeline promises one viewport and one scissor rectangle but leaves their values open. The main pass supplies them each frame from the current swapchain size:
let scissor = vk::Rect2D {
offset: vk::Offset2D::default(),
extent,
};
device.cmd_set_viewport(cmd, 0, &[viewport]);
device.cmd_set_scissor(cmd, 0, &[scissor]);Without this, every pipeline would bake in a window size and need rebuilding on every resize.
Rasterizer and multisampling
let rasterizer = vk::PipelineRasterizationStateCreateInfo {
depth_clamp_enable: vk::FALSE,
rasterizer_discard_enable: vk::FALSE,
polygon_mode: if self.honor_wireframe && engine_settings.wireframe {
vk::PolygonMode::LINE
} else {
vk::PolygonMode::FILL
},
line_width: 1.0,
cull_mode,
front_face: vk::FrontFace::COUNTER_CLOCKWISE,
depth_bias_enable: vk::FALSE,
..Default::default()
};
let multisampling = vk::PipelineMultisampleStateCreateInfo {
rasterization_samples: vk::SampleCountFlags::from_raw(engine_settings.msaa_samples),
sample_shading_enable: vk::FALSE,
..Default::default()
};polygon_modeswitches to lines when the UI's wireframe toggle is on, unless this pipeline opted out (the sky would otherwise become a single outlined triangle). Vulkan requiresfillModeNonSolidfor line mode. Device creation enables it only when supported; the UI disables Wireframe on GPUs without it, and the toggle itself also guards against unsupported use.- Materials cull back faces (the sky and grass pass
NONE), with counter-clockwise triangles counting as front. Which way round a triangle appears depends on the projection's Y direction, which differs from OpenGL's (Chapter 12). rasterization_samplesis the anti-aliasing setting. It must match the images being drawn into, which is why changing it rebuilds every pipeline.
Blending and depth
let color_blend_attachment = vk::PipelineColorBlendAttachmentState {
color_write_mask: vk::ColorComponentFlags::R
| vk::ColorComponentFlags::G
| vk::ColorComponentFlags::B
| vk::ColorComponentFlags::A,
blend_enable: if self.alpha_blending {
vk::TRUE
} else {
vk::FALSE
},
src_color_blend_factor: vk::BlendFactor::SRC_ALPHA,
dst_color_blend_factor: vk::BlendFactor::ONE_MINUS_SRC_ALPHA,
color_blend_op: vk::BlendOp::ADD,
src_alpha_blend_factor: vk::BlendFactor::ONE,
dst_alpha_blend_factor: vk::BlendFactor::ZERO,
alpha_blend_op: vk::BlendOp::ADD,
};When blending is enabled, this is classic "over" compositing: new colour × its alpha, plus existing colour × (1 − alpha). The colour write mask lets all four channels through either way.
let depth_stencil = vk::PipelineDepthStencilStateCreateInfo {
depth_test_enable: if self.depth_test { vk::TRUE } else { vk::FALSE },
depth_write_enable: if self.depth_write {
vk::TRUE
} else {
vk::FALSE
},
depth_compare_op: vk::CompareOp::LESS,
// ...
stencil_test_enable: vk::FALSE,
front: Default::default(),
back: Default::default(),
..Default::default()
};Depth testing keeps the fragment nearest the camera (LESS). Depth runs from 0 at the near
plane to 1 at the far: the viewport above sets min_depth 0 and max_depth 1, and
the projection matrix must produce clip-space depth in 0..1, not OpenGL's −1..1
(Chapter 12). Each material decides whether it also writes depth.
Putting it together
let color_formats = [color_format];
let rendering_info = vk::PipelineRenderingCreateInfo {
color_attachment_count: color_formats.len() as u32,
p_color_attachment_formats: color_formats.as_ptr(),
depth_attachment_format: depth_format,
..Default::default()
};
let mut pipeline_info = vk::GraphicsPipelineCreateInfo {
stage_count: shader_stage_create_infos.len() as u32,
p_stages: shader_stage_create_infos.as_ptr(),
p_vertex_input_state: &vertex_input_info,
p_input_assembly_state: &input_assembly,
p_viewport_state: &viewport_state,
p_rasterization_state: &rasterizer,
p_multisample_state: &multisampling,
p_color_blend_state: &color_blending,
p_depth_stencil_state: &depth_stencil,
p_dynamic_state: &dynamic_state,
layout: self.vk_layout,
render_pass: vk::RenderPass::null(),
subpass: 0,
..Default::default()
};
pipeline_info.p_next = &rendering_info as *const _ as *const std::ffi::c_void;
let pipelines = unsafe {
device
.create_graphics_pipelines(pipeline_cache, &[pipeline_info], None)
.map_err(|(_, e)| e)?
};PipelineRenderingCreateInfo is the dynamic-rendering piece: one colour attachment in the
swapchain's format, and a depth attachment in D32_SFLOAT. It's chained on through
p_next by a raw pointer cast, the hand-written equivalent of push_next
(Chapter 2). Creating a raw pointer is safe in Rust, but
nothing ties its lifetime to rendering_info, so correctness rests on both being locals used
immediately. render_pass is left null. Then everything is handed to
create_graphics_pipelines, together with the shared cache. That call is where the driver
compiles the SPIR-V for this exact combination of state, which is why it's comparatively slow and is
never done mid-frame.
The shader stages it receives were converted to create-infos just before the depth state, each with its SPIR-V chained on (Chapter 6):
let mut shader_modules: Vec<_> = shader_infos
.iter()
.map(|s| s.module_create_info())
.collect();
let shader_stage_create_infos: Vec<_> = shader_infos
.iter()
.zip(&mut shader_modules)
.map(|(s, module)| s.to_create_info(module))
.collect();Rebuilding
Toggling wireframe, or changing the MSAA sample count or swapchain format, bumps a generation counter on
VulkanBase:
pub fn toggle_wireframe(&mut self) {
if !self.supports_wireframe {
return;
}
let old = self.engine_settings.wireframe;
self.engine_settings.wireframe = !self.engine_settings.wireframe;
if self.engine_settings.wireframe != old {
// Wireframe affects pipelines; ensure rebuild next frame
self.pipeline_generation = self.pipeline_generation.saturating_add(1);
}
}At the start of each frame, the render graph compares that counter with the last one it saw. If it has changed, the graph waits for the GPU once and asks every owner to rebuild:
// Rebuild pipelines only when engine-signaled generation changes (swapchain/MSAA/wireframe)
let swap_gen = vb.pipeline_generation();
if swap_gen != self.swap_gen_seen {
// Rebuilding a pipeline invalidates objects referenced by already
// submitted command buffers. Wait once for the whole rebuild,
// rather than once per material and again for ImGui.
if !device_is_idle {
unsafe {
vb.device.device_wait_idle()?;
}
}
app.material_manager.recreate_pipelines(vb)?;
// ...
self.swap_gen_seen = swap_gen;
}Each material's rebuild goes through Pipeline::recreate:
let old_pipeline = self.vk_pipeline;
self.create_graphics_pipeline(
device,
pipeline_cache,
extent,
color_format,
depth_format,
shader_infos,
engine_settings,
)?;
// Keep the old pipeline alive until its replacement exists. If creation fails,
// `?` returns while the still-valid old handle remains owned by `self`.
unsafe {
device.destroy_pipeline(old_pipeline, None);
}
Ok(())The old pipeline is destroyed only after its replacement exists. If creation fails, ?
returns early and self still holds the old, valid handle, a safer arrangement than the swapchain's (Chapter 4), whose old handle is
unusable once replacement has been attempted.
Recap
- A graphics pipeline bakes shaders and the state chosen to be fixed into one immutable object. Dynamic state is supplied when recording commands; changing baked state needs a new pipeline.
- The pipeline layout is the resource signature: descriptor set layouts plus a 144-byte push-constant range.
- Dynamic rendering replaces render passes and framebuffers; pipelines name only attachment formats.
- Viewport and scissor are dynamic, so resizes don't rebuild pipelines. MSAA, wireframe and format changes do, triggered by a generation counter and done safely at a frame boundary.