Skip to content

Latest commit

History

History
731 lines (631 loc) · 22.4 KB

File metadata and controls

731 lines (631 loc) · 22.4 KB
titleOffscreen Post: Render to a Texture and Sample to the Surface
document_idoffscreen-post-tutorial-2025-12-29
statusdraft
created2025-12-29T00:00:00Z
last_updated2026-02-07T00:00:00Z
version0.2.2
engine_workspace_version2023.1.30
wgpu_version26.0.1
shader_backend_defaultnaga
winit_version0.29.10
repo_commit544444652b4dc3639f8b3e297e56c302183a7a0b
owners
lambda-sh
reviewers
engine
rendering
tags
tutorial
graphics
offscreen
render-targets
multipass
post-processing
texture
sampler
wgpu
rust

Overview

This tutorial renders a triangle into an offscreen render target, then samples that target in a second pass to present the result on the window surface. The implementation demonstrates multi-pass rendering, bind groups for texture sampling, and resource replacement during window resize.

Reference implementation: demos/render/src/bin/offscreen_post.rs.

Table of Contents

Goals

  • Render into an offscreen color texture using RenderDestination::Offscreen.
  • Sample the offscreen result in a second pass using a bind group.
  • Replace the offscreen target and dependent bind group on window resize.

Prerequisites

  • The workspace builds: cargo build --workspace.
  • The minimal demo runs: cargo run -p lambda-demos-minimal --bin minimal.

Requirements and Constraints

  • The offscreen target color texture MUST be created with both render-attachment and sampled usage. Use OffscreenTargetBuilder to ensure correct usage.
  • The offscreen pass/pipeline color format MUST match the offscreen target format. This example uses render_context.surface_format() for both.
  • The render path MUST handle 0x0 sizes during resize. This example clamps viewport sizes via width.max(1) and height.max(1).
  • The bind group layout bindings MUST match the shader declarations: layout (set = 0, binding = 1) for the texture and binding = 2 for the sampler.
  • Replacing an offscreen target MUST also replace any bind groups that reference the previous target’s texture view.
  • Acronyms: graphics processing unit (GPU), central processing unit (CPU), texture coordinates (UV).

Data Flow

Default::default
└─ ShaderBuilder → Shader handles
Component::on_attach
├─ OffscreenTargetBuilder → OffscreenTarget (attached)
├─ RenderPassBuilder → offscreen pass + post pass (attached)
├─ RenderPipelineBuilder → offscreen pipeline + post pipeline (attached)
└─ BindGroupLayout/BindGroup → sample offscreen color texture
Component::on_render (each frame)
Pass A (Offscreen): draw triangle → offscreen color texture
Pass B (Surface): sample offscreen texture → fullscreen quad

Implementation Steps

Step 1 — Imports and Shader Sources

Start with the imports and the embedded post shaders.

#![allow(clippy::needless_return)]//! Example: Render to an offscreen target, then sample it to the surface.use lambda::{
component::Component,
events::Events,
logging,
render::{
bind::{BindGroupBuilder,BindGroupLayout,BindGroupLayoutBuilder,},
buffer::BufferBuilder,
command::{RenderCommand,RenderDestination,},
mesh::{Mesh,MeshBuilder,},
pipeline::{CullingMode,RenderPipelineBuilder,},
render_pass::RenderPassBuilder,
shader::{Shader,ShaderBuilder,ShaderKind,VirtualShader,},
targets::offscreen::OffscreenTargetBuilder,
texture::SamplerBuilder,
vertex::{ColorFormat,Vertex,VertexAttribute,VertexBuilder,VertexElement,},
viewport::ViewportBuilder,RenderContext,ResourceId,},
runtime::start_runtime,
runtimes::{
application::ComponentResult,ApplicationRuntimeBuilder,},};constPOST_VERTEX_SHADER_SOURCE:&str = r#"#version 450layout (location = 0) in vec3 vertex_position;layout (location = 2) in vec3 vertex_color; // uv packed into .xylayout (location = 0) out vec2 v_uv;void main() { gl_Position = vec4(vertex_position, 1.0); v_uv = vertex_color.xy;}"#;constPOST_FRAGMENT_SHADER_SOURCE:&str = r#"#version 450layout (location = 0) in vec2 v_uv;layout (location = 0) out vec4 fragment_color;layout (set = 0, binding = 1) uniform texture2D tex;layout (set = 0, binding = 2) uniform sampler samp;void main() { fragment_color = texture(sampler2D(tex, samp), v_uv);}"#;

