Passes & the PassNode Trait
A pass is a PassNode. The trait declares the pass's resource dependencies, gives the graph a chance to upload per-pass uniforms during the prepare phase, and records GPU commands during execute.
The PassNode Trait
#![allow(unused)] fn main() { pub trait PassNode<C = ()>: Send + Sync + Any { fn name(&self) -> &str; fn reads(&self) -> Vec<&str>; fn writes(&self) -> Vec<&str>; fn reads_writes(&self) -> Vec<&str> { Vec::new() } fn optional_reads(&self) -> Vec<&str> { Vec::new() } fn prepare(&mut self, _device: &Device, _queue: &wgpu::Queue, _configs: &C) {} fn invalidate_bind_groups(&mut self) {} fn execute<'r, 'e>( &mut self, context: PassExecutionContext<'r, 'e, C>, ) -> Result<Vec<SubGraphRunCommand<'r>>>; } }
On WASM the Send + Sync bounds are removed because the WebGPU types are not Send.
Slot-Based Resource Binding
Passes declare slot names. Slot names are strings that match the keys in the add_pass() bindings. The names are local to the pass.
#![allow(unused)] fn main() { impl PassNode<World> for MyPass { fn name(&self) -> &str { "my_pass" } // Slots this pass reads from (input textures) fn reads(&self) -> Vec<&str> { vec!["input"] } // Slots this pass writes to (output attachments) fn writes(&self) -> Vec<&str> { vec!["output"] } // Slots that are both read and written (read-modify-write) fn reads_writes(&self) -> Vec<&str> { vec![] } // Slots that are read if available, but don't create dependencies if absent fn optional_reads(&self) -> Vec<&str> { vec![] } // ... } }
The slot-to-resource binding happens at add_pass().
#![allow(unused)] fn main() { graph.add_pass( Box::new(my_pass), &[("input", scene_color_id), ("output", swapchain_id)], )?; }
The pass's shader code refers to bind groups, not to slot names. The slot names are only the graph's vocabulary. Inside the pass, you ask the execution context for the view bound to a slot.
PassExecutionContext
The context is what the pass uses to look up resources, get attachment descriptors, and record commands.
#![allow(unused)] fn main() { pub struct PassExecutionContext<'r, 'e, C = ()> { pub encoder: &'e mut CommandEncoder, pub resources: &'r RenderGraphResources, pub device: &'r Device, pub queue: &'r wgpu::Queue, pub configs: &'r C, // For Nightshade, this is &World // ... internal fields } }
Context Methods
| Method | Returns | Description |
|---|---|---|
get_texture_view(slot) | &TextureView | Get a texture view for sampling |
get_color_attachment(slot) | (view, LoadOp, StoreOp) | Get color attachment with automatic load/store ops |
get_depth_attachment(slot) | (view, LoadOp, StoreOp) | Get depth attachment with automatic load/store ops |
get_buffer(slot) | &Buffer | Get a GPU buffer |
get_texture_size(slot) | (u32, u32) | Get texture dimensions |
is_pass_enabled() | bool | Check if this pass is currently enabled |
run_sub_graph(name, inputs) | - | Execute a sub-graph |
Automatic Load/Store Operations
The graph picks the right load and store op for each attachment based on who else touches the resource.
LoadOp::Clearis used when this pass is the first writer and the resource has a clear value.LoadOp::Loadis used when a previous pass already wrote to this resource.StoreOp::Storeis used when any later pass reads this resource.StoreOp::Discardis used when no subsequent pass reads this resource.
You do not choose these. get_color_attachment() and get_depth_attachment() return the right ones.
Prepare Phase
prepare() runs before execution for every non-culled pass. The right work for prepare is anything that uploads uniforms or per-frame data.
#![allow(unused)] fn main() { fn prepare(&mut self, device: &Device, queue: &wgpu::Queue, configs: &World) { let camera_data = extract_camera_uniforms(configs); queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&camera_data)); } }
Bind Group Invalidation
When the graph reallocates a resource (resize, aliasing change), invalidate_bind_groups() is called on every pass that references that resource. The pass clears its cached bind groups so they get rebuilt against the new GPU texture.
#![allow(unused)] fn main() { fn invalidate_bind_groups(&mut self) { self.bind_group = None; } }
The graph tracks a version per resource. Only passes that actually reference a changed resource get invalidated.
Full Example
#![allow(unused)] fn main() { pub struct BlurPass { pipeline: wgpu::RenderPipeline, bind_group_layout: wgpu::BindGroupLayout, bind_group: Option<wgpu::BindGroup>, sampler: wgpu::Sampler, } impl PassNode<World> for BlurPass { fn name(&self) -> &str { "blur_pass" } fn reads(&self) -> Vec<&str> { vec!["input"] } fn writes(&self) -> Vec<&str> { vec!["output"] } fn invalidate_bind_groups(&mut self) { self.bind_group = None; } fn execute<'r, 'e>( &mut self, ctx: PassExecutionContext<'r, 'e, World>, ) -> Result<Vec<SubGraphRunCommand<'r>>> { if !ctx.is_pass_enabled() { return Ok(vec![]); } let input_view = ctx.get_texture_view("input")?; let (output_view, load_op, store_op) = ctx.get_color_attachment("output")?; if self.bind_group.is_none() { self.bind_group = Some(ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { layout: &self.bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(input_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler), }, ], label: Some("blur_bind_group"), })); } let mut pass = ctx.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("blur_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: output_view, resolve_target: None, ops: wgpu::Operations { load: load_op, store: store_op }, })], depth_stencil_attachment: None, ..Default::default() }); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, self.bind_group.as_ref().unwrap(), &[]); pass.draw(0..3, 0..1); // Fullscreen triangle Ok(vec![]) } } }
The pattern is the same for every pass. Pull the views out of the context, build a bind group if the cached one was invalidated, begin a render pass with the load and store ops the graph computed, set the pipeline, draw.
Sub-Graph Execution
A pass can trigger a sub-graph for multi-pass effects like a bloom mip chain.
#![allow(unused)] fn main() { fn execute<'r, 'e>( &mut self, mut ctx: PassExecutionContext<'r, 'e, World>, ) -> Result<Vec<SubGraphRunCommand<'r>>> { ctx.run_sub_graph("bloom_mip_chain".to_string(), vec![ SlotValue::TextureView { view: ctx.get_texture_view("hdr")?, width: self.width, height: self.height, }, ]); Ok(ctx.into_sub_graph_commands()) } }
The sub-graph is a separate RenderGraph registered against a name. The parent pass passes in slot values and the sub-graph executes with those as its external inputs.