Custom render pass — step-by-step guide¶
This guide walks you through creating a fully functional custom
RenderPass from scratch. The example adds a toon-outline pass
that draws a slightly expanded, back-face-culled solid in a single
colour to produce a cartoon outline effect.
Prerequisites¶
- Read
../render_pass.mdfor the trait contract and ordering rules. - Read
../architecture.mdto understandFramePacketand the prepare/execute split.
Step 1 — Write the WGSL shader¶
Create assets/shaders/outline.wgsl:
// Outline pass — expands vertices along their normals and renders
// back faces in a flat colour.
struct CameraUniform {
view_proj: mat4x4<f32>,
};
struct ModelUniform {
model: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> camera: CameraUniform;
@group(1) @binding(0) var<uniform> model: ModelUniform;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) color: vec3<f32>, // unused; keeps layout compatible
};
@vertex
fn vs_main(in: VertexInput) -> @builtin(position) vec4<f32> {
let world_pos = model.model * vec4<f32>(in.position, 1.0);
let expanded = world_pos.xyz + normalize(world_pos.xyz) * 0.03;
return camera.view_proj * vec4<f32>(expanded, 1.0);
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(0.0, 0.0, 0.0, 1.0); // black outline
}
The shader reuses the same bind-group layout as the world shader
(group 0 = camera, group 1 = model), so PipelineLayouts can be
shared directly.
Step 2 — Build the pipeline¶
use std::sync::Arc;
use ferrous_renderer::{
pipeline::layout::PipelineLayouts,
geometry::Vertex,
};
pub fn build_outline_pipeline(
device: &wgpu::Device,
layouts: &PipelineLayouts,
format: wgpu::TextureFormat,
samples: u32,
) -> wgpu::RenderPipeline {
let src = std::fs::read_to_string("assets/shaders/outline.wgsl")
.expect("outline.wgsl not found");
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("outline_shader"),
source: wgpu::ShaderSource::Wgsl(src.into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("outline_layout"),
bind_group_layouts: &[&layouts.camera, &layouts.model],
push_constant_ranges: &[],
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("outline_pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_main"),
buffers: &[Vertex::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Front), // cull front faces = draw back
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: Default::default(),
bias: Default::default(),
}),
multisample: wgpu::MultisampleState {
count: samples,
..Default::default()
},
multiview: None,
cache: None,
})
}
Step 3 — Implement the pass¶
use ferrous_renderer::{RenderPass, FramePacket};
use std::any::Any;
use std::sync::Arc;
pub struct OutlinePass {
pipeline: Arc<wgpu::RenderPipeline>,
}
impl OutlinePass {
pub fn new(
device: &wgpu::Device,
layouts: &ferrous_renderer::pipeline::layout::PipelineLayouts,
format: wgpu::TextureFormat,
samples: u32,
) -> Self {
Self {
pipeline: Arc::new(build_outline_pipeline(device, layouts, format, samples)),
}
}
}
impl RenderPass for OutlinePass {
fn name(&self) -> &str { "outline_pass" }
fn as_any(&self) -> &dyn Any { self }
fn as_any_mut(&mut self) -> &mut dyn Any { self }
fn prepare(
&mut self,
_device: &wgpu::Device,
_queue: &wgpu::Queue,
_packet: &FramePacket,
) {
// nothing to upload: camera and model uniforms are managed
// by WorldPass and sync_world respectively
}
fn execute(
&self,
_device: &wgpu::Device,
_queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
color_view: &wgpu::TextureView,
resolve_target: Option<&wgpu::TextureView>,
depth_view: &wgpu::TextureView,
packet: &FramePacket,
) {
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("outline_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color_view,
resolve_target,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load, // preserve WorldPass output
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &packet.camera.bind_group, &[]);
for cmd in &packet.scene_objects {
rp.set_bind_group(1, &cmd.model_bind_group, &[]);
rp.set_vertex_buffer(0, cmd.vertex_buffer.slice(..));
rp.set_index_buffer(cmd.index_buffer.slice(..), cmd.index_format);
rp.draw_indexed(0..cmd.index_count, 0, 0..1);
}
}
}
Step 4 — Register the pass¶
Retrieve the format and sample count from the renderer, then register
the pass between WorldPass and UiPass:
// 1. Clear the default pass list
renderer.clear_passes();
// 2. Re-add WorldPass manually (it was removed by clear_passes)
// when constructing a `WorldPass` you now supply four PBR pipelines
// (opaque, opaque-double-sided, blend, blend-double-sided) followed by
// four analogous instancing pipelines. the snippet above is abbreviated –
// consult `renderer::new` for the full call with concrete variables.
let world_pass = ferrous_renderer::passes::world_pass::WorldPass::new(
/* pbr_opaque */ pbr_pipeline,
/* pbr_double */ pbr_pipeline_double,
/* pbr_blend */ pbr_pipeline_blend,
/* pbr_blend_double */ pbr_pipeline_blend_double,
/* inst_opaque */ instancing_pipeline,
/* inst_double */ instancing_pipeline_double,
/* inst_blend */ instancing_pipeline_blend,
/* inst_blend_double */ instancing_pipeline_blend_double,
gpu_camera.bind_group.clone(),
&ctx.device,
&ctx.queue, // new parameter: queue reference is required for environment
&pipeline_layouts,
/* hdri_path */ None,
);
renderer.add_pass(Box::new(world_pass));
// 3. Add the outline pass
let outline = OutlinePass::new(
&ctx.device,
&pipeline_layouts,
renderer.render_target().color_format(),
renderer.render_target().sample_count(),
);
renderer.add_pass(Box::new(outline));
// 4. Re-add UiPass
let ui_pass = ferrous_renderer::passes::ui_pass::UiPass::new(&ctx.device, &ctx.queue);
renderer.add_pass(Box::new(ui_pass));
Tip — if you only want to append a pass after the existing list (e.g. a post-process effect after the UI), simply call
renderer.add_pass(Box::new(my_pass))without callingclear_passes.
Step 5 — Verify¶
Run cargo check -p ferrous_editor (or your application crate) to
confirm there are no compile errors, then run the application to see the
outline effect.
Common mistakes¶
| Mistake | Symptom | Fix |
|---|---|---|
Using LoadOp::Clear in a non-first pass |
Overwrites WorldPass output | Use LoadOp::Load |
Forgetting resolve_target in MSAA |
Black screen or validation error | Always forward resolve_target |
Accessing queue inside execute |
Panic / borrow conflict | Move all uploads to prepare |
| Wrong cull mode for outline effect | No outline visible | Front faces must be culled |