The offscreen pass uses crates/lambda-rs/assets/shaders/triangle.vert and crates/lambda-rs/assets/shaders/triangle.frag.

Step 2 — Component State

Define the component state used by the example.

pubstructOffscreenPostExample{triangle_vs:Shader,triangle_fs:Shader,post_vs:Shader,post_fs:Shader,quad_mesh:Option<Mesh>,offscreen_pass:Option<ResourceId>,offscreen_pipeline:Option<ResourceId>,offscreen_target:Option<ResourceId>,post_pass:Option<ResourceId>,post_pipeline:Option<ResourceId>,post_bind_group:Option<ResourceId>,post_layout:Option<BindGroupLayout>,width:u32,height:u32,}

This struct matches the example’s fields and keeps the shader handles alongside the IDs returned by RenderContext::attach_*.

Step 3 — Compile Shaders in Default

Compile the triangle and post shaders in Default, matching the example.

implDefaultforOffscreenPostExample{fndefault() -> Self{let triangle_vertex = VirtualShader::Source{source:include_str!("../assets/shaders/triangle.vert").to_string(),kind:ShaderKind::Vertex,name:String::from("triangle"),entry_point:String::from("main"),};let triangle_fragment = VirtualShader::Source{source:include_str!("../assets/shaders/triangle.frag").to_string(),kind:ShaderKind::Fragment,name:String::from("triangle"),entry_point:String::from("main"),};letmut builder = ShaderBuilder::new();let triangle_vs = builder.build(triangle_vertex);let triangle_fs = builder.build(triangle_fragment);let post_vs = builder.build(VirtualShader::Source{source:POST_VERTEX_SHADER_SOURCE.to_string(),kind:ShaderKind::Vertex,entry_point:"main".to_string(),name:"offscreen-post".to_string(),});let post_fs = builder.build(VirtualShader::Source{source:POST_FRAGMENT_SHADER_SOURCE.to_string(),kind:ShaderKind::Fragment,entry_point:"main".to_string(),name:"offscreen-post".to_string(),});returnOffscreenPostExample{
triangle_vs,
triangle_fs,
post_vs,
post_fs,quad_mesh:None,offscreen_pass:None,offscreen_pipeline:None,offscreen_target:None,post_pass:None,post_pipeline:None,post_bind_group:None,post_layout:None,width:800,height:600,};}}

This keeps shader construction out of on_attach so the component can build pipelines immediately from the stored Shader values.

Step 4 — Implement Component and Build Resources

Implement the component lifecycle. This example creates the offscreen target, passes, pipelines, and bind group in on_attach, and records two render passes each frame in on_render.

