Compare commits

..

6 Commits

Author SHA1 Message Date
Krishna Ayyalasomayajula
45b6819f1e docs: fix tokio reference in troubleshooting to match pollster architecture 2026-05-31 22:43:52 -05:00
Krishna Ayyalasomayajula
0479cdf4e4 docs: complete application lifecycle and final validation pass 2026-05-31 22:39:05 -05:00
Krishna Ayyalasomayajula
13dc88aafb docs: make guide cross-platform and improve error handling resilience 2026-05-31 22:34:56 -05:00
Krishna Ayyalasomayajula
5a7cb22bf2 docs: update tutorial to wgpu 29 / winit 0.30 APIs and pollster async pattern 2026-05-31 22:29:13 -05:00
Krishna Ayyalasomayajula
b557ea8a1e docs: restructure rainbow triangle guide to eliminate code duplication and assembly friction 2026-05-31 22:05:57 -05:00
Krishna Ayyalasomayajula
97922d3616 fix: resolve compile errors in rainbow_triangle.rs 2026-05-31 20:31:08 -05:00
4 changed files with 460 additions and 228 deletions

View File

@@ -23,26 +23,22 @@ programs that drive rendering.
## S2: The winit Application and Event Loop
New concept: **event-driven windowing.** winit is the bridge between your Rust
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
descriptors.
code and your platform's display server. Think of it like `epoll`, `kqueue`,
or IOCP (Windows) but for windows, input, and display lifecycle events instead
of file descriptors.
The entire program runs on the tokio async runtime — wgpu's [adapter](concepts/GLOSSARY.md#adapter)
queries and [device](concepts/GLOSSARY.md#device) creation are async, and the
runtime is the natural home for the main event loop.
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`.
### Architecture Overview
- **`main()` is `#[tokio::main] async fn`** — the entry point runs on the tokio
runtime, giving us access to tokio's task scheduler and I/O facilities.
- **`tokio::spawn_blocking`** — winit's `event_loop.run_app()` is synchronous
and owns the display server connection. Blocking the tokio runtime thread with
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
- **`main()` is synchronous** — the entry point initializes logging, creates
the event loop, and calls `run_app()`. The entire program runs on a single
thread. No async runtime is needed for the main loop.
- **`pollster::block_on()` in `resumed()`** — wgpu initialization (adapter and
device queries) is async, but winit's `resumed()` handler is synchronous. We
bridge the two execution models exactly once at startup. This initial GPU
setup takes ~50ms of wall time.
bridge the two execution models exactly once at startup using `pollster`,
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
winit event handlers and wgpu [surface](concepts/GLOSSARY.md#surface) state
must hold a reference to the same window object across the event loop
@@ -60,52 +56,58 @@ Add these to your `Cargo.toml`:
```toml
wgpu = "29"
winit = "0.30"
tokio = { version = "1", features = ["rt", "macros"] }
pollster = "0.4"
bytemuck = { version = "1", features = ["derive"] }
log = "0.4"
simple_logger = "5"
```
- `wgpu` — the GPU abstraction layer. Manages device lifecycles, shaders, buffers,
pipelines, and command encoding.
- `winit` — cross-platform window creation and event dispatch. Owns the display
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
for uploading vertex data to GPU buffers without manual serialization.
- `log` / `simple_logger` — structured logging. wgpu and winit emit diagnostic
messages via `log` when misconfigurations or driver issues are detected.
### Cross-Platform Window Creation
winit abstracts all platform-specific windowing details. The same code works
across every supported platform:
- **Windows:** Native Win32/composition backend.
- **macOS:** Cocoa backend.
- **Linux:** X11 or Wayland, depending on your active session.
No platform-specific conditionals or feature flags are needed — winit handles
everything automatically.
### Complete Code
```rust
use pollster::block_on;
use std::sync::Arc;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event::{WindowEvent, ElementState, KeyEvent};
use winit::event::keyboard::KeyCode;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId};
#[tokio::main]
async fn main() {
fn main() {
simple_logger::init_with_level(log::Level::Debug).unwrap();
let event_loop = EventLoop::new().unwrap();
let handle = tokio::Handle::current();
tokio::spawn_blocking(move || {
event_loop.run_app(&mut App {
handle,
window: None,
state: None,
})
})
.await
.unwrap();
}
struct App {
handle: tokio::Handle,
window: Option<Arc<Window>>,
state: Option<State>,
}
@@ -124,30 +126,40 @@ impl ApplicationHandler<()> for App {
event_loop_ctl.set_control_flow(ControlFlow::Poll);
self.window = Some(window.clone());
self.state = Some(
self.handle
.block_on(async {
State::new(window.clone()).await.expect("Failed to create wgpu State")
})
.expect("Failed to create wgpu State"),
);
// Graceful GPU initialization: if adapter/device creation fails,
// log the error and close the window instead of panicking.
match block_on(State::new(window.clone())) {
Ok(state) => self.state = Some(state),
Err(e) => {
log::error!("Failed to initialize GPU: {}", e);
window.close();
}
}
}
fn window_event(
&mut self,
event_loop_ctl: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else { return };
let Some(window) = self.window.as_ref() else { return };
fn window_event(&mut self, event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {
match event {
WindowEvent::Resized(size) => state.resize(size),
WindowEvent::CloseRequested { .. } => event_loop_ctl.exit(),
WindowEvent::CloseRequested
| WindowEvent::KeyboardInput {
event: KeyEvent {
state: ElementState::Pressed,
logical_key: KeyCode::Escape,
..
},
..
} => {
event_loop.exit();
}
WindowEvent::Resized(physical_size) => {
if let Some(state) = &mut self.state {
state.resize(*physical_size);
}
}
WindowEvent::RedrawRequested => {
if let Some(state) = &mut self.state {
state.render();
window.request_redraw();
}
self.window.as_ref().unwrap().request_redraw();
}
_ => {}
}
@@ -159,18 +171,28 @@ 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.
> **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.
> wgpu's `request_adapter` and `request_device` query the driver over async
> platform-specific entry points (Metal on macOS, DX12/Vulkan on Windows,
> Vulkan on Linux). 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: `ControlFlow::Poll` for the render loop**
>
> winit supports `ControlFlow::Poll` (continuous redraw) and `ControlFlow::Wait` (idle until next event). A graphics application needs a steady render loop. `Poll` tells winit to keep firing `RedrawRequested` events. We re-queue ourselves inside the handler via `window.request_redraw()`, matching the wgpu swapchain presentation rhythm.
> **WHY: `ControlFlow` Poll vs Wait — the CPU usage tradeoff**
>
> - `ControlFlow::Poll` keeps the render loop running continuously. winit fires `RedrawRequested` as fast as possible, giving you a tight loop ideal for games and animation. The downside: it consumes 100% of one CPU core because the event loop never sleeps.
> - `ControlFlow::Wait` blocks the event loop until a new event arrives. This is dramatically more CPU-efficient since the loop sleeps between frames. However, it requires explicit `request_redraw()` calls to trigger rendering — without them, the window stays static.
> - For this tutorial, `ControlFlow::Poll` is used for simplicity: you don't need to reason about when to request redraws. A real application would almost always use `ControlFlow::Wait` with targeted `request_redraw()` calls to avoid wasting CPU cycles.
> - The `request_redraw()` call in the `RedrawRequested` handler works with both modes, but has different semantics: under `Poll` it keeps the loop going (which is already happening anyway), while under `Wait` it's the sole mechanism that wakes the event loop for the next frame.
**Why `request_redraw()`:** After presenting a frame to the display, we ask
winit to schedule the next `RedrawRequested` frame. This creates an explicit
render loop: render → present → request redraw → render → repeat. The rate is
@@ -182,6 +204,34 @@ the event loop must still drain. `exiting()` ensures we have one last clean
opportunity to flush the queue and release GPU resources before the process
exits.
### The `State` Impl Skeleton
The `State` struct (defined fully in S3) carries all GPU resources. Its
implementation has three public methods. Here is the complete signature
overview so you can see the full shape before diving into the init chain:
```rust
impl State {
// S3: async constructor — builds the full GPU init chain (8 steps)
async fn new(window: Arc<Window>) -> Result<Self, String> {
// ... (see S3)
}
// S7: synchronous render loop — records commands and submits to GPU
fn render(&mut self) {
// ... (see S7)
}
// S8: reconfigure swapchain when window dimensions change
fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
// ... (see S8)
}
}
```
The next sections fill in each method. S3 covers `State::new()` in full.
S7 covers `render()`. S8 covers `resize()`.
## S3: Connecting to the GPU — The Init Chain
New concept: **5-layer GPU connection.** Each layer adds a capability:
@@ -215,6 +265,16 @@ Instance
allocates the swapchain [framebuffers](concepts/GLOSSARY.md#framebuffer) for
this window at a specific resolution and pixel format.
> **Cross-platform GPU adapter selection:** wgpu abstracts away the platform
> graphics API. It automatically picks the best backend for your operating
> system:
> - **Windows:** DirectX 12 (primary), DirectX 11 (fallback), Vulkan
> - **macOS:** Metal (primary and only supported backend)
> - **Linux:** Vulkan (primary), OpenGL (fallback), DX12 (under Wine)
>
> `PowerPreference::HighPerformance` picks the discrete GPU on laptops
> regardless of which backend wgpu selects.
### The State Struct
```rust
@@ -279,7 +339,7 @@ const VERTICES: &[Vertex] = &[
impl State {
async fn new(window: Arc<Window>) -> Result<Self, String> {
// 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
let surface = instance
@@ -294,7 +354,7 @@ impl State {
compatible_surface: None,
})
.await
.ok_or("No GPU adapter found. Ensure Vulkan drivers are installed.")?;
.ok_or("No GPU adapter found. Ensure your graphics drivers (Vulkan/Metal/DirectX) are installed.")?;
// Step 4: Device + Queue — resource owner + command submission
let (device, queue) = adapter
@@ -347,12 +407,12 @@ impl State {
attributes: &[
wgpu::VertexAttribute {
offset: 0,
format: wgpu::VertexFormat::F32x3,
format: wgpu::VertexFormat::Float32x3,
shader_location: 0,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as u64,
format: wgpu::VertexFormat::F32x3,
format: wgpu::VertexFormat::Float32x3,
shader_location: 1,
},
],
@@ -364,7 +424,7 @@ impl State {
vertex: wgpu::VertexState {
module: &shader_module,
entry_point: Some("vs_main"),
buffers: &[vertex_buffer_layout],
buffers: &[Some(&vertex_buffer_layout)],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState {
@@ -387,7 +447,7 @@ impl State {
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: None,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
@@ -411,32 +471,34 @@ impl State {
### Init Steps Explained
**Step 1 — Instance:** `Instance::default()` opens a connection to the graphics
driver on the current platform. On Linux with Vulkan, this loads `libvulkan.so`
and creates a Vulkan `VkInstance`. On Windows, it loads `vulkan-1.dll`. The
instance is the foundational wgpu object — every other wgpu operation requires
it.
**Step 1 — Instance:** `Instance::new()` opens a connection to the graphics
driver on the current platform. wgpu automatically selects the best backend:
Metal on macOS, DirectX 12 (or DX11/Vulkan) on Windows, and Vulkan (or OpenGL)
on Linux. The instance is the foundational wgpu object — every other wgpu
operation requires it.
**Step 2 — Surface:** `instance.create_surface(window)` binds the wgpu instance
to the winit `Window`. This tells the GPU: "the pixels of *this* window will be
the output of my rendering." In Vulkan terms, this is the first half of creating
a `SwapchainKHR`. The surface must match the window platform type exactly (X11,
Wayland, Windows, macOS, etc.).
the output of my rendering." The surface must match the window platform type
exactly (Windows, macOS, X11, Wayland, etc.), and wgpu handles this
mapping automatically through winit.
**Step 3 — Adapter:** `request_adapter()` queries available GPUs and returns the
best match for the given options. With
`PowerPreference::HighPerformance`, wgpu prefers a discrete GPU over an
integrated one on hybrid systems (e.g., NVIDIA + Intel Optimus). The
`compatible_surface: None` path works because our `Instance` was created without
a display handle; on Linux with Vulkan, the adapter selection remains correct
because the surface itself was created through a compatible instance.
integrated one on hybrid systems (e.g., NVIDIA + Intel Optimus on Windows,
AMD Radeon + Intel Iris on macOS/Linux). This preference is cross-platform: it
picks the high-performance GPU regardless of backend (Metal, DX12, or Vulkan).
The `compatible_surface: None` path works because our `Instance` was created without
a display handle; adapter selection remains correct across all backends.
**Step 4 — Device + Queue:** `request_device()` allocates the logical GPU
resource manager and its submission queue. The device tracks all GPU memory and
validates API calls. The queue is the submission endpoint — every rendered frame
becomes a [command buffer](concepts/GLOSSARY.md#command-buffer) that is submitted
to this queue. On Vulkan, the device corresponds to `VkDevice` and the queue
to a `VkQueue`.
to this queue. This maps to platform-native concepts: `ID3D12Device` + `ID3D12CommandQueue`
on DX12, `MTLDevice` + `MTLCommandQueue` on Metal, and `VkDevice` + `VkQueue` on
Vulkan.
> **Key insight — Validation layers catch GPU errors at runtime:** wgpu ships
> with built-in validation layers that inspect your API calls for common
@@ -503,7 +565,10 @@ runtime.
### The Complete Shader
Create `shader.wgsl` in your project root (at the same level as `main.rs`):
Create the file `src/shader.wgsl` in the same directory as `src/main.rs`, at the
crate source root. The `include_str!` macro in the master `State::new()` block
(S3, Step 6) expects the path `shader.wgsl` relative to the source file, so it
must live alongside `main.rs`:
```wgsl
struct VertexOutput {
@@ -612,18 +677,10 @@ RGB color to RGBA by setting alpha = 1.0 (fully opaque). The
across the triangle; we just attach an alpha channel and return it. The output
merge stage writes this color directly to the framebuffer.
### Rust Shader Module Creation
### How the Shader Is Loaded (S3, Step 6)
The Rust side loads the shader file at compile time and feeds the source to wgpu:
```rust
let shader_module = device.create_shader_module(
wgpu::ShaderModuleDescriptor {
label: Some("Rainbow Triangle Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
}
);
```
The Rust side loads the shader file at compile time and feeds the source to wgpu.
This code lives in the master `State::new()` block (S3, Step 6):
- **`ShaderModuleDescriptor`** — has two fields: `label` (debug string, shown
in graphics debuggers and validation messages) and `source` (the shader
@@ -654,30 +711,21 @@ strides the attribute begins.
> reports "method not found." This is a Rust trait-discovery issue, not a wgpu
> API issue. Add `use wgpu::util::DeviceExt;` to bring the method into scope.
### The Vertex Struct
### The Vertex Struct (S3, Step 7)
```rust
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
color: [f32; 3],
}
```
The `Vertex` struct is defined alongside the `VERTICES` constant at the top of
`main.rs`, before `impl State`. This is the same struct used in the master
initialization block (S3). The key annotations are:
> **WHY: `#[repr(C)]` + bytemuck for GPU data layout**
>
> `#[repr(C)]` forces the Rust compiler to lay out the struct fields in declaration order with no padding reordering. Without this, Rust is free to reorder fields for optimal alignment, which would break the byte layout the shader expects. `bytemuck::Pod` ("Plain Old Data") guarantees the struct has no padding holes, no destructors, and a trivial memory representation. wgpu requires all vertex types to be Pod so they can be safely transmuted to bytes. `bytemuck::Zeroable` guarantees that initializing the struct's memory to all-zero bytes produces a valid instance. Required because `Pod` alone does not guarantee zero is a valid discriminant for enums or optional types. Combined with Pod, it enables `bytemuck::cast_slice` to convert between `&[Vertex]` and `&[u8]` without an unsafe block.
### Vertex Data
### Vertex Data (S3, Step 7)
```rust
const VERTICES: &[Vertex] = &[
Vertex { position: [-0.5, -0.5, 0.0], color: [1.0, 0.0, 0.0] }, // red
Vertex { position: [ 0.5, -0.5, 0.0], color: [0.0, 0.0, 1.0] }, // blue
Vertex { position: [ 0.0, 0.5, 0.0], color: [0.0, 1.0, 0.0] }, // green
];
```
The `VERTICES` constant is defined at the top of `main.rs` alongside the
`Vertex` struct. It is the same data used in the master initialization block
(S3, Step 7).
- **Positions are in NDC:** The [normalized device coordinates](concepts/GLOSSARY.md#ndc)
range from -1.0 (left/bottom) to +1.0 (right/top). Our triangle spans the
@@ -690,18 +738,10 @@ const VERTICES: &[Vertex] = &[
determines which face is "front" and which is "back" — critical for
[culling](concepts/GLOSSARY.md) and correct normal computation.
### Buffer Upload
### Buffer Upload (S3, Step 7)
```rust
use wgpu::util::DeviceExt;
let vertex_buffer = device.create_buffer_init(
&wgpu::util::BufferInitDescriptor {
label: Some("Vertex Buffer"),
contents: bytemuck::cast_slice(VERTICES),
usage: wgpu::BufferUsages::VERTEX,
}
);
```
This code lives in the master `State::new()` block (S3, Step 7). The
`create_buffer_init` method is the combined allocate-and-upload call.
- **`use wgpu::util::DeviceExt`** — imports the extension trait that adds
`create_buffer_init` to `Device`. Without this import, the method is not
@@ -731,29 +771,11 @@ GPU-executable configuration. Errors in any field are caught at creation time,
not at draw time. This validation-upfront model is what makes pipelines expensive
to create but cheap to execute.
### Vertex Buffer Layout
### Vertex Buffer Layout (S3, Step 8)
Before the pipeline descriptor, you must tell wgpu how to parse the byte stream
in the vertex buffer into per-vertex attributes:
```rust
let vertex_buffer_layout = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
format: wgpu::VertexFormat::F32x3,
shader_location: 0,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as u64,
format: wgpu::VertexFormat::F32x3,
shader_location: 1,
},
],
};
```
in the vertex buffer into per-vertex attributes. This layout is defined inside
the master `State::new()` block (S3, Step 8):
- **`array_stride: 24`** — `size_of::<Vertex>()` = 24 bytes (6 × `f32` × 4 bytes).
This is the byte distance from one vertex to the next in the buffer. The GPU
@@ -763,7 +785,7 @@ let vertex_buffer_layout = wgpu::VertexBufferLayout {
the vertex shader processes. The other option is `Instance`, which advances
per draw instance in instanced rendering. For a single triangle, `Vertex` is
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
[shader location](concepts/GLOSSARY.md#shader-location) `@location(0)` in the
vertex shader — the `position` parameter. The GPU delivers `[x, y, z]` to
@@ -775,47 +797,11 @@ let vertex_buffer_layout = wgpu::VertexBufferLayout {
receive the position values as the color input, rendering a triangle with
gradient colors derived from position data.
### The Complete Render Pipeline Descriptor
### The Complete Render Pipeline Descriptor (S3, Step 8)
```rust
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Triangle Pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &shader_module,
entry_point: Some("vs_main"),
buffers: &[vertex_buffer_layout],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &shader_module,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
```
The full `device.create_render_pipeline()` call with all descriptor fields
lives in the master `State::new()` block (S3, Step 8). Below is the
field-by-field walkthrough explaining every parameter.
### Field-by-Field Walkthrough
@@ -837,7 +823,7 @@ must provide a `RenderPipelineLayout` created with `device.create_render_pipelin
- **`entry_point: Some("vs_main")`** — selects which function in the module is
the vertex shader entry point. Must match the `@vertex fn vs_main(...)`
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
instance buffers). For a single vertex buffer, one layout suffices.
- **`compilation_options: Default::default()`** — shader compilation backend
@@ -894,10 +880,11 @@ draws at the same pixel. For a single triangle this is not a concern.
`SurfaceConfiguration`. The pipeline writes in this format; the surface
reads in this format. A mismatch at render time produces an error. If
you change the surface format, you must recreate the pipeline.
- **`blend: None`** — disables blending. Without blending, every fragment
color replaces the existing framebuffer pixel (`REPLACE` mode). With
blending, new and existing colors are combined according to a blend
equation (useful for transparency).
- **`blend: Some(wgpu::BlendState::REPLACE)`** — explicitly replaces every
fragment color with the new output. `None` would default to this behavior,
but we make it explicit for clarity. With a custom blend state, new and
existing colors can be combined according to a blend equation
(useful for transparency).
- **`write_mask: ColorWrites::ALL`** — write all four RGBA channels.
You can mask out individual channels (e.g., write only R and G) if you
need to preserve certain framebuffer channels across draw calls.
@@ -951,8 +938,8 @@ fn render(&mut self) {
```
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
no tokio handle. All wgpu recording and submission operations are synchronous
(triggered by `RedrawRequested`), has no `async` keyword, no `.await`, and requires
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
wait for GPU completion.
@@ -1045,12 +1032,7 @@ fn render(&mut self) {
depth_slice: None,
resolve_target: None,
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,
},
})],
@@ -1096,9 +1078,9 @@ not reclaim finished work. Called once per frame. Returns
re-creating the device.
WHY this is synchronous: `poll()` does not spawn a task or use `.await`. It
runs a small internal loop checking Vulkan fence objects until all in-flight
work is done, then returns. On a busy GPU this can take a few milliseconds per
frame — that is normal.
runs a small internal loop checking platform-specific synchronization primitives
(fences, events, etc.) until all in-flight work is done, then returns. On a
busy GPU this can take a few milliseconds per frame — that is normal.
**`texture.create_view(&Default::default())`** — A [texture view](concepts/GLOSSARY.md#texture-view)
is how wgpu references a texture's memory inside a render pass. The GPU does
@@ -1124,7 +1106,7 @@ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
resolve_target: None,
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,
},
})],
@@ -1142,7 +1124,7 @@ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
- **`ops`** — [operations](concepts/GLOSSARY.md#operations) controlling load
and store behavior. Two sub-fields:
- **`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
draw on top of previous content).
- **`store: StoreOp::Store`** — after drawing, keep what was written. The
@@ -1257,7 +1239,7 @@ and returns the original vector. This avoids a heap allocation for what is
typically a 1-element vec.
```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 {
let config = wgpu::SurfaceConfiguration {
usage: self.config.usage,
@@ -1320,13 +1302,15 @@ The full source is the codeblocks in sections S2S8, assembled in order into
```
src/
├── main.rs # Sections S2, S3, S5 (structs), S7 (render), S8 (resize)
├── main.rs # Sections S2 (event loop), S3 (State + init chain), S7 (render), S8 (resize)
├── shader.wgsl # Section S4 (the complete WGSL shader)
```
- `main.rs` combines the winit event loop (S2), the init chain and `State`
struct (S3), the `Vertex` type and `VERTICES` constant (S5), the `render`
method (S7), and the `resize` method (S8).
- `main.rs` combines the winit event loop (S2), the complete `State` struct
definition and full `State::new()` implementation covering all 8 init steps
(S3), the `render` method (S7), and the `resize` method (S8). Sections S5
and S6 explain the vertex data and pipeline concepts but their code lives
inside the master `State::new()` block in S3.
- `shader.wgsl` is the single file from S4: vertex shader, fragment shader,
and the `VertexOutput` struct.
@@ -1346,7 +1330,7 @@ cargo run
module compilation log, pipeline creation messages, and the `simple_logger`
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,
blue at the bottom-right corner, green at the top vertex. Colors blend smoothly
across the triangle surface via hardware interpolation.

View File

@@ -22,7 +22,26 @@ graphics programming experience. Every sentence teaches GPU concepts — not Rus
- **01 — Rainbow Triangle** — The minimal complete program: one triangle, smooth color interpolation
- More guides coming soon (textures, lighting, compute)
## Running the Project
From the project root:
```bash
cargo run
```
This works on all supported platforms. If you encounter build errors, see the
**Prerequisites** section above for platform-specific build tool requirements.
## Prerequisites
- Rust stable (edition 2024)
- Linux x86_64 with Vulkan drivers (`libvulkan1`)
- A system with a GPU supporting WebGPU-compatible APIs:
- **Windows:** Windows 10 or later with a DirectX 12-capable GPU
- **macOS:** macOS 10.13+ with a Metal-capable GPU
- **Linux:** Vulkan-capable GPU and drivers
> **Build tools:** Your platform may require additional build tools:
> - **Windows:** Visual Studio Build Tools (2019 or later)
> - **macOS:** Xcode Command Line Tools (`xcode-select --install`)
> - **Linux:** Vulkan ICD and development packages (e.g., `vulkan-validationlayers` on Ubuntu)

View File

@@ -10,7 +10,7 @@
```toml
wgpu = "29"
winit = "0.30"
tokio = { version = "1", features = ["rt", "macros"] }
pollster = "0.4"
bytemuck = { version = "1", features = ["derive"] }
log = "0.4"
simple_logger = "5"
@@ -41,38 +41,77 @@ simple_logger = "5"
view_formats: vec![format.add_srgb_suffix()],
```
## 4. Panic: "No adapter found"
## 4. "No adapter found"
**Symptom:** Program crashes immediately with `No GPU adapter found`.
**Symptom:** Program crashes or logs "No GPU adapter found" during startup.
**Cause:** No Vulkan driver installed. wgpu on Linux requires Vulkan.
**Cause:** wgpu cannot find a compatible graphics adapter. This can happen
across all platforms for different reasons.
**Fix:**
- **Windows:** Ensure your GPU supports DirectX 12 (WDDM 2.0+). Update GPU
drivers from the manufacturer's website (NVIDIA, AMD, or Intel). Some
integrated GPUs may require a Windows 10+ build. If DX12 is unavailable,
install the LunarG Vulkan Runtime.
- **macOS:** Metal is required. Ensure your GPU supports Metal (all Macs
from 2012 onward). Update macOS to the latest version. If you're on
Apple Silicon, wgpu will automatically use the Metal backend.
- **Linux:** Install Vulkan drivers and tools:
```bash
sudo apt install libvulkan1 mesa-vulkan-drivers
# Ubuntu/Debian
sudo apt install vulkan-tools mesa-vulkan-drivers
# Arch
sudo pacman -S vulkan-tools vulkan-radeon # or vulkan-intel / nvidia-utils
vulkaninfo # verify installation
```
## 5. Panic: "Surface lost"
## 5. "Adapter not compatible" (macOS / Metal)
**Symptom:** Program crashes with a surface lost error during rendering.
**Symptom:** wgpu reports that no adapter is compatible with the surface.
**Cause:** Display server restarted or GPU context was reset. The [Surface](concepts/GLOSSARY.md#surface) is permanently invalidated.
**Cause:** On macOS, wgpu uses Metal exclusively. If the GPU does not support
Metal (e.g., very old Mac hardware or a GPU below Metal 2.0), no adapter will
be found.
**Fix:** In the tutorial, this means the window needs to be reopened. In production code, handle the `Lost` variant of `CurrentSurfaceTexture` by recreating the surface via `Instance::create_surface()`.
**Fix:** Verify your Mac supports Metal. Check `System Information > Graphics`
in macOS. If Metal is not supported, the GPU cannot be used with wgpu.
## 6. Wayland surface crashes or doesn't render
## 6. "Surface lost"
**Symptom:** Program crashes or shows blank window on Wayland.
**Symptom:** Program crashes with a surface lost error during rendering, or the
window renders blanks after being minimized/restored.
**Cause:** winit's Wayland backend may have compatibility issues with certain compositors.
**Cause:** The display server or GPU context was reset. The
[Surface](concepts/GLOSSARY.md#surface) is permanently invalidated. Common
triggers include:
- Window minimized and restored (some display servers)
- Display configuration changed (hotplug, external monitor connected/disconnected)
- GPU driver reset
- Compositor restart (Linux/Wayland)
**Fix:** Force X11 backend:
**Fix:** Handle the `CurrentSurfaceTexture::Lost` variant gracefully. In the
tutorial, this means the window may need to be reopened. In production code,
set `self.state = None`, then on the next redraw event, re-run the full
`State::new()` initialization chain to recreate all GPU resources.
## 7. Wayland surface crashes or doesn't render (Linux only)
**Symptom:** Program crashes or shows blank window on Wayland compositors
(Sway, GNOME Shell, KDE Plasma, etc.).
**Cause:** winit's Wayland backend may have compatibility issues with certain
compositors or Vulkan/Wayland interop layers.
**Fix:** Temporarily force the X11 backend:
```bash
WINIT_UNIX_BACKEND=x11 cargo run
```
## 7. Window won't close
If X11 works but Wayland does not, check your compositor version and winit
version. This is a Linux-specific issue and does not affect Windows or macOS.
## 8. Window won't close
**Symptom:** Clicking the X button or pressing Escape doesn't close the window.
@@ -85,7 +124,7 @@ fn exiting(&mut self, event_loop_ctl: &ActiveEventLoop) {
}
```
## 8. CPU at 100%
## 9. CPU at 100%
**Symptom:** One CPU core at 100% usage.
@@ -93,7 +132,7 @@ fn exiting(&mut self, event_loop_ctl: &ActiveEventLoop) {
**Fix:** No fix needed — this is expected for a continuous redraw demo. For production, switch to `ControlFlow::Wait` and call `request_redraw()` only when state changes.
## 9. Shader compilation or pipeline creation error
## 10. Shader compilation or pipeline creation error
**Symptom:** Program panics during `create_render_pipeline` with a shader validation error.
@@ -104,7 +143,7 @@ fn exiting(&mut self, event_loop_ctl: &ActiveEventLoop) {
- WGSL `@location(1)` matches Rust `shader_location: 1`
- Vertex shader outputs `@builtin(position) clip_position: vec4<f32>`
## 10. Triangle shows one solid color instead of gradient
## 11. Triangle shows one solid color instead of gradient
**Symptom:** Triangle renders but is a single uniform color instead of smoothly blending.
@@ -123,7 +162,7 @@ Not this (which returns a solid color):
return vec4<f32>(1.0, 1.0, 1.0, 1.0); // wrong: solid white
```
## 11. WGSL shader compilation errors
## 12. WGSL shader compilation errors
**Symptom:** Program panics or logs errors during `device.create_shader_module()` or `create_render_pipeline()` with messages referencing WGSL validation failures.
@@ -140,13 +179,13 @@ return vec4<f32>(1.0, 1.0, 1.0, 1.0); // wrong: solid white
- Confirm the function name in your `@vertex`/`@fragment` declaration matches the string you pass to `ProgrammableStage::entry_point`
- If the error message is unclear, try compiling the shader in isolation to isolate syntax vs. pipeline-binding issues
## 12. GPU debugging with RenderDoc
## 13. GPU debugging with RenderDoc
**Symptom:** Rendering issues that are difficult to diagnose (artifacts, wrong output, silent failures).
**Cause:** GPU debugging is hard with standard tools. Graphics pipeline state, shader execution, and buffer contents are not easily inspectable at runtime.
**Fix:** Use [RenderDoc](https://renderdoc.org/) — a standalone GPU debugging tool supporting frame capture, pipeline state inspection, and shader debugging. It works with Vulkan (Linux), DX12 (Windows), and OpenGL. Launch RenderDoc, attach to your wgpu process, and capture frames to inspect the full graphics pipeline step by step.
**Fix:** Use [RenderDoc](https://renderdoc.org/) — a standalone GPU debugging tool supporting frame capture, pipeline state inspection, and shader debugging. It works with Vulkan (Linux), DX12/DX11 (Windows), and Metal (macOS). Launch RenderDoc, attach to your wgpu process, and capture frames to inspect the full graphics pipeline step by step. On macOS, RenderDoc supports Metal 2.0+ devices.
## Additional Resources

190
src/rainbow_traingle.rs Normal file
View File

@@ -0,0 +1,190 @@
use std::sync::Arc;
use winit::{application::ApplicationHandler, dpi::LogicalSize, window::Window};
struct App {
window: Option<Arc<Window>>,
frame_state: Option<FrameState>,
handle: tokio::runtime::Handle,
}
struct FrameState {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
window: Arc<Window>,
pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
let window: Arc<Window> = Arc::new(
event_loop
.create_window(
Window::default_attributes().with_inner_size(LogicalSize::new(800.0, 800.0)),
)
.unwrap(),
);
self.window = Some(window.clone());
self.frame_state = Some(
self.handle
.block_on(async {
FrameState::new(window.clone())
})
.expect("failed to create wgpu state"),
);
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
color: [f32; 3],
}
const VERTICES: &[Vertex] = &[
Vertex {
position: [-0.5, -0.5, 0.0],
color: [1.0, 0.0, 0.0],
}, // red
Vertex {
position: [0.5, -0.5, 0.0],
color: [0.0, 0.0, 1.0],
}, // blue
Vertex {
position: [0.0, 0.5, 0.0],
color: [0.0, 1.0, 0.0],
}, // green
];
impl FrameState {
async fn new(window: Arc<Window>) -> Result<Self, String> {
// Step 1: Instance — connection to the graphics driver
let instance = wgpu::Instance::default();
// Step 2: Surface — binds our window to the GPU's swapchain
let surface = instance
.create_surface(window)
.map_err(|e| format!("Failed to create surface: {:?}", e))?;
// Step 3: Adapter — selects the physical GPU
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: None,
})
.await
.ok_or("No GPU adapter found. Ensure Vulkan drivers are installed.")?;
// Step 4: Device + Queue — resource owner + command submission
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor::default(), None)
.await
.map_err(|e| format!("Failed to request device: {:?}", e))?;
// Step 5: SurfaceConfiguration — allocates swapchain framebuffers
let size = window.inner_size();
let surface_caps = surface.get_capabilities(&adapter);
let format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: wgpu::PresentMode::Mailbox,
desired_maximum_frame_latency: 2,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![format.add_srgb_suffix()],
};
surface.configure(&device, &config);
// Step 6: Compile the shader module
let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Rainbow Triangle Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
// Step 7: Upload vertex data to GPU memory
use wgpu::util::DeviceExt;
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Vertex Buffer"),
contents: bytemuck::cast_slice(VERTICES),
usage: wgpu::BufferUsages::VERTEX,
});
// Step 8: Create the render pipeline
let vertex_buffer_layout = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
format: wgpu::VertexFormat::Float32x3,
shader_location: 0,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as u64,
format: wgpu::VertexFormat::Float32x3,
shader_location: 1,
},
],
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Triangle Pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &shader_module,
entry_point: Some("vs_main"),
buffers: &[vertex_buffer_layout],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &shader_module,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
Ok(Self {
surface,
device,
queue,
config,
window: Arc::clone(&window),
pipeline,
vertex_buffer,
})
}
}