Hi! Trying to learn WGPU without a deep graphics background,like many complex topics, can be extremely challenging. It doesn’thelp that much of the content out there is written by peoplealready with years of experience in the field. I wrote the belowtutorial to get absolute beginners started with graphicsprogramming, but if you already have experience with Rust andgraphics, I would check out the awesome learn-wgpu tutorial whichwill go faster and cover more topics
That said, learning graphics takes time regardless of whattutorial you use, so feel free to skip around and come back tosections.
Background
So you want to get the GPU to do something...
To get your GPU device to do anything, you need to go throughsome OS/device specific APIs to tell your GPU what to do. In thepast, you were required to do a lot of work yourself to supportvarious APIs, even if your physical hardware is the same. That’swhere WGPU comes in.
WGPU is across-platform graphics API written in Rust that’s an abstractionlayer above native APIs like Vulkan, Metal, D3D12, and someothers.
WGPU is based on the WebGPU spec which defines agraphics API that will be exposed in browsers. In fact Firefox uses WGPU for its WebGPU backend. WebGPU will beimplemented eventually inmodern browsers, but for now it’s guarded behind some flags. Thegood news is that once WebGPU ships, the programs you write in WGPUwill be able to run natively in the browser!
You might have heard of OpenGL before coming here, or used itpreviously. OpenGL is another cross-platform API that is much morewidely used than WGPU, but has some limitations since it’s ahigher-level and older API. WGPU is designed to give developersmore control and better match modern hardware.
WebGPU (and by design WGPU) also better matches the design ofmore modern graphics APIs like Vulkan and Metal. The tradeoff isthat these APIs provide a lower-level interface which can beverbose and fragile at times. But don’t be scared! Even though theAPI is verbose, it’s usually not actually doing anything supercomplex that we have to worry about under the hood.
A note on Rust
WGPU is a library written in Rust and so is this tutorial. Iimagine that you’re probably unfamiliar with Rust or haven’t usedit widely so I’ll try to walk through some of the syntax here aswell. This should be relatively easy to follow for anyone with someintermediate programming experience.
First steps
To get started, let’s install Rust! Go to https://www.rust-lang.org/tools/installand follow the instructions for downloading. rustup
should install a bunch of tools (in ~/.cargo/bin
)which should automatically be added to your PATH
environment variable. The main tool we’ll use in this tutorial iscargo
.
cargo
is a package manager for Rust which we canalso use for compiling and running Rust code.
First off, let’s make a package to hold our code. Run
cargo init wgpu-intro
in the directory of your choice to create thewgpu-intro
package.
You should see two files in the wgpu-intro
directory
.├── Cargo.toml # Meta file for the cargo package manager└── src └── main.rs # File with the main() function
First, open the Cargo.toml
and add these lines tothe [dependencies]
section
[dependencies]winit = "0.26.0"wgpu = "0.12.0"env_logger = "0.9"log = "0.4"pollster = "0.2"
We won’t use all these dependencies now, but they’ll come inhandy later. Here’s a little bit of info on how we’re going to useeach package.
winit
is used as thecross-platform abstraction of window management. This allows us toeasily make windows and handle window events (such as key presses)without having to do OS-specific work.
wgpu
is the the WGPUlibrary of course! This library holds all the functions and typesnecessary for communicating with the GPU and translating the APIcalls into the actual Vulkan/Metal/etc. commands.
env_logger
andlog
are used to providewgpu
a way tooutput useful messages to the console instead of just exitingwithout any output.
pollster
is used torun an async function we’ll talk about later to completion. Rusthas a pretty interesting model of concurrency that requires us to use thisto await completion of a future in the main function.
In the next section we’ll setup everything but for now run
cargo run
anywhere in the wgpu-intro
directory and you shouldsee the dependencies downloaded and a “Hello, world!” messageprinted.
Creating a window
Before we can show anything on the screen, we first need tocreate a window. Since we’re using winit
as ourwindowing library, we’ll import some utilities and call some setupfunctions in our main.rs
file.
use winit::{ event::*, event_loop::{ControlFlow, EventLoop}, window::WindowBuilder,};fn main() { env_logger::init(); // Necessary for logging within WGPU let event_loop = EventLoop::new(); // Loop provided by winit for handling window events let window = WindowBuilder::new().build(&event_loop).unwrap(); // Opens the window and starts processing events (although no events are handled yet) event_loop.run(move |event, _, control_flow| {});}
If you cargo run
now, you should see a window popupon your screen like
Great! We now have a window ready for us to use. You can useCtrl+C
to kill the process in your terminal since thewindow controls will not work. Let’s handle a little user input tomake it easy to close the window. Modify the event loop to looklike this
...event_loop.run(move |event, _, control_flow| { *control_flow = ControlFlow::Wait; match event { Event::WindowEvent { event: WindowEvent::CloseRequested, window_id, } if window_id == window.id() => *control_flow = ControlFlow::Exit, Event::WindowEvent { event: WindowEvent::KeyboardInput { input, .. }, window_id, } if window_id == window.id() => { if input.virtual_keycode == Some(VirtualKeyCode::Escape) { *control_flow = ControlFlow::Exit } } _ => (), }});
We just added a body to the event_loop closure to handle theCloseRequested
(hitting the X
button onthe window) and the escape key which you can use to close thewinit
window.
It took me a little bit to get used to the Rust-y way of writingthings so feel free to take some time to digest all of this. Somequestions I had with some answers are below:
Why do we need to usemove
and what does it dohere?
move
is used to capture a closure’s environment by value — meaning thatthe variables defined outside the closure can outlive the contextwhere those variables are defined. Also, the parameter torun
is defined to be 'static
which causes compiler errors to let usknow that this behavior occurs.
What is the if
statement doing after thematch
value?
This is called a guard and can be used to further filter the arm based on theconditional given. It’s usually used when destructuring structslike the event
struct is here.
If you’re still a little lost, I highly recommend taking a lookat https://doc.rust-lang.org/rust-by-example/which is an awesome tutorial on Rust. Otherwise, feel free tocontinue going. I think this was the most confusing syntax-wisepart for me.
Anyways, this gets us through the winit
setup thatwe need to make a window. Now let’s use WGPU to do something!
Let’s do some rendering!
Like I said before, the WGPU API is very verbose, but don’tworry — just because it’s a lot of lines, doesn’t mean it’s doinganything super complicated. Additionally, the API uses some jargonthat may not mean exactly what you’re used to, so feel free to lookup anything in the WGPUdocs or the WebGPUspec for help. Both can give some context on why things onnamed the way they are.
First, let’s setup a connection to the GPU.
fn main() { ... let window = WindowBuilder::new().build(&event_loop).unwrap(); let instance = wgpu::Instance::new(wgpu::Backends::all()); let surface = unsafe { instance.create_surface(&window) }; let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::default(), compatible_surface: Some(&surface), force_fallback_adapter: false, })) .unwrap(); let (device, queue) = pollster::block_on(adapter.request_device( &wgpu::DeviceDescriptor { label: None, features: wgpu::Features::empty(), limits: wgpu::Limits::default(), }, None, // Trace path )) .unwrap(); let size = window.inner_size(); surface.configure(&device, &wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: surface.get_preferred_format(&adapter).unwrap(), width: size.width, height: size.height, present_mode: wgpu::PresentMode::Fifo, }); ...}
There’s a lot going on here so take some time to internalizewhat is being done and how the pieces connect together. In summary,we are setting up a connection between the window and GPU device sowe can begin to send commands to the GPU.
wgpu::Instance::new(wpgu::Backends::all())
createsan instance of the WPGU API for all backends. Backendsdefine the actual API (Vulkan, Metal, DX11, etc.) that WGPU selectsto make calls.instance.create_surface(&window)
gets asurface from the window that WGPU can make calls to draw into.Under the hood it uses https://crates.io/crates/raw-window-handleto provide the interoperability betweenwgpu
andwinit
libraries.unsafe
is used heresince theraw_window_handle
must be valid and remainvalid for the lifetime of the surface.pollster::block_on(instance.request_adapter(...))
waits on the WGPU API to get an adapter. We use pollster here topoll the async request adapter function since the main function issynchronous. You can think of requesting an adapter as anintermediate step between getting a reference to the actual device.The options here are pretty self-explanatory.pollster::block_on(adapter.request_device(...))
gets the actualdevice
which represents the GPU onyour system. Also, it returns aqueue
which we’ll uselater to send draw calls and other commands to thedevice
. Be aware thatfeatures
here can be used to define device specific features that you maywant to enable in the future.surface.configure(&device, ...);
makes theconnection between the surface (in the window) and the GPU devicewith some configuration. With this line, the surface initialized toreceive input from the device and draw it on screen.
Now we have a window, wgpu instance, configured surface, anddevice with a queue — all the tools needed to setup a render, butwe’re not actually doing any rendering yet. Let’s fix that. Add thefollowing lines inside your event loop.
fn main() { ... match event { ... Event::RedrawRequested(_) => { let output = surface.get_current_texture().unwrap(); let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = 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: &[wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.1, // Pick any color you want here g: 0.9, b: 0.3, a: 1.0, }), store: true, }, }], depth_stencil_attachment: None, }); } // submit will accept anything that implements IntoIter queue.submit(std::iter::once(encoder.finish())); output.present(); }, ...}
Again, there’s a lot of words that are pretty abstract here.Looking up any terms you’re unfamiliar with can help you understandwhat’s really going on here.
Event::RedrawRequested
occurs when the windowrequests a redraw. This only happens once for now since the windowdoes this once itself, but in the future we’ll have to trigger itif we want to draw something else.surface.get_current_texture()
gets the nextsurface texture, which is a wrapper around the actualtexture, to be drawn to the window.output.texture.create_view(...)
gets the nextTextureView that describes the actual texture to be draw to thewindow.device.create_command_encoder(...)
initializes acommand encoder for encoding operations to the GPU. Sendingoperations to the GPU in WGPU involves encoding and then queuing upoperations for the GPU to perform.encoder.begin_render_pass(...)
creates a RenderPass.You can think of a render pass as a series of operations that getqueued up and then submitted to the GPU usingqueue.submit
. In this case, we only have one operationwhich is to clear theview
usingLoadOp::Clear
.queue.submit(...)
actually submits the work to theGPU. Before this, any calls likebegin_render_pass
arenot actually triggering any processing.output.present()
schedules the texture(written inthesubmit
call) to be presented on thesurface
and subsequently on your screen.
Now for the exciting part, the moment we’ve all been waitingfor, do a cargo run
and you should see this
Amazing. It might not look like much but you’re now actuallytelling your GPU to to do something, which is pretty cool!
Now let’s do something fun with this. We’ll create a smoothtransition to from blue 0.0
to blue 1.0
and back again using some simple logic.
let mut blue_value = 0; // Newlet mut blue_inc = 0; // Newevent_loop.run(move |event, _, control_flow| { ... load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.1, g: 0.9, b: blue_value, // New a: 1.0, }), ... output.present(); // New blue_value += (red_inc as f64) * 0.001; if blue_value > 1.0 { blue_inc = -1; blue_value = 1.0; } else if blue_value < 0.0 { blue_inc = 1; blue_value = 0.0; } }, // New Event::MainEventsCleared => { window.request_redraw(); } ...
Note: Notice the MainEventsCleared
lines. Before we were actually only submitting one render passsince we have to trigger a redraw due to how the windowing libraryis implemented.
You should see a smooth transition from green to light blue andback again.
But we’re not done yet, we haven’t even drawn anythinginteresting. Next up, we’ll learn about shaders and pipelines inorder to draw a humble triangle.
Based on the learn-wgpututorial