implComponent<ComponentResult,String>forOffscreenPostExample{fnon_attach(&mutself,render_context:&mutRenderContext,) -> Result<ComponentResult,String>{
logging::info!("Attaching OffscreenPostExample");let surface_size = render_context.surface_size();let offscreen_target = OffscreenTargetBuilder::new().with_color(
render_context.surface_format(),
surface_size.0,
surface_size.1,).with_label("offscreen-post-target").build(render_context.gpu()).map_err(|e| format!("Failed to build offscreen target: {:?}", e))?;let offscreen_target_id =
render_context.attach_offscreen_target(offscreen_target);let offscreen_pass =
RenderPassBuilder::new().with_label("offscreen-pass").build(
render_context.gpu(),
render_context.surface_format(),
render_context.depth_format(),);let offscreen_pipeline = RenderPipelineBuilder::new().with_label("offscreen-pipeline").with_culling(CullingMode::None).build(
render_context.gpu(),
render_context.surface_format(),
render_context.depth_format(),&offscreen_pass,&self.triangle_vs,Some(&self.triangle_fs),);let post_pass = RenderPassBuilder::new().with_label("post-pass").build(
render_context.gpu(),
render_context.surface_format(),
render_context.depth_format(),);let post_layout = BindGroupLayoutBuilder::new().with_sampled_texture(1).with_sampler(2).build(render_context.gpu());let sampler = SamplerBuilder::new().linear_clamp().with_label("offscreen-post-sampler").build(render_context.gpu());let offscreen_ref =
render_context.get_offscreen_target(offscreen_target_id);let post_bind_group = BindGroupBuilder::new().with_layout(&post_layout).with_texture(1, offscreen_ref.color_texture()).with_sampler(2,&sampler).build(render_context.gpu());let quad_mesh = Self::build_fullscreen_quad_mesh();let quad_vertex_buffer =
BufferBuilder::build_from_mesh(&quad_mesh, render_context.gpu()).map_err(|e| format!("Failed to build quad vertex buffer: {:?}", e))?;let post_pipeline = RenderPipelineBuilder::new().with_label("post-pipeline").with_culling(CullingMode::None).with_layouts(&[&post_layout]).with_buffer(quad_vertex_buffer, quad_mesh.attributes().to_vec()).build(
render_context.gpu(),
render_context.surface_format(),
render_context.depth_format(),&post_pass,&self.post_vs,Some(&self.post_fs),);self.offscreen_pass =
Some(render_context.attach_render_pass(offscreen_pass));self.offscreen_pipeline =
Some(render_context.attach_pipeline(offscreen_pipeline));self.offscreen_target = Some(offscreen_target_id);self.post_pass = Some(render_context.attach_render_pass(post_pass));self.post_pipeline = Some(render_context.attach_pipeline(post_pipeline));self.post_bind_group =
Some(render_context.attach_bind_group(post_bind_group));self.post_layout = Some(post_layout);self.quad_mesh = Some(quad_mesh);let(width, height) = render_context.surface_size();self.width = width;self.height = height;returnOk(ComponentResult::Success);}fnon_detach(&mutself,_render_context:&mutRenderContext,) -> Result<ComponentResult,String>{returnOk(ComponentResult::Success);}fnevent_mask(&self) -> lambda::events::EventMask{return lambda::events::EventMask::WINDOW;}fnon_window_event(&mutself,event:&lambda::events::WindowEvent,) -> Result<(),String>{iflet lambda::events::WindowEvent::Resize{ width, height } = event {self.width = *width;self.height = *height;}returnOk(());}fnon_update(&mutself,_last_frame:&std::time::Duration,) -> Result<ComponentResult,String>{returnOk(ComponentResult::Success);}fnon_render(&mutself,render_context:&mutRenderContext,) -> Vec<RenderCommand>{self.ensure_offscreen_matches_surface(render_context);let offscreen_viewport =
ViewportBuilder::new().build(self.width.max(1),self.height.max(1));let surface_viewport =
ViewportBuilder::new().build(self.width.max(1),self.height.max(1));returnvec![RenderCommand::BeginRenderPassTo{
render_pass:self.offscreen_pass.expect("offscreen pass not set"),
viewport: offscreen_viewport.clone(),
destination:RenderDestination::Offscreen(self.offscreen_target.expect("offscreen target not set"),),},RenderCommand::SetPipeline{
pipeline:self.offscreen_pipeline.expect("offscreen pipeline not set"),},RenderCommand::SetViewports{
start_at:0,
viewports: vec![offscreen_viewport.clone()],},RenderCommand::SetScissors{
start_at:0,
viewports: vec![offscreen_viewport.clone()],},RenderCommand::Draw{
vertices:0..3,
instances:0..1,},RenderCommand::EndRenderPass,RenderCommand::BeginRenderPass{
render_pass:self.post_pass.expect("post pass not set"),
viewport: surface_viewport.clone(),},RenderCommand::SetPipeline{
pipeline:self.post_pipeline.expect("post pipeline not set"),},RenderCommand::SetBindGroup{
set:0,
group:self.post_bind_group.expect("post bind group not set"),
dynamic_offsets: vec![],},RenderCommand::BindVertexBuffer{
pipeline:self.post_pipeline.expect("post pipeline not set"),
buffer:0,},RenderCommand::SetViewports{
start_at:0,
viewports: vec![surface_viewport.clone()],},RenderCommand::SetScissors{
start_at:0,
viewports: vec![surface_viewport.clone()],},RenderCommand::Draw{
vertices:0..6,
instances:0..1,},RenderCommand::EndRenderPass,];}}

