Part 2 - The Render Pipeline Image

#Part 2 - The Render Pipeline

Elliot Forbes Elliot Forbes · May 29, 2026 · 5 min read

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:

ObjectWhat it is
InstanceEntry point to wgpu; discovers GPUs on the system
SurfaceThe window’s drawable area
AdapterA handle to a specific GPU that can render to the surface
DeviceAn open connection to the GPU
QueueWhere you submit command lists
SurfaceConfigurationTells 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:

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.