RustyVulkan GuideContents

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.

Stages of the graphics pipeline, with this renderer's settings Vertex input 68-byte Vertex, 6 attributes Input assembly triangle lists Vertex shader e.g. main.vert (Chapter 6) Viewport & scissor screen area and clip rectangle, set per frame Rasterizer fill or wireframe, cull back faces Multisampling anti-aliasing (MSAA) sample count, from the UI Fragment shader e.g. main.frag Depth test, blend nearer wins; alpha blending optional Plus: a layout (what resources the shaders use) and the attachment formats.
A pipeline describes each stage, with viewport and scissor values supplied dynamically here. The two shaded stages are programmable; the rest are configurable fixed-function hardware.

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.

Skip to next overview →

Deeper dive

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)? };
src/graphics/pipeline.rs:87-94,97-113

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,
        },
src/graphics/mesh.rs:33-50

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()
};
src/graphics/pipeline.rs:184-196

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()
};
src/graphics/pipeline.rs:197-209

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]);
src/vulkan/main_pass.rs:130-135

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()
};
src/graphics/pipeline.rs:211-229

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,
};
src/graphics/pipeline.rs:232-248

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()
};
src/graphics/pipeline.rs:267-274,276-280

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)?
};
src/graphics/pipeline.rs:282-312

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();
src/graphics/pipeline.rs:257-265

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);
    }
}
src/vulkan/base.rs:714-724

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;
}
src/vulkan/render_graph.rs:219-230,240-241

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(())
src/graphics/pipeline.rs:344-359

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