This produces two render passes: an offscreen triangle render and a post pass that samples the offscreen color texture and draws a fullscreen quad.

Step 5 — Fullscreen Quad Mesh

Build the fullscreen quad mesh used by the post pass.

implOffscreenPostExample{fnbuild_fullscreen_quad_mesh() -> Mesh{let vertices:[Vertex;6] = [VertexBuilder::new().with_position([-1.0, -1.0,0.0]).with_normal([0.0,0.0,1.0]).with_color([0.0,0.0,0.0]).build(),VertexBuilder::new().with_position([1.0, -1.0,0.0]).with_normal([0.0,0.0,1.0]).with_color([1.0,0.0,0.0]).build(),VertexBuilder::new().with_position([1.0,1.0,0.0]).with_normal([0.0,0.0,1.0]).with_color([1.0,1.0,0.0]).build(),VertexBuilder::new().with_position([-1.0, -1.0,0.0]).with_normal([0.0,0.0,1.0]).with_color([0.0,0.0,0.0]).build(),VertexBuilder::new().with_position([1.0,1.0,0.0]).with_normal([0.0,0.0,1.0]).with_color([1.0,1.0,0.0]).build(),VertexBuilder::new().with_position([-1.0,1.0,0.0]).with_normal([0.0,0.0,1.0]).with_color([0.0,1.0,0.0]).build(),];return vertices
.into_iter().fold(MeshBuilder::new(), |builder, vertex| builder.with_vertex(vertex)).with_attributes(vec![VertexAttribute{
location:0,
offset:0,
element:VertexElement{
format:ColorFormat::Rgb32Sfloat,
offset:0,},},VertexAttribute{
location:1,
offset:0,
element:VertexElement{
format:ColorFormat::Rgb32Sfloat,
offset:12,},},VertexAttribute{
location:2,
offset:0,
element:VertexElement{
format:ColorFormat::Rgb32Sfloat,
offset:24,},},]).build();}}

The post vertex shader reads UV from vertex_color.xy at location = 2, which is why the quad’s VertexAttribute for location = 2 uses offset: 24.

Step 6 — Record Commands in on_render

on_render records two passes each frame. The offscreen pass targets RenderDestination::Offscreen and draws 0..3 vertices. The post pass targets the surface, binds set 0 and vertex buffer slot 0, and draws 0..6 vertices for the fullscreen quad.

Step 7 — Resize Events and Resource Replacement

on_window_event stores the new width/height and ensure_offscreen_matches_surface rebuilds the offscreen target (and dependent bind group) when the sizes diverge.

implOffscreenPostExample{fnensure_offscreen_matches_surface(&mutself,render_context:&mutRenderContext,){let offscreen_id = matchself.offscreen_target{Some(id) => id,None => return,};let post_layout = matchself.post_layout.as_ref(){Some(layout) => layout,None => return,};let bind_group_id = matchself.post_bind_group{Some(id) => id,None => return,};let surface_size = render_context.surface_size();let target_size =
render_context.get_offscreen_target(offscreen_id).size();if target_size == surface_size {return;}let new_target = matchOffscreenTargetBuilder::new().with_color(
render_context.surface_format(),
surface_size.0,
surface_size.1,).with_label("offscreen-post-target").build(render_context.gpu()){Ok(target) => target,Err(error) => {
logging::error!("Failed to rebuild offscreen target: {:?}", error);return;}};ifletErr(error) =
render_context.replace_offscreen_target(offscreen_id, new_target){
logging::error!("Failed to replace offscreen target: {}", error);return;}let offscreen_ref = render_context.get_offscreen_target(offscreen_id);let sampler = SamplerBuilder::new().linear_clamp().with_label("offscreen-post-sampler").build(render_context.gpu());let new_bind_group = BindGroupBuilder::new().with_layout(post_layout).with_texture(1, offscreen_ref.color_texture()).with_sampler(2,&sampler).build(render_context.gpu());ifletErr(error) =
render_context.replace_bind_group(bind_group_id, new_bind_group){
logging::error!("Failed to replace post bind group: {}", error);}}}

This replacement path rebuilds both the offscreen target and the bind group so the post pass samples the updated texture view after a resize.

Step 8 — Main Entry Point

Start the runtime using the example’s main.

fnmain(){let runtime = ApplicationRuntimeBuilder::new("Offscreen Post Process").with_window_configured_as(move |window_builder| {return window_builder
.with_dimensions(1200,600).with_name("Offscreen Post Process");}).with_component(move |runtime,component:OffscreenPostExample| {return(runtime, component);}).build();start_runtime(runtime);}

The resulting program opens a window, renders into an offscreen texture, and presents the sampled result to the surface each frame.

Validation

  • Build: cargo build --workspace
  • Run: cargo run -p lambda-demos-render --bin offscreen_post
  • Expected behavior:
    • A window opens and shows a solid-color triangle.
    • Resizing the window preserves the rendering without stretching artifacts.

Notes

  • Format matching
    • The offscreen target and the offscreen pass/pipeline MUST agree on the color format. Use render_context.surface_format() to match the window.
  • Bindings
    • BindGroupLayoutBuilder::with_sampled_texture(1) MUST match layout (set = 0, binding = 1) in the fragment shader.
    • The sampler binding index MUST also match (binding = 2).
  • Resize
    • Replacing the offscreen target invalidates the previous texture view. Rebuild the bind group after calling render_context.replace_offscreen_target.
    • Viewports are built from width.max(1) and height.max(1) to avoid zero-size viewport creation during resize.

Conclusion

This tutorial demonstrates a minimal multi-pass post path in lambda-rs: render into an offscreen texture, then sample that texture to the surface using a fullscreen quad and a bind group.

Exercises

  • Exercise 1: Apply a post effect
    • Modify the post fragment shader to invert colors or apply a grayscale conversion before writing fragment_color.
  • Exercise 2: Render offscreen at half resolution
    • Create the offscreen target at width / 2, height / 2 and adjust UVs or sampling to upsample to the surface.
  • Exercise 3: Add a debug border
    • Draw a second quad in the post pass that outlines the viewport to validate scissor and viewport behavior.
  • Exercise 4: Add MSAA to the offscreen target
    • Enable multi-sampling on the offscreen target and ensure the pipeline and pass use the same sample count.
  • Exercise 5: Add a second post pass
    • Render the first offscreen result into a second offscreen target, then sample the second target to the surface.
  • Exercise 6: Sample with nearest filtering
    • Replace .linear_clamp() with nearest sampling and compare the result when rendering offscreen at reduced resolution.

Changelog

  • 0.2.2 (2026-02-05): Update demo commands and reference paths for demos/.
  • 0.2.1 (2026-01-16): Replace on_event resize handling with event_mask() and on_window_event.
  • 0.2.0 (2025-12-31): Update the tutorial to match the example’s Default, on_attach, on_render, and resize replacement structure.
  • 0.1.0 (2025-12-29): Initial draft aligned with demos/render/src/bin/offscreen_post.rs.