#Part 2 - The Render Pipeline
In Part 1 we opened a window. In this part, we connect that window to the GPU and draw our first frame — a solid colour fill. It’s a modest result, but it means the full rendering pipeline is working end-to-end.
How the GPU Pipeline Works
Before writing code, here’s the mental model you need.
Your CPU sends commands to the GPU. It doesn’t tell the GPU to draw individual pixels — it records a list of operations into a command encoder, then submits that list to a queue. The GPU processes the queue asynchronously, writing pixels into a texture (a grid of colour data). When it’s done, that texture is presented to the screen.
The objects you’ll create:
| Object | What it is |
|---|---|
Instance | Entry point to wgpu; discovers GPUs on the system |
Surface | The window’s drawable area |
Adapter | A handle to a specific GPU that can render to the surface |
Device | An open connection to the GPU |
Queue | Where you submit command lists |
SurfaceConfiguration | Tells the surface its size, format, and presentation mode |
The State Struct
wgpu initialisation is async and produces several objects that all need to live for the duration of the program. We put them in a State struct so we can pass them around together.
use std::sync::Arc;
use winit::{
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoop},
window::{Window, WindowBuilder},
};
struct State<'window> {
surface: wgpu::Surface<'window>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
size: winit::dpi::PhysicalSize<u32>,
window: Arc<Window>,
}
The 'window lifetime tells Rust that State must not outlive the window it renders into — a safety guarantee the compiler enforces for us.
Initialising wgpu
impl<'window> State<'window> {
async fn new(window: Arc<Window>) -> State<'window> {
let size = window.inner_size();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
let surface = instance.create_surface(window.clone()).unwrap();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.unwrap();
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
},
None,
)
.await
.unwrap();
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width,
height: size.height,
present_mode: surface_caps.present_modes[0],
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
State { surface, device, queue, config, size, window }
}
We request an adapter compatible with our surface, then open a Device and Queue from it. The SurfaceConfiguration tells wgpu the pixel format, size, and how to time frame presentation.
Resize and Render
fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
self.surface.configure(&self.device, &self.config);
}
}
fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
let output = self.surface.get_current_texture()?;
let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("Render Encoder") }
);
{
let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.1, g: 0.2, b: 0.3, a: 1.0 }),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
}
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
Ok(())
}
}
The inner block around _render_pass matters: encoder.finish() can only be called after the render pass is dropped. Rust’s ownership rules enforce this — if you try to call finish() while the pass is still alive, the compiler refuses to compile.
Wiring Up main
fn main() {
env_logger::init();
let event_loop = EventLoop::new().unwrap();
event_loop.set_control_flow(ControlFlow::Poll);
let window = Arc::new(
WindowBuilder::new()
.with_title("Graphics with wgpu")
.with_inner_size(winit::dpi::LogicalSize::new(800u32, 600u32))
.build(&event_loop)
.unwrap(),
);
let mut state = pollster::block_on(State::new(window.clone()));
event_loop.run(move |event, elwt| match event {
Event::WindowEvent { event, window_id } if window_id == state.window.id() => {
match event {
WindowEvent::CloseRequested => elwt.exit(),
WindowEvent::Resized(physical_size) => state.resize(physical_size),
WindowEvent::RedrawRequested => match state.render() {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost) => state.resize(state.size),
Err(wgpu::SurfaceError::OutOfMemory) => elwt.exit(),
Err(e) => eprintln!("{e:?}"),
},
_ => {}
}
}
Event::AboutToWait => state.window.request_redraw(),
_ => {}
}).unwrap();
}
pollster::block_on runs the async State::new synchronously so main stays a normal function. AboutToWait fires after all pending events are processed — the right time to request the next frame.
Running It
cargo run
You should see a dark blue-grey window. The colour (0.1, 0.2, 0.3) is RGB on a 0–1 scale. Try changing the values and rerunning to see the colour change.
What’s Next
In Part 3 we draw our first triangle — the “hello world” of graphics programming. That’s where we write our first shaders.
Further reading:
- Scope and Ownership in Rust — the
'windowlifetime andArc<Window>are both ownership concepts - Part 1 — Project Setup
FAQ
What is a render pass in wgpu? A render pass is a recording of GPU draw operations that all write to the same set of textures. You begin it, record draw calls into it, then drop it — at which point the commands are finalised in the encoder.
Why is wgpu initialisation async?
Requesting an adapter and device involves querying the OS graphics layer, which can take time. wgpu exposes this as async so applications can initialise the GPU without blocking a thread. pollster::block_on lets us run it synchronously from main.
What does SurfaceError::Lost mean?
It means the surface became invalid — usually because the window was minimised or resized before the GPU finished presenting. The fix is to reconfigure the surface with its current size, which is what state.resize(state.size) does.
Why wrap Window in Arc?
Both State and the event loop closure need to hold a reference to the window. Arc (atomically reference-counted pointer) allows shared ownership without copying the window.
Continue Learning
Part 6 - 2D Transformations
Move, rotate, and scale objects in wgpu using transformation matrices with glam — pass a 4×4 matrix as a uniform to the vertex shader and animate it each frame.
Part 5 - Textures and Image Loading
Load an image file and render it as a texture on a quad with wgpu in Rust — covers texture creation, samplers, UV coordinates, and index buffers.
Part 4 - Colors and Uniforms
Learn how to pass data from your Rust program to the GPU using uniform buffers and bind groups in wgpu — and animate a colour tint in real time.
Part 3 - Drawing Your First Triangle
Draw your first triangle with wgpu in Rust — define vertex data, upload it to the GPU with a vertex buffer, and write your first WGSL shaders.