How to Use wgpu for GPU Programming in Rust

Add the `wgpu` crate to your `Cargo.toml` and initialize an instance to access the GPU.

The GPU bottleneck

You've written a simulation that runs fine with a hundred particles. You crank it to a million, and your laptop fan sounds like a jet engine. The CPU is drowning. You know the GPU has thousands of cores sitting idle, begging for work. You want to offload the math, but the last time you touched graphics APIs, you spent three days fighting driver crashes and a thousand lines of boilerplate just to draw a triangle. Rust has a solution that cuts through the noise. wgpu gives you safe, cross-platform access to modern GPUs without the headache of Vulkan, Metal, or DirectX directly.

What wgpu actually does

Think of your computer like a factory. The CPU is the foreman. It's smart, flexible, and handles complex decisions, but it can only talk to one worker at a time. The GPU is the assembly floor. It has thousands of identical workers who are great at doing the same simple task over and over, but they can't make decisions on their own. They just need instructions and data.

wgpu is the standardized order form. You fill out the form in Rust, and wgpu translates it to whatever language the factory floor speaks, whether that's Vulkan on Linux, Metal on Mac, or DirectX on Windows. You write one set of instructions, and wgpu handles the dialects. The crate also enforces safety rules that prevent you from sending malformed commands that would crash the driver. You get the raw power of the GPU with the compile-time guarantees of Rust.

Minimal setup

Add wgpu to your dependencies. You also need an async runtime because GPU initialization involves talking to the OS, which can block. The community standard is tokio.

[dependencies]
wgpu = "0.20.1"
tokio = { version = "1", features = ["full"] }

The initialization sequence is short but strict. You create an instance, request an adapter, and then request a device.

use wgpu::util::DeviceExt;

#[tokio::main]
async fn main() {
    // Create the wgpu instance. This is your entry point to the GPU world.
    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::default());

    // Request an adapter. This asks the OS for a GPU that can do what you need.
    // The await is crucial because driver initialization can block.
    let adapter = instance
        .request_adapter(&wgpu::RequestAdapterOptions::default())
        .await
        .expect("No GPU found");

    // Get the device and queue. The device configures the GPU, the queue sends work.
    let (device, queue) = adapter
        .request_device(&wgpu::DeviceDescriptor::default(), None)
        .await
        .expect("Failed to create device");
}

Convention aside: import wgpu::util::DeviceExt. This brings in helper methods like queue.write_buffer and queue.write_texture. The core API is minimal by design. The util crate fills in the ergonomic gaps without bloating the main namespace.

The initialization chain

The hierarchy exists for a reason. You can't create a buffer without a device, and you can't get a device without an adapter. Respect the chain.

The Instance is the global handle. It manages the connection to the graphics driver. You create one per application. Use the instance to find an Adapter. An adapter represents a physical or logical GPU. Your system might have multiple GPUs. The adapter lets you pick one based on power preference or features.

Once you have an adapter, you request a Device. The device is where you create resources like buffers, textures, and pipelines. The device holds the state for your GPU context. Finally, you get a Queue. The queue is how you submit commands to the GPU. You can't draw or compute anything without a queue. The queue accepts command buffers and schedules them for execution.

If you drop the device early, the queue becomes invalid. Any attempt to submit work will fail. Keep the device alive as long as you're using the GPU. The borrow checker helps here. The queue borrows from the device, so Rust won't let you drop the device while the queue is in use.

Sending work to the GPU

Initialization is just the handshake. Real work happens when you send data and commands. Here's a pattern for a compute shader setup. You create a buffer, write data to it, and submit a command to process that data.

// Create a buffer for data.
// STORAGE allows shaders to read and write.
// COPY_DST allows the CPU to write via the queue.
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
    label: Some("Data Buffer"),
    size: 1024,
    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
    mapped_at_creation: false,
});

// Write data to the buffer from the CPU.
queue.write_buffer(&buffer, 0, &[1, 2, 3, 4]);

// Create a shader module using WGSL.
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
    label: Some("Compute Shader"),
    source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
        @compute @workgroup_size(64)
        fn main(@builtin(global_invocation_id) global_id : vec4<u32>) {
            // Each invocation processes one element.
            let index = global_id.x;
            // Shader logic goes here.
        }
    "#)),
});

// Create the compute pipeline.
let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
    label: Some("Compute Pipeline"),
    layout: wgpu::PipelineLayoutDescriptor::default(),
    module: &shader,
    target_entry_point: Some("main"),
});

// Record commands into an encoder.
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
    label: Some("Main Encoder"),
});

{
    // Begin a compute pass.
    let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
        label: Some("Compute Pass"),
        timestamp_writes: None,
    });

    // Set the pipeline and dispatch work.
    compute_pass.set_pipeline(&compute_pipeline);
    compute_pass.dispatch_workgroups(1, 1, 1);
}

// Submit the encoded commands to the GPU.
queue.submit(std::iter::once(encoder.finish()));

Convention aside: always add label to your resources. The wgpu debugger and GPU profilers rely on these labels to help you identify bottlenecks. A buffer named "Data Buffer" is infinitely easier to debug than an anonymous handle.

The CommandEncoder works like a tape recorder. You record commands into it, and when you're done, you hand the tape to the queue to play back. This batch approach reduces driver overhead. Sending one command at a time is slow. Recording a batch and submitting it once is fast.

WGSL (WebGPU Shading Language) is the standard shader language for wgpu. It's text-based, readable, and portable. You can use SPIR-V if you have a compiler that outputs it, but WGSL is the community preference for pure Rust projects. Stick to WGSL unless you have a specific reason to bring in an external shader compiler.

Pitfalls and errors

GPU programming introduces new failure modes. The compiler catches some, but others happen at runtime.

If you try to use wgpu without an async runtime, the compiler rejects you. You'll see E0277 (trait bound not satisfied) because wgpu functions return Future types that need an executor. You need #[tokio::main] or a similar attribute to drive the async machinery.

The adapter request can return None. This happens if the system has no GPU or if the driver is broken. Using unwrap here panics your application. Use expect with a clear message, or handle the fallback gracefully. A silent crash is worse than a helpful error message.

Shader compilation errors don't show up in the Rust compiler. They happen when you create the shader module. wgpu catches these errors, but you need to handle them. If you don't set an error handler, wgpu will panic on the first shader error. Set a handler early.

device.on_lost(Box::new(|reason| {
    println!("Device lost: {:?}", reason);
}));

Convention aside: set the error handler before you create a single buffer. Debugging a lost device without a callback is a guessing game. The on_lost callback tells you if the driver crashed or if the device was dropped.

Dropping the device while the queue is still in use is impossible due to Rust's borrow checker. You'll get E0505 (cannot move out of borrowed content) if you try to move the device while the queue holds a reference. This is a feature, not a bug. The borrow checker prevents use-after-free errors that would crash the driver in C++.

When to use wgpu

Use wgpu when you need cross-platform GPU access without maintaining separate codebases for Windows, Mac, and Linux. Use wgpu when you want safety guarantees that prevent driver crashes and memory leaks in your graphics code. Use wgpu when you are building a game engine, a data visualization tool, or a compute-heavy application that benefits from parallel processing.

Use raw Vulkan or Metal when you require absolute minimum overhead and are willing to write platform-specific code for every target. Reach for the CPU when your workload involves complex branching logic or low-latency single-threaded tasks that don't parallelize well.

wgpu is the pragmatic choice for most Rust projects. The abstraction cost is negligible compared to the development speed gain. Only go raw if you have a profiler proving wgpu is the bottleneck and you have the resources to maintain multiple backends.

Where to go next