An absolute beginners guide to WGPU
Hi! Trying to learn WGPU without a deep graphics background, like many complex topics, can be extremely challenging. It doesn’t help that much of the content out there is written by people already with years of experience in the field. I wrote the below tutorial to get absolute beginners started with graphics programming, but if you already have experience with Rust and graphics, I would check out the awesome learn-wgpu tutorial which will go faster and cover more topics
That said, learning graphics takes time regardless of what tutorial you use, so feel free to skip around and come back to sections.
Background
So you want to get the GPU to do something...
To get your GPU device to do anything, you need to go through some OS/device specific APIs to tell your GPU what to do. In the past, you were required to do a lot of work yourself to support various APIs, even if your physical hardware is the same. That’s where WGPU comes in.
WGPU is a cross-platform graphics API written in Rust that’s an abstraction layer above native APIs like Vulkan, Metal, D3D12, and some others.
WGPU is based on the WebGPU spec which defines a graphics API that will be exposed in browsers. In fact Firefox uses WGPU for its WebGPU backend. WebGPU will be implemented eventually in modern browsers, but for now it’s guarded behind some flags. The good news is that once WebGPU ships, the programs you write in WGPU will be able to run natively in the browser!
You might have heard of OpenGL before coming here, or used it previously. OpenGL is another cross-platform API that is much more widely used than WGPU, but has some limitations since it’s a higher-level and older API. WGPU is designed to give developers more control and better match modern hardware.
WebGPU (and by design WGPU) also better matches the design of more modern graphics APIs like Vulkan and Metal. The tradeoff is that these APIs provide a lower-level interface which can be verbose and fragile at times. But don’t be scared! Even though the API is verbose, it’s usually not actually doing anything super complex 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. I imagine that you’re probably unfamiliar with Rust or haven’t used it widely so I’ll try to walk through some of the syntax here as well. This should be relatively easy to follow for anyone with some intermediate programming experience.
First steps
To get started, let’s install Rust! Go to https://www.rust-lang.org/tools/install
and 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 is
cargo
.
cargo
is a package manager for Rust which we can
also 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 the
wgpu-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 to
the [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 in handy later. Here’s a little bit of info on how we’re going to use each package.
winit
is used as the cross-platform abstraction of window management. This allows us to easily make windows and handle window events (such as key presses) without having to do OS-specific work.
wgpu
is the the WGPU library of course! This library holds all the functions and types necessary for communicating with the GPU and translating the API calls into the actual Vulkan/Metal/etc. commands.
env_logger
andlog
are used to providewgpu
a way to output useful messages to the console instead of just exiting without any output.
pollster
is used to run an async function we’ll talk about later to completion. Rust has a pretty interesting model of concurrency that requires us to use this to 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 should
see the dependencies downloaded and a “Hello, world!” message
printed.
Creating a window
Before we can show anything on the screen, we first need to
create a window. Since we’re using winit
as our
windowing library, we’ll import some utilities and call some setup
functions 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 popup
on your screen like
Great! We now have a window ready for us to use. You can use
Ctrl+C
to kill the process in your terminal since the
window controls will not work. Let’s handle a little user input to
make it easy to close the window. Modify the event loop to look
like 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 the
CloseRequested
(hitting the X
button on
the window) and the escape key which you can use to close the
winit
window.
It took me a little bit to get used to the Rust-y way of writing things so feel free to take some time to digest all of this. Some questions I had with some answers are below:
Why do we need to use
move
and what does it do
here?
move
is used to capture a closure’s environment by value — meaning that
the variables defined outside the closure can outlive the context
where those variables are defined. Also, the parameter to
run
is defined to be
'static
which causes compiler errors to let us
know that this behavior occurs.
What is the if
statement doing after the
match
value?
This is called a
guard and can be used to further filter the arm based on the
conditional given. It’s usually used when destructuring structs
like the event
struct is here.
If you’re still a little lost, I highly recommend taking a look at https://doc.rust-lang.org/rust-by-example/ which is an awesome tutorial on Rust. Otherwise, feel free to continue going. I think this was the most confusing syntax-wise part for me.
Anyways, this gets us through the winit
setup that
we 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’t worry — just because it’s a lot of lines, doesn’t mean it’s doing anything super complicated. Additionally, the API uses some jargon that may not mean exactly what you’re used to, so feel free to look up anything in the WGPU docs or the WebGPU spec for help. Both can give some context on why things on named 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 internalize what is being done and how the pieces connect together. In summary, we are setting up a connection between the window and GPU device so we can begin to send commands to the GPU.
wgpu::Instance::new(wpgu::Backends::all())
creates an instance of the WPGU API for all backends. Backends define the actual API (Vulkan, Metal, DX11, etc.) that WGPU selects to make calls.instance.create_surface(&window)
gets a surface from the window that WGPU can make calls to draw into. Under the hood it uses https://crates.io/crates/raw-window-handle to provide the interoperability betweenwgpu
andwinit
libraries.unsafe
is used here since theraw_window_handle
must be valid and remain valid 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 to poll the async request adapter function since the main function is synchronous. You can think of requesting an adapter as an intermediate 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 on your system. Also, it returns aqueue
which we’ll use later to send draw calls and other commands to thedevice
. Be aware thatfeatures
here can be used to define device specific features that you may want to enable in the future.surface.configure(&device, ...);
makes the connection between the surface (in the window) and the GPU device with some configuration. With this line, the surface initialized to receive input from the device and draw it on screen.
Now we have a window, wgpu instance, configured surface, and device with a queue — all the tools needed to setup a render, but we’re not actually doing any rendering yet. Let’s fix that. Add the following 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 understand what’s really going on here.
Event::RedrawRequested
occurs when the window requests a redraw. This only happens once for now since the window does this once itself, but in the future we’ll have to trigger it if we want to draw something else.surface.get_current_texture()
gets the next surface texture, which is a wrapper around the actual texture, to be drawn to the window.output.texture.create_view(...)
gets the next TextureView that describes the actual texture to be draw to the window.device.create_command_encoder(...)
initializes a command encoder for encoding operations to the GPU. Sending operations to the GPU in WGPU involves encoding and then queuing up operations 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 get queued up and then submitted to the GPU usingqueue.submit
. In this case, we only have one operation which is to clear theview
usingLoadOp::Clear
.queue.submit(...)
actually submits the work to the GPU. Before this, any calls likebegin_render_pass
are not actually triggering any processing.output.present()
schedules the texture(written in thesubmit
call) to be presented on thesurface
and subsequently on your screen.
Now for the exciting part, the moment we’ve all been waiting
for, do a cargo run
and you should see this
Amazing. It might not look like much but you’re now actually telling your GPU to to do something, which is pretty cool!
Now let’s do something fun with this. We’ll create a smooth
transition to from blue 0.0
to blue 1.0
and back again using some simple logic.
let mut blue_value = 0; // New
let mut blue_inc = 0; // New
event_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 pass
since we have to trigger a redraw due to how the windowing library
is implemented.
You should see a smooth transition from green to light blue and back again.
But we’re not done yet, we haven’t even drawn anything interesting. Next up, we’ll learn about shaders and pipelines in order to draw a humble triangle.
Based on the learn-wgpu tutorial