RustyVulkan GuideContents

Part 4 · Chapter 10 · ~9 min prose skim

The render graph

How a frame is split into passes, how each image is moved into the right layout for each one, and how a missing piece of that bookkeeping turned into a real bug.

Overview The big picture

Three passes

Every frame is drawn in three passes, each a stretch of rendering into a particular set of images:

  1. Shadow pass: renders the scene's depth from the sun's point of view into a shadow map, four times over (Chapter 11).
  2. Main pass: draws the sky, the objects and the grass into a colour image and a depth buffer, reading the shadow map as it goes (Chapter 12).
  3. UI pass: draws the ImGui panels on top of the finished scene (Chapter 15).

Images change jobs, so they change layouts

Chapter 5 introduced layouts: each kind of use wants an image's data arranged a particular way. The shadow map is a depth target in pass 1, then a texture in pass 2. The swapchain image is a render target, then something to hand to the display. Every change of job needs a barrier, which does three things at once:

Each side of a barrier is described with a stage (where in the GPU's pipeline) and an access (what kind of reading or writing). Getting them too broad wastes performance. Getting them too narrow is a bug that may only show up on another GPU, months later.

What the "render graph" does

Rather than hand-writing every barrier, each pass declares which images it uses and how. The render graph runs the passes in order, remembers each image's current layout, access and stage, and inserts exactly the barriers needed between one pass and the next. DEVELOPMENT.md is careful to call it "a linear pass scheduler, not a general graph compiler". Its shadow → main → UI order is fixed; it doesn't infer another schedule or share memory between images. It just keeps the bookkeeping honest.

Each image's layout through one frame Shadow pass Main pass UI pass Present Shadow map depth target shader read MSAA colour colour target MSAA depth depth target Swapchain resolve target colour target present (with anti-aliasing on; at 1× the main pass draws straight into the swapchain image and a plain depth buffer)
Every box starts with a barrier wherever the layout or access differs from the box before it. The first box in each row also needs a barrier, against whatever used that image last.

Anti-aliasing and the resolve

With multisample anti-aliasing (MSAA) on, the main pass draws into separate images that store several samples per pixel. At the end of the pass, each pixel's samples are averaged into the single-sample swapchain image: the resolve. The multisampled images are then thrown away; only the resolved result is kept.

Skip to next overview →

Deeper dive

What a pass looks like to the graph

Every pass implements one trait:

/// Trait implemented by all render passes.
pub trait RenderPass {
    /// Record commands for this pass using the supplied [`RenderCtx`].
    fn execute(&mut self, ctx: &mut RenderCtx) -> Result<(), Box<dyn std::error::Error>>;

    /// List of attachment requests needed by this pass.
    fn attachments(&self) -> &[AttachmentRequest];

    /// Whether an attachment is active for the current MSAA configuration.
    /// Most passes use all of their declared attachments.
    fn uses_attachment(&self, _kind: AttachmentKind, _msaa_samples: u32) -> bool {
        true
    }

    /// Provides the graph with the required layout and access mask for a given attachment.
    fn attachment_info(&self, kind: AttachmentKind) -> (vk::ImageLayout, vk::AccessFlags2);

    /// Optional cleanup hook for passes that own GPU objects (pipelines, layouts, etc.).
    /// Default is no-op.
    fn cleanup(&mut self, _device: &ash::Device) {}
}
src/vulkan/render_graph.rs:100-120

Passes are kept in a fixed order whatever order they're added in, by giving each a priority and inserting at the right position. The app adds the shadow and main passes, then enables the UI pass separately through set_pass_enabled, and the priorities keep the result correct either way:

fn pass_order(node: RenderPassNode) -> u8 {
    match node {
        RenderPassNode::Shadow => 50,
        RenderPassNode::Main => 100,
        RenderPassNode::UI => 200,
    }
}
src/vulkan/render_graph.rs:46-52

Declaring attachments

The main pass asks for five images, and states how it uses each:

    let attachments = [
        AttachmentRequest::new(AttachmentKind::MsaaColor),
        AttachmentRequest::new(AttachmentKind::MsaaDepth),
        AttachmentRequest::new(AttachmentKind::SwapchainColor),
        AttachmentRequest::new(AttachmentKind::Depth),
        // Read-only shadow map so the graph can manage transitions
        AttachmentRequest::new(AttachmentKind::Shadow),
    ];
// ...
fn uses_attachment(&self, kind: AttachmentKind, msaa_samples: u32) -> bool {
    // MainPass either renders directly to the swapchain (1x) or uses the
    // two MSAA attachments and resolves into it (>1x).
    match kind {
        AttachmentKind::MsaaColor | AttachmentKind::MsaaDepth => msaa_samples > 1,
        AttachmentKind::Depth => msaa_samples == 1,
        AttachmentKind::SwapchainColor | AttachmentKind::Shadow | AttachmentKind::Color => true,
    }
}

fn attachment_info(&self, kind: AttachmentKind) -> (vk::ImageLayout, vk::AccessFlags2) {
    match kind {
        AttachmentKind::MsaaColor | AttachmentKind::SwapchainColor | AttachmentKind::Color => (
            vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL,
            vk::AccessFlags2::COLOR_ATTACHMENT_WRITE,
        ),
        AttachmentKind::MsaaDepth | AttachmentKind::Depth => (
            vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
            vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE,
        ),
        AttachmentKind::Shadow => (
            vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
            vk::AccessFlags2::SHADER_READ,
        ),
src/vulkan/main_pass.rs:24-31,219-242

Not all five are used at once. With MSAA on, it draws into the two multisampled images and resolves into the swapchain image; at 1×, it draws straight into the swapchain image and a single-sample depth buffer. uses_attachment tells the graph which set is live. attachment_info gives the layout and access each needs: write access as a colour or depth target, but read-only access to the shadow map, which the main pass only samples.

Finding the actual images

Each frame, the graph turns those requests into real images. Some come from the swapchain; the rest from an AttachmentManager:

let handle = match req.kind {
    AttachmentKind::SwapchainColor => AttachmentHandle {
        image: vb.swapchain.images[image_index],
        view: vb.swapchain.swapchain_image_views[image_index],
        layer_views: [vk::ImageView::null(); SHADOW_CASCADE_COUNT],
    },
    AttachmentKind::MsaaColor => AttachmentHandle {
        image: vb.swapchain.color_msaa_image,
        view: vb.swapchain.color_msaa_image_view,
        layer_views: [vk::ImageView::null(); SHADOW_CASCADE_COUNT],
    },
    AttachmentKind::MsaaDepth => AttachmentHandle {
// ...
    AttachmentKind::Depth => {
        // Build a concrete request for a single-sample depth attachment
        let mut depth_req = *req;
        depth_req.format = vb.swapchain.depth_format; // engine’s chosen depth format
        depth_req.extent = vb.swapchain.extent; // match swapchain size
        depth_req.samples = vk::SampleCountFlags::TYPE_1; // single-sampled
        vb.get_attachment(depth_req)
    }
src/vulkan/render_graph.rs:319-330,335-342

The swapchain image is the one just acquired. The MSAA images live in the Swapchain struct, because they share its size (Chapter 4), so there's one pair for everything. Depth and shadow images come from the manager, which creates them on first request and keeps one per swapchain image:

/// Gets or lazily creates an attachment matching the request for a given image index.
pub fn get_attachment(
    &mut self,
    device: &ash::Device,
    allocator: &Allocator,
    image_index: usize,
    request: AttachmentRequest,
) -> AttachmentHandle {
    let key = AttachmentKey::from(request);
    if !self.attachments.contains_key(&key) {
        self.create_attachments(device, allocator, &key);
    }
    self.attachments[&key][image_index].handle
}
src/vulkan/attachments.rs:96-109

The key is the full description (kind, format, size, sample count), so a request with a new size, after a resize or a shadow-resolution change, simply produces new images. (Old ones stay cached until the next swapchain recreation clears the manager.) The shadow map is a four-layer image array, one layer per cascade, and gets an extra view for each layer so the shadow pass can render into them one at a time while the main pass reads all four through a single array view:

let mut layer_views = [vk::ImageView::null(); SHADOW_CASCADE_COUNT];
if key.kind == AttachmentKind::Shadow {
    for (layer, layer_view) in layer_views.iter_mut().enumerate() {
        let layer_info = vk::ImageViewCreateInfo {
            view_type: vk::ImageViewType::TYPE_2D,
            subresource_range: vk::ImageSubresourceRange {
                base_array_layer: layer as u32,
                layer_count: 1,
                ..view_info.subresource_range
            },
            ..view_info
        };
        *layer_view = unsafe {
            device
                .create_image_view(&layer_info, None)
                .expect("create shadow cascade view")
        };
    }
}
src/vulkan/attachments.rs:201-219

Starting each frame

The graph tracks each attachment's state as it goes. At the start of a frame every image is treated as UNDEFINED: its old contents are thrown away, since every pass here clears or overwrites its images anyway. The first barrier must still wait for whatever used the image last (the display, for a swapchain image; the previous frame, for the shared MSAA images), so the starting state records that earlier use:

attachment_handles.insert(req.kind, handle);
// Contents are discarded (UNDEFINED), but the first barrier must still wait
// for the image's previous use: see `previous_frame_use`.
let (access, stage) = previous_frame_use(req.kind);
attachment_states.insert(
    req.kind,
    AttachmentState {
        handle,
        layout: vk::ImageLayout::UNDEFINED,
        access,
        stage,
    },
);
src/vulkan/render_graph.rs:357-369

A small function, previous_frame_use, supplies the stage and access of that earlier use for each kind of image.

Barriers between passes

Before each pass records, the graph compares what the pass wants with each image's current state:

let mut transitions: Vec<ImageTransition> = Vec::new();
for req in pass.attachments() {
    if !pass.uses_attachment(req.kind, vb.engine_settings.msaa_samples) {
        continue;
    }
    let (new_layout, new_access) = pass.attachment_info(req.kind);
    let new_stage = pipeline_stage_for_access(new_access);

    let state = attachment_states.get_mut(&req.kind).unwrap();

    if state.layout != new_layout || state.access != new_access {
        transitions.push(ImageTransition {
            image: state.handle.image,
            old_layout: state.layout,
            new_layout,
            src_access_mask: state.access,
            dst_access_mask: new_access,
            src_stage_mask: state.stage,
            dst_stage_mask: new_stage,
            aspect_mask: aspect_for_kind(req.kind),
            layer_count: if req.kind == AttachmentKind::Shadow {
                SHADOW_CASCADE_COUNT as u32
            } else {
                1
            },
        });
        state.layout = new_layout;
        state.access = new_access;
        state.stage = new_stage;
    }
}

if !transitions.is_empty() {
    vb.insert_attachment_barriers(cmd, &transitions);
}
src/vulkan/render_graph.rs:383-417

If the layout or access differs, it records a transition from the current state to the new one, deriving the destination stage from the access type, and updates its record. All the transitions for one pass go into a single vkCmdPipelineBarrier2 call, with each image's barrier carrying its own stages:

    let mut barriers: Vec<vk::ImageMemoryBarrier2> = Vec::new();
    for t in transitions {
        barriers.push(vk::ImageMemoryBarrier2 {
            src_stage_mask: t.src_stage_mask,
            src_access_mask: t.src_access_mask,
            dst_stage_mask: t.dst_stage_mask,
            dst_access_mask: t.dst_access_mask,
            old_layout: t.old_layout,
            new_layout: t.new_layout,
            src_queue_family_index: vk::QUEUE_FAMILY_IGNORED,
            dst_queue_family_index: vk::QUEUE_FAMILY_IGNORED,
            image: t.image,
            subresource_range: vk::ImageSubresourceRange {
                aspect_mask: t.aspect_mask,
                base_mip_level: 0,
                level_count: 1,
                base_array_layer: 0,
                layer_count: t.layer_count,
            },
            ..Default::default()
        });
    }
    let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers);
    unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency) };
}
src/vulkan/base.rs:327-351

Following the shadow map through a frame shows it working. Before the shadow pass: UNDEFINED → depth attachment, writing. Before the main pass: depth attachment → shader-read-only, so the main pass's fragment shaders wait for the shadow pass's depth writes to finish and be visible. The UI pass also declares shader-read access for its cascade preview. That matches the state established by the main pass, so it samples the same image without another transition. Declaring the read keeps this dependency visible to the scheduler even when the preview panel is closed.

The swapchain image shows one more rule. The UI pass keeps the same layout, but it loads the main pass's result and blends over it, so it declares read as well as write access. That difference is what makes the graph insert a barrier, so the UI can't read the image before the main pass has finished writing it.

Beginning rendering, and the resolve

Once its barriers are in, a pass starts rendering with cmd_begin_rendering from dynamic rendering (Chapter 7), naming the actual image views. Here's the main pass's MSAA path:

let (color_attachment_info, depth_attachment_info) = if msaa_samples > 1 {
    // MSAA path: render to MSAA color/depth and resolve to swapchain
    let color_att = ctx.attachments[&AttachmentKind::MsaaColor];
    let depth_att = ctx.attachments[&AttachmentKind::MsaaDepth];
    let resolve_att = ctx.attachments[&AttachmentKind::SwapchainColor];

    let color_attachment_info = vk::RenderingAttachmentInfo::default()
        .image_view(color_att.view)
        .image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
        .load_op(vk::AttachmentLoadOp::CLEAR)
        .store_op(vk::AttachmentStoreOp::DONT_CARE) // resolve writes final color
        .clear_value(clear_color)
        .resolve_mode(vk::ResolveModeFlags::AVERAGE)
        .resolve_image_view(resolve_att.view)
        .resolve_image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
src/vulkan/main_pass.rs:58-72

load_op: CLEAR clears the image at the start of rendering. store_op: DONT_CARE says the multisampled samples needn't be written back to memory afterwards. Instead, the resolve averages them (AVERAGE) into the swapchain image's view. Apple's GPUs render the screen in small tiles held in fast on-chip memory. Combined with the TRANSIENT_ATTACHMENT usage these images were created with (Chapter 4), that lets the driver avoid writing the full multisampled image out to main memory. Depth is cleared and discarded the same way. Then the pass begins:

let color_attachments = [color_attachment_info];
let rendering_info = vk::RenderingInfo::default()
    .render_area(vk::Rect2D {
        offset: vk::Offset2D::default(),
        extent: ctx.vulkan_base.swapchain.extent,
    })
    .layer_count(1)
    .color_attachments(&color_attachments)
    .depth_attachment(&depth_attachment_info);

unsafe {
    device.cmd_begin_rendering(cmd, &rendering_info);
src/vulkan/main_pass.rs:106-117

Handing the image to the display

After the last pass, the swapchain image goes to the layout presentation expects:

// --- 4. Final transition for presentation ---
let swapchain_state = attachment_states
    .get_mut(&AttachmentKind::SwapchainColor)
    .unwrap();
if swapchain_state.layout != vk::ImageLayout::PRESENT_SRC_KHR {
    vb.insert_attachment_barriers(
        frame.cmd_buf,
        &[ImageTransition {
            image: swapchain_state.handle.image,
            old_layout: swapchain_state.layout,
            new_layout: vk::ImageLayout::PRESENT_SRC_KHR,
            src_access_mask: swapchain_state.access,
            dst_access_mask: vk::AccessFlags2::empty(), // No access needed for present
            src_stage_mask: swapchain_state.stage,
            dst_stage_mask: vk::PipelineStageFlags2::NONE,
            aspect_mask: vk::ImageAspectFlags::COLOR,
            layer_count: 1,
        }],
    );
}
src/vulkan/render_graph.rs:462-481

The destination stage and access are both empty: nothing later in this command buffer touches the image. Ordering against the presentation engine is handled by the render-finished semaphore, not by this barrier (Chapter 9).

Recap