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:
- Shadow pass: renders the scene's depth from the sun's point of view into a shadow map, four times over (Chapter 11).
- 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).
- 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:
- Waits: work at some earlier stage must finish before work at some later stage starts (an execution dependency).
- Publishes: writes made before the barrier become visible to reads after it (a memory dependency). GPUs have caches, and without this a later pass could read stale data.
- Rearranges: moves the image from its old layout to its new one.
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.
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.
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) {}
}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,
}
}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,
),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)
}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
}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")
};
}
}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,
},
);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);
}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) };
}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);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);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,
}],
);
}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
- A frame is three passes in fixed order: shadow, main, UI. Each declares the attachments it uses and the layout and access it needs.
- Barriers wait, publish writes, and change layouts. The graph tracks every attachment's state and inserts the barriers between passes.
- Each image's first barrier in a frame waits for whatever used it last, and a pass that reads an image declares it, so it waits for the writes before it.
- With MSAA, the main pass draws into multisampled images and resolves into the swapchain image, discarding the samples. A final barrier readies the image for presentation.