Part 1 - Project Setup and Your First Window Image

#Part 1 - Project Setup and Your First Window

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

Welcome to the first part of this series on graphics programming with wgpu in Rust! By the end of this article, you’ll have a Rust program that opens a window and responds to input events. It doesn’t draw anything yet, but it’s the skeleton that everything else will hang from.

What is wgpu?

wgpu is a Rust graphics library that talks to your GPU using whichever native API your platform provides — Vulkan on Linux, Metal on macOS, DirectX 12 on Windows, and WebGPU in the browser. You write one codebase and it runs everywhere.

This is different from older graphics APIs like OpenGL, which abstracts the GPU in a way that’s easier to start with but hides how modern hardware actually works. wgpu is lower-level, which means more setup upfront, but you’ll understand exactly what the GPU is doing at every step.

The Event Loop

Every graphical application is built around an event loop — a loop that sits and waits for things to happen (key presses, mouse moves, window resizes, redraw requests) and dispatches them to your code. winit is the Rust library that gives us this, along with the window itself.

Setting Up the Project

Create a new Rust project:

cargo new graphics-with-wgpu-in-rust
cd graphics-with-wgpu-in-rust

Add our dependencies to Cargo.toml:

Cargo.toml
[package]
name = "graphics-with-wgpu-in-rust"
version = "0.1.0"
edition = "2024"

[dependencies]
wgpu = "29.0.3"
winit = "0.29.1"
pollster = "0.4.0"
env_logger = "0.11"
log = "0.4"
  • wgpu — the GPU library we’ll use throughout the series
  • winit — creates windows and delivers input events
  • pollster — runs async code synchronously (wgpu’s initialisation is async; we’ll need this in Part 2)
  • env_logger / log — structured logging that wgpu uses internally; useful for debugging

Opening a Window

Replace src/main.rs with:

src/main.rs
use winit::{
    event::{Event, WindowEvent},
    event_loop::{ControlFlow, EventLoop},
    window::WindowBuilder,
};

fn main() {
    // Wire up logging — wgpu reports errors and diagnostics
    // through the `log` crate, so this makes them visible.
    env_logger::init();

    // The event loop is the heart of the application. The OS delivers
    // events (input, resize, close requests) through it to our code.
    let event_loop = EventLoop::new().unwrap();

    // Poll = keep the loop spinning continuously instead of sleeping
    // until the next event — what we want for rendering every frame.
    event_loop.set_control_flow(ControlFlow::Poll);

    // Create an 800x600 window attached to the event loop. The
    // underscore keeps it alive without an "unused variable" warning.
    let _window = WindowBuilder::new()
        .with_title("Graphics with wgpu")
        .with_inner_size(winit::dpi::LogicalSize::new(800u32, 600u32))
        .build(&event_loop)
        .unwrap();

    // Hand control to the event loop. Our closure runs for every event;
    // `elwt` is our handle for controlling the loop itself.
    event_loop
        .run(move |event, elwt| match event {
            // The user clicked the window's close button — shut down.
            Event::WindowEvent {
                event: WindowEvent::CloseRequested,
                ..
            } => elwt.exit(),
            // Ignore everything else for now.
            _ => {}
        })
        .unwrap();
}

ControlFlow::Poll tells the event loop to call our closure continuously rather than waiting for an event — exactly what a rendering loop needs, since we’ll want to draw a frame on every iteration.

When the close button is clicked, we receive a CloseRequested event and call elwt.exit() to shut the loop down cleanly. elwt is the event loop window target — our handle for controlling the loop from inside the closure.

One subtlety: the window variable is named _window because nothing reads it yet. The underscore silences the unused-variable warning while keeping the window alive — if we dropped it, the window would close immediately.

The move keyword doesn’t capture anything yet — closures only take ownership of variables they actually use, and ours uses none. It becomes important in later parts, when the handler needs the window and GPU state.

Running It

cargo run

You should see an 800×600 window with a black background and the title “Graphics with wgpu”. The window responds to the close button. That’s it for Part 1 — a small amount of code, but the scaffolding every graphics program needs.

What’s Next

In Part 2, we initialise the wgpu pipeline and learn how to clear the screen to a colour. That’s where the GPU gets involved for the first time.

Further reading:

FAQ

What is wgpu used for? wgpu is used for GPU-accelerated graphics and compute in Rust. It’s the foundation for game engines like Bevy, scientific visualisation tools, and anything that needs to talk to the GPU from Rust.

Does wgpu work on macOS? Yes. wgpu translates to Metal on macOS automatically. The same code runs on Windows (DirectX 12 / Vulkan), Linux (Vulkan), and macOS (Metal) without changes.

Why do we need an event loop? The operating system delivers window events (close, resize, keyboard, mouse) asynchronously. An event loop gives you a single place to receive and respond to all of them without polling manually.

What is ControlFlow::Poll? It tells winit to call your event handler continuously rather than sleeping between events. For a rendering application you want to draw every frame, so Poll is the right choice. Interactive tools that only need to update on user input would use ControlFlow::Wait to save CPU.