docs: update tutorial to wgpu 29 / winit 0.30 APIs and pollster async pattern

This commit is contained in:
2026-05-31 22:29:13 -05:00
parent b557ea8a1e
commit 5a7cb22bf2

View File

@@ -27,22 +27,18 @@ code and the display server (X11 or Wayland on Linux). Think of it like `epoll`
or `kqueue` but for windows, input, and display lifecycle events instead of file or `kqueue` but for windows, input, and display lifecycle events instead of file
descriptors. descriptors.
The entire program runs on the tokio async runtime — wgpu's [adapter](concepts/GLOSSARY.md#adapter) GPU initialization (adapter and device queries) is async, while the winit event loop runs synchronously. We bridge these two execution models once at startup using `pollster::block_on`.
queries and [device](concepts/GLOSSARY.md#device) creation are async, and the
runtime is the natural home for the main event loop.
### Architecture Overview ### Architecture Overview
- **`main()` is `#[tokio::main] async fn`** — the entry point runs on the tokio - **`main()` is synchronous** — the entry point initializes logging, creates
runtime, giving us access to tokio's task scheduler and I/O facilities. the event loop, and calls `run_app()`. The entire program runs on a single
- **`tokio::spawn_blocking`** — winit's `event_loop.run_app()` is synchronous thread. No async runtime is needed for the main loop.
and owns the display server connection. Blocking the tokio runtime thread with - **`pollster::block_on()` in `resumed()`** — wgpu initialization (adapter and
an indefinite sync call would starve other tasks. We offload the blocking event
loop to a dedicated thread, then await the join handle.
- **`Handle::block_on()` in `resumed()`** — wgpu initialization (adapter and
device queries) is async, but winit's `resumed()` handler is synchronous. We device queries) is async, but winit's `resumed()` handler is synchronous. We
bridge the two execution models exactly once at startup. This initial GPU bridge the two execution models exactly once at startup using `pollster`,
setup takes ~50ms of wall time. a minimal single-threaded async executor. This initial GPU setup takes
~50ms of wall time.
- **`Arc<Window>`** — shared reference count to the window, needed because both - **`Arc<Window>`** — shared reference count to the window, needed because both
winit event handlers and wgpu [surface](concepts/GLOSSARY.md#surface) state winit event handlers and wgpu [surface](concepts/GLOSSARY.md#surface) state
must hold a reference to the same window object across the event loop must hold a reference to the same window object across the event loop
@@ -60,17 +56,16 @@ Add these to your `Cargo.toml`:
```toml ```toml
wgpu = "29" wgpu = "29"
winit = "0.30" winit = "0.30"
tokio = { version = "1", features = ["rt", "macros"] } pollster = "0.4"
bytemuck = { version = "1", features = ["derive"] } bytemuck = { version = "1", features = ["derive"] }
log = "0.4" log = "0.4"
simple_logger = "5"
``` ```
- `wgpu` — the GPU abstraction layer. Manages device lifecycles, shaders, buffers, - `wgpu` — the GPU abstraction layer. Manages device lifecycles, shaders, buffers,
pipelines, and command encoding. pipelines, and command encoding.
- `winit` — cross-platform window creation and event dispatch. Owns the display - `winit` — cross-platform window creation and event dispatch. Owns the display
server connection. server connection.
- `tokio` — async runtime for the main loop and all GPU queries. - `pollster` — minimal single-threaded async executor. Bridges wgpu's async GPU queries with synchronous winit callbacks. Polls futures to completion (~50ms) during initial GPU setup, then returns.
- `bytemuck` — zero-copy casting between Rust structs and byte slices. Required - `bytemuck` — zero-copy casting between Rust structs and byte slices. Required
for uploading vertex data to GPU buffers without manual serialization. for uploading vertex data to GPU buffers without manual serialization.
- `log` / `simple_logger` — structured logging. wgpu and winit emit diagnostic - `log` / `simple_logger` — structured logging. wgpu and winit emit diagnostic
@@ -79,6 +74,7 @@ simple_logger = "5"
### Complete Code ### Complete Code
```rust ```rust
use pollster::block_on;
use std::sync::Arc; use std::sync::Arc;
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize; use winit::dpi::LogicalSize;
@@ -86,26 +82,19 @@ use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId}; use winit::window::{Window, WindowId};
#[tokio::main] fn main() {
async fn main() {
simple_logger::init_with_level(log::Level::Debug).unwrap(); simple_logger::init_with_level(log::Level::Debug).unwrap();
let event_loop = EventLoop::new().unwrap(); let event_loop = EventLoop::new().unwrap();
let handle = tokio::Handle::current();
tokio::spawn_blocking(move || { event_loop.run_app(&mut App {
event_loop.run_app(&mut App { window: None,
handle, state: None,
window: None,
state: None,
})
}) })
.await
.unwrap(); .unwrap();
} }
struct App { struct App {
handle: tokio::Handle,
window: Option<Arc<Window>>, window: Option<Arc<Window>>,
state: Option<State>, state: Option<State>,
} }
@@ -125,11 +114,10 @@ impl ApplicationHandler<()> for App {
self.window = Some(window.clone()); self.window = Some(window.clone());
self.state = Some( self.state = Some(
self.handle block_on(async {
.block_on(async { State::new(window.clone()).await.expect("Failed to create wgpu State")
State::new(window.clone()).await.expect("Failed to create wgpu State") })
}) .expect("Failed to create wgpu State"),
.expect("Failed to create wgpu State"),
); );
} }
@@ -159,13 +147,9 @@ impl ApplicationHandler<()> for App {
} }
``` ```
> **WHY: `spawn_blocking` for winit** > **WHY: `pollster::block_on` for async GPU init**
> >
> The display server event loop must run to completion and cannot be interrupted. If we ran `run_app()` on the tokio runtime thread, no other async tasks could execute. By spawning it on a blocking thread, the tokio runtime remains free for GPU queries, driver I/O, and future background tasks. > wgpu's `request_adapter` and `request_device` query the driver over async D-Bus/Wayland/Vulkan entrypoints. These futures must be polled by a runtime executor. We use `pollster`, a minimal single-threaded async executor, to bridge wgpu's async GPU initialization with winit's synchronous `resumed()` callback. `pollster::block_on` polls the future to completion (~50ms) on the current thread, then returns the result — no background runtime, no spawn overhead, no cross-thread communication.
> **WHY: `Handle::block_on` for async GPU init**
>
> wgpu's `request_adapter` and `request_device` query the driver over async D-Bus/Wayland/Vulkan entrypoints. These futures must be polled by a runtime executor. `block_on` attaches temporarily to the runtime thread via its handle, polls the future to completion (~50ms), then returns the result.
> **WHY: `ControlFlow::Poll` for the render loop** > **WHY: `ControlFlow::Poll` for the render loop**
> >
@@ -307,7 +291,7 @@ const VERTICES: &[Vertex] = &[
impl State { impl State {
async fn new(window: Arc<Window>) -> Result<Self, String> { async fn new(window: Arc<Window>) -> Result<Self, String> {
// Step 1: Instance — connection to the graphics driver // Step 1: Instance — connection to the graphics driver
let instance = wgpu::Instance::default(); let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
// Step 2: Surface — binds our window to the GPU's swapchain // Step 2: Surface — binds our window to the GPU's swapchain
let surface = instance let surface = instance
@@ -375,12 +359,12 @@ impl State {
attributes: &[ attributes: &[
wgpu::VertexAttribute { wgpu::VertexAttribute {
offset: 0, offset: 0,
format: wgpu::VertexFormat::F32x3, format: wgpu::VertexFormat::Float32x3,
shader_location: 0, shader_location: 0,
}, },
wgpu::VertexAttribute { wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as u64, offset: std::mem::size_of::<[f32; 3]>() as u64,
format: wgpu::VertexFormat::F32x3, format: wgpu::VertexFormat::Float32x3,
shader_location: 1, shader_location: 1,
}, },
], ],
@@ -392,7 +376,7 @@ impl State {
vertex: wgpu::VertexState { vertex: wgpu::VertexState {
module: &shader_module, module: &shader_module,
entry_point: Some("vs_main"), entry_point: Some("vs_main"),
buffers: &[vertex_buffer_layout], buffers: &[Some(&vertex_buffer_layout)],
compilation_options: Default::default(), compilation_options: Default::default(),
}, },
primitive: wgpu::PrimitiveState { primitive: wgpu::PrimitiveState {
@@ -415,7 +399,7 @@ impl State {
entry_point: Some("fs_main"), entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: config.format,
blend: None, blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
compilation_options: Default::default(), compilation_options: Default::default(),
@@ -751,7 +735,7 @@ the master `State::new()` block (S3, Step 8):
the vertex shader processes. The other option is `Instance`, which advances the vertex shader processes. The other option is `Instance`, which advances
per draw instance in instanced rendering. For a single triangle, `Vertex` is per draw instance in instanced rendering. For a single triangle, `Vertex` is
correct: each of the three vertices has its own position and color. correct: each of the three vertices has its own position and color.
- **First attribute — `shader_location: 0`**: reads 3 floats (`F32x3`) at byte - **First attribute — `shader_location: 0`**: reads 3 floats (`Float32x3`) at byte
offset 0 of each vertex. These 3 floats map to the offset 0 of each vertex. These 3 floats map to the
[shader location](concepts/GLOSSARY.md#shader-location) `@location(0)` in the [shader location](concepts/GLOSSARY.md#shader-location) `@location(0)` in the
vertex shader — the `position` parameter. The GPU delivers `[x, y, z]` to vertex shader — the `position` parameter. The GPU delivers `[x, y, z]` to
@@ -789,7 +773,7 @@ must provide a `RenderPipelineLayout` created with `device.create_render_pipelin
- **`entry_point: Some("vs_main")`** — selects which function in the module is - **`entry_point: Some("vs_main")`** — selects which function in the module is
the vertex shader entry point. Must match the `@vertex fn vs_main(...)` the vertex shader entry point. Must match the `@vertex fn vs_main(...)`
declaration exactly. declaration exactly.
- **`buffers: &[vertex_buffer_layout]`** — array of vertex buffer layouts. - **`buffers: &[Some(&vertex_buffer_layout)]`** — array of optional vertex buffer layouts. Each layout is wrapped in `Some` to indicate it is present.
Multiple layouts are used rarely (multi-mesh, GPU instancing with separate Multiple layouts are used rarely (multi-mesh, GPU instancing with separate
instance buffers). For a single vertex buffer, one layout suffices. instance buffers). For a single vertex buffer, one layout suffices.
- **`compilation_options: Default::default()`** — shader compilation backend - **`compilation_options: Default::default()`** — shader compilation backend
@@ -846,10 +830,11 @@ draws at the same pixel. For a single triangle this is not a concern.
`SurfaceConfiguration`. The pipeline writes in this format; the surface `SurfaceConfiguration`. The pipeline writes in this format; the surface
reads in this format. A mismatch at render time produces an error. If reads in this format. A mismatch at render time produces an error. If
you change the surface format, you must recreate the pipeline. you change the surface format, you must recreate the pipeline.
- **`blend: None`** — disables blending. Without blending, every fragment - **`blend: Some(wgpu::BlendState::REPLACE)`** — explicitly replaces every
color replaces the existing framebuffer pixel (`REPLACE` mode). With fragment color with the new output. `None` would default to this behavior,
blending, new and existing colors are combined according to a blend but we make it explicit for clarity. With a custom blend state, new and
equation (useful for transparency). existing colors can be combined according to a blend equation
(useful for transparency).
- **`write_mask: ColorWrites::ALL`** — write all four RGBA channels. - **`write_mask: ColorWrites::ALL`** — write all four RGBA channels.
You can mask out individual channels (e.g., write only R and G) if you You can mask out individual channels (e.g., write only R and G) if you
need to preserve certain framebuffer channels across draw calls. need to preserve certain framebuffer channels across draw calls.
@@ -903,8 +888,8 @@ fn render(&mut self) {
``` ```
This is a **fully synchronous** method. It runs on the winit event loop thread This is a **fully synchronous** method. It runs on the winit event loop thread
(triggered by `RedrawRequested`), has no `async` keyword, no `.await`, and takes (triggered by `RedrawRequested`), has no `async` keyword, no `.await`, and requires
no tokio handle. All wgpu recording and submission operations are synchronous no async runtime. All wgpu recording and submission operations are synchronous
and fast — they only encode instructions and push them to the queue; they do not and fast — they only encode instructions and push them to the queue; they do not
wait for GPU completion. wait for GPU completion.
@@ -997,12 +982,7 @@ fn render(&mut self) {
depth_slice: None, depth_slice: None,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
r: 0.1,
g: 0.1,
b: 0.1,
a: 1.0,
}),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
})], })],
@@ -1076,7 +1056,7 @@ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None, depth_slice: None,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.1, g: 0.1, b: 0.1, a: 1.0 }), load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
})], })],
@@ -1094,7 +1074,7 @@ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
- **`ops`** — [operations](concepts/GLOSSARY.md#operations) controlling load - **`ops`** — [operations](concepts/GLOSSARY.md#operations) controlling load
and store behavior. Two sub-fields: and store behavior. Two sub-fields:
- **`load: LoadOp::Clear(color)`** — before drawing, fill the entire - **`load: LoadOp::Clear(color)`** — before drawing, fill the entire
framebuffer with this color. **This IS your background color.** Dark gray. framebuffer with this color. **This IS your background color.** Black.
`LoadOp::Load` keeps existing pixels (used in UI compositing where you `LoadOp::Load` keeps existing pixels (used in UI compositing where you
draw on top of previous content). draw on top of previous content).
- **`store: StoreOp::Store`** — after drawing, keep what was written. The - **`store: StoreOp::Store`** — after drawing, keep what was written. The
@@ -1209,7 +1189,7 @@ and returns the original vector. This avoids a heap allocation for what is
typically a 1-element vec. typically a 1-element vec.
```rust ```rust
fn resize(&mut self, size: wgpu::dpi::PhysicalSize<u32>) { fn resize(&mut self, size: winit::dpi::PhysicalSize<u32>) {
if size.width > 0 && size.height > 0 { if size.width > 0 && size.height > 0 {
let config = wgpu::SurfaceConfiguration { let config = wgpu::SurfaceConfiguration {
usage: self.config.usage, usage: self.config.usage,
@@ -1300,7 +1280,7 @@ cargo run
module compilation log, pipeline creation messages, and the `simple_logger` module compilation log, pipeline creation messages, and the `simple_logger`
debug lines from surface status and device polling. debug lines from surface status and device polling.
**Expected visual:** A dark gray background (from `LoadOp::Clear`) with a **Expected visual:** A black background (from `LoadOp::Clear(wgpu::Color::BLACK)`) with a
rainbow triangle spanning most of the window. Red at the bottom-left corner, rainbow triangle spanning most of the window. Red at the bottom-left corner,
blue at the bottom-right corner, green at the top vertex. Colors blend smoothly blue at the bottom-right corner, green at the top vertex. Colors blend smoothly
across the triangle surface via hardware interpolation. across the triangle surface via hardware interpolation.