66 KiB
Building a Rainbow Triangle
S1: What We're Building
We're creating a window containing a single triangle with smoothly blended colors:
Red at the bottom-left corner, blue at the bottom-right corner, and green at the top vertex. The gradient between each pair of vertices is not computed by you — it is interpolated automatically by the GPU rasterizer in hardware. You provide three vertices, each carrying a position and a color. The rasterizer determines every pixel covered by the triangle and computes the color for that pixel by blending the three vertex colors proportionally to their distance. The result is a smooth rainbow gradient across a single primitive. We do not need a texture, a colormap, or a fragment shader with any branching — just three colored vertices and the default linear interpolation the rasterizer applies to every interpolated value.
If you haven't read the concept overview, do so now. Coordinate systems explains how the GPU positions geometry. Shader basics covers the GPU 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.
The entire program runs on the tokio async runtime — wgpu's adapter queries and device creation are async, and the runtime is the natural home for the main event loop.
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'sevent_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()inresumed()— wgpu initialization (adapter and device queries) is async, but winit'sresumed()handler is synchronous. We bridge the two execution models exactly once at startup. 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 state must hold a reference to the same window object across the event loop boundary.ControlFlow::Poll— continuous redraw mode. winit firesRedrawRequestedas fast as the display server allows the window to be presented, giving us a tight render loop without a separate timer or explicit vsync setup. The display present mode controls the actual vsync behavior.
Dependencies
Add these to your Cargo.toml:
wgpu = "29"
winit = "0.30"
tokio = { version = "1", features = ["rt", "macros"] }
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.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 vialogwhen misconfigurations or driver issues are detected.
Complete Code
use std::sync::Arc;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId};
#[tokio::main]
async 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>,
}
impl ApplicationHandler<()> for App {
fn resumed(&mut self, event_loop_ctl: &ActiveEventLoop) {
let window = Arc::new(
event_loop_ctl
.create_window(
Window::default_attributes()
.with_inner_size(LogicalSize::new(800.0, 600.0))
.with_title("Rainbow Triangle"),
)
.unwrap(),
);
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"),
);
}
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 };
match event {
WindowEvent::Resized(size) => state.resize(size),
WindowEvent::CloseRequested { .. } => event_loop_ctl.exit(),
WindowEvent::RedrawRequested => {
state.render();
window.request_redraw();
}
_ => {}
}
}
fn exiting(&mut self, event_loop_ctl: &ActiveEventLoop) {
event_loop_ctl.exit();
}
}
Why spawn_blocking: 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: 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: 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 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
governed by the swapchain present mode.
Why exiting(): This is the final lifecycle signal before the process
terminates. On some display servers, CloseRequested fires on the window but
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.
S3: Connecting to the GPU — The Init Chain
New concept: 5-layer GPU connection. Each layer adds a capability:
Instance
│
├──> Surface (winit window → GPU surface)
│
├──> Adapter (select GPU: integrated vs discrete)
│
├──> Device + Queue (GPU connection + command submission)
│
└──> SurfaceConfiguration (swapchain: format, size, present mode)
- Instance — opens a connection to the graphics driver. On Vulkan this loads the Vulkan loader and registers instance-level extensions. On WebGL this picks the browser GPU context.
- Surface — binds the instance to a specific window's swapchain. The surface is the wgpu representation of the window's display buffer.
- Adapter — selects the physical GPU hardware. An adapter wraps the actual driver + silicon pair (e.g., Mesa RADV on AMD, NVIDIA driver on NVIDIA silicon).
- Device + Queue — the device owns all GPU resources (buffers, textures, shaders, pipelines). The queue is the submission channel: you encode work into command buffers and submit them to the queue.
- SurfaceConfiguration — allocates the swapchain framebuffers for this window at a specific resolution and pixel format.
The State Struct
struct State {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
window: Arc<Window>,
pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
}
surface— connects to the window's display buffer. The'staticlifetime is safe becauseAppowns the window and lives for the entire lifetime of the process. The surface mediates all swapchain operations.device— owns all GPU resources. Every buffer, texture, shader module, and pipeline created in this guide is a child of the device. When the device is dropped, all its children are freed.queue— the command submission channel. You encode a frame's worth of work into a command buffer, then submit that buffer to the queue. The queue pushes work to the GPU hardware.config— holds the surface's current width, height, pixel format, and present mode. When the window is resized, we reconfigure the surface with updated dimensions.window— shared reference to the winit window. Stored as anArcso theresize()method and theCurrentSurfaceTexture::Outdatedrecovery handler can access the window's current dimensions. When the surface becomes outdated (e.g., after a compositor restart or display hotplug), recovery requires reconfiguring the swapchain with the window's live size — and that requires holding a reference to the window itself.pipeline— the compiled render pipeline. A render pipeline is an immutable configuration combining a shader, a vertex buffer layout, a primitive topology, and a color target setup. Switching pipelines mid-frame is expensive; most applications use a few pipelines and change them between draw calls.vertex_buffer— GPU memory holding our vertex data. The GPU reads position and color data directly from this buffer during the vertex shader stage.
Complete State::new() Implementation
use wgpu::Surface;
// --- Vertex type and data ---
#[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 State {
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::F32x3,
shader_location: 0,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as u64,
format: wgpu::VertexFormat::F32x3,
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,
})
}
}
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 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.).
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.
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 that is submitted
to this queue. On Vulkan, the device corresponds to VkDevice and the queue
to a VkQueue.
Key insight — Validation layers catch GPU errors at runtime: wgpu ships with built-in validation layers that inspect your API calls for common mistakes: incorrect buffer bindings, mismatched pipeline state, out-of-bounds buffer slices, and resource lifecycle violations. These layers run automatically during development and surface errors as log messages or panics, saving hours of debugging silent GPU corruption. The tradeoff: validation adds measurable overhead to every frame. In release builds, disable validation by omitting
InstanceFlags::VALIDATIONwhen creating theInstance, or set theWGPU_VALIDATION=0environment variable.
Step 5 — SurfaceConfiguration: This allocates the
swapchain framebuffers.
We negotiate the pixel format with the driver (preferring an
sRGB format for correct color display), pick the
window dimensions (clamped to at least 1x1 to allow minimize-and-restore on some
platforms), and select the present mode.
PresentMode::Mailbox is a triple-buffered present mode that provides
consistent 60fps without tearing on most platforms.
desired_maximum_frame_latency: 2 tells the swapchain to keep two frames of
back pressure, smoothing out frame time spikes.
Steps 6 through 8 — shader module compilation, vertex buffer upload, and render pipeline assembly — will be explored in detail in the next sections.
S4: Writing the Shaders
New concept: shaders are GPU programs. A shader is a function or set of functions that runs on the GPU, compiled once at pipeline creation time, then executed thousands of times in parallel. Each invocation operates on different data but follows the identical instruction sequence. There is no heap allocation, no recursion, no I/O, and no shared mutable state. The GPU runs every invocation of a shader in lockstep: if one thread takes a different branch, the entire wavefront serializes both paths and discards the dead result. This is why you write shaders differently from CPU code — you optimize for parallelism and branchless arithmetic.
A shader module can contain multiple entry points. For rendering, the two mandatory entry points are the vertex shader and the fragment shader. The vertex shader runs once per vertex. The fragment shader runs once per fragment — that is, once per pixel covered by the rasterized primitive.
Key insight #1 — Interpolation is free hardware: The vertex shader outputs per-vertex colors at
@location(0). The rasterizer automatically interpolates them across the triangle surface using barycentric coordinates. The fragment shader just returns whatever it receives. The rainbow gradient is not programmed — it is a consequence of the pipeline architecture. You supply colors at three points; the hardware computes every color in between at zero shader cost.
Why WGSL: WebGPU Shading Language (WGSL) is the single source format. wgpu compiles it to the platform-native intermediate at runtime: SPIR-V for Vulkan, MSL for Metal, DXIL for DirectX. You write one shader file and wgpu produces the right binary for every backend.
Why include_str!("shader.wgsl"): This Rust macro embeds the file contents
at compile time. The shader source becomes a string literal inside your binary.
At runtime there is zero file I/O. No paths to resolve, no loading failures,
no async reads. If the file is missing or malformed, the build fails, not the
runtime.
The Complete Shader
Create shader.wgsl in your project root (at the same level as main.rs):
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) vertex_color: vec3<f32>,
};
@vertex
fn vs_main(
@location(0) position: vec3<f32>,
@location(1) color: vec3<f32>,
) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(position, 1.0);
out.vertex_color = color;
return out;
}
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(input.vertex_color, 1.0);
}
Line-by-Line Walkthrough
struct VertexOutput { ... } — Defines the data flowing between the vertex
shader and the fragment shader. This struct is not a Rust type and not a buffer
layout — it is the output contract of the vertex shader that the rasterizer
carries through to the fragment shader.
@builtin(position) clip_position: vec4<f32> — @builtin(position) is a
reserved GPU output slot. Every vertex shader must produce a vec4<f32> at
this slot. This value is the vertex position in clip space.
The GPU uses it for perspective division (dividing x, y, z by w to produce
ndc) and clipping. In our triangle, the w
component is 1.0, so perspective division is the identity operation — our
positions are already in the right space.
@location(0) vertex_color: vec3<f32> — @location(0) marks this field
for interpolation. Any @location(n) output from the vertex shader that is
not a builtin is automatically interpolated by the rasterizer using barycentric
weights. At each vertex, the value is exact. Inside the triangle, it is the
weighted blend of all three vertex values. The fragment shader receives a
different vertex_color for every pixel, without any manual interpolation code.
Key insight #2 — THE LOCATIONS MUST MATCH:
shader_location: 0in Rust'sVertexAttributeMUST equal@location(0)in WGSL's parameter annotation. If they differ, the shader reads from the wrong memory offset and produces garbage. This is not a type error or a runtime panic — it is silent data corruption. The GPU reads whatever bytes live at the mismatched offset and interprets them as floats.
@vertex fn vs_main(...) — @vertex declares this function as the vertex
shader entry point. The function is invoked once per vertex in the draw call.
For our triangle with three vertices, vs_main runs exactly three times.
@location(0) position: vec3<f32> — This input parameter receives data
from the vertex buffer mapped by shader_location: 0. In our Rust
VertexBufferLayout, the first VertexAttribute reads 3 floats at offset 0
and delivers them to the shader at location 0. This is the raw NDC position.
@location(1) color: vec3<f32> — The second vertex buffer attribute
mapped to location 1. Reads 3 floats at the offset after the position
(12 bytes into each vertex) — the per-vertex RGB color.
var out: VertexOutput; — Local variable declaration. WGSL requires
explicit variable bindings. var creates a mutable local.
out.clip_position = vec4<f32>(position, 1.0); — Converts the vec3
input into homogeneous coordinates
by appending w = 1.0. This promotes the position from 3D to clip space. With
w = 1.0, perspective division (x/w, y/w, z/w) leaves the coordinates unchanged.
If we were using perspective projection, the vertex shader would compute a
nontrivial w value from the depth.
out.vertex_color = color; — Passes the input color through to the output.
The rasterizer picks this field up, interpolates it across the triangle surface,
and delivers the interpolated value to every fragment.
@fragment fn fs_main(input: VertexOutput) — @fragment declares the
fragment shader entry point. input is the rasterizer's interpolated output
from the vertex shader. Every @location(n) field in VertexOutput is now
pre-blended with barycentric weights.
Key insight — TWO
@builtin(position)builtins, zero connection: Vertex@builtin(position)and fragment@builtin(position)are two completely separate builtins that happen to share the same name. The vertex shader outputs clip-space coordinates into@builtin(position)for the rasterizer to perform perspective division and viewport transform. The fragment shader receives an entirely different@builtin(position)injected by the fragment stage, providing framebuffer pixel coordinates:x/yare the pixel center within the viewport,zis the depth value (typically [0, 1]), andwis the interpolated reciprocal of the vertex clip-space w-coordinate (1/w). The vertex shader's position output is NOT passed to the fragment shader's position input. They are independent builtins from different pipeline stages. If you need to pass data from vertex to fragment with interpolation, use@location(N)on regular struct fields — which is exactly whatvertex_colordoes in our shader.
-> @location(0) vec4<f32> — The fragment shader must output at least one
color value at @location(0). This number must match the corresponding color
target in the render pipeline descriptor. The
return type is vec4<f32> — RGBA with linear-space components.
return vec4<f32>(input.vertex_color, 1.0); — Promotes the interpolated
RGB color to RGBA by setting alpha = 1.0 (fully opaque). The
rasterizer interpolated input.vertex_color
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
The Rust side loads the shader file at compile time and feeds the source to wgpu:
let shader_module = device.create_shader_module(
wgpu::ShaderModuleDescriptor {
label: Some("Rainbow Triangle Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
}
);
ShaderModuleDescriptor— has two fields:label(debug string, shown in graphics debuggers and validation messages) andsource(the shader text).ShaderSource::Wgsl(...)— wraps the WGSL string. wgpu also accepts SPIR-V binary source viaShaderSource::SpirV, but WGSL is the native path.device.create_shader_module()— takes the descriptor and parses + validates the shader. On Vulkan, wgpu translates WGSL to SPIR-V internally. If the shader has syntax errors, type mismatches, or unresolved entry points, this call returns an error.&shader_module— the resulting handle is passed by reference into the render pipeline descriptor. The module remains valid for the lifetime of the pipeline.
S5: Uploading Vertex Data to the GPU
New concept: GPU memory isolation. The GPU cannot read Rust heap or stack memory directly. Vertex data must be laid out as a flat byte array and uploaded into a dedicated GPU buffer slice. The pipeline configuration then describes how to interpret those bytes: how many bytes per vertex, what format each attribute has, and where in the vertex strides the attribute begins.
Key insight #3 —
create_buffer_initis an extension trait: The method lives inwgpu::util::DeviceExt, not onDevicedirectly. If you calldevice.create_buffer_init(...)without importing the trait, the compiler reports "method not found." This is a Rust trait-discovery issue, not a wgpu API issue. Adduse wgpu::util::DeviceExt;to bring the method into scope.
The Vertex Struct
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
color: [f32; 3],
}
#[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 becausePodalone does not guarantee zero is a valid discriminant for enums or optional types. Combined with Pod, it enablesbytemuck::cast_sliceto convert between&[Vertex]and&[u8]without aunsafeblock.
Vertex Data
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
];
- Positions are in NDC: The normalized device coordinates range from -1.0 (left/bottom) to +1.0 (right/top). Our triangle spans the bottom half of the screen: the bottom-left corner at (-0.5, -0.5), the bottom-right at (0.5, -0.5), and the top center at (0.0, 0.5). This produces an upright, centered triangle.
- CCW winding order: The vertices are listed counter-clockwise: red → blue → green. In a standard right-handed coordinate system, connecting vertices in this sequence traces the triangle counter-clockwise. This determines which face is "front" and which is "back" — critical for culling and correct normal computation.
Buffer Upload
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,
}
);
use wgpu::util::DeviceExt— imports the extension trait that addscreate_buffer_inittoDevice. Without this import, the method is not visible.device.create_buffer_init(...)— combined allocate-and-upload. It creates a GPU buffer, allocates system memory, copies thecontentsslice into staging storage, and issues a synchronous copy to GPU memory. This is a convenience wrapper aroundcreate_buffer+queue.write_buffer.bytemuck::cast_slice(VERTICES)— converts&[Vertex; 3]to&[u8]by reinterpreting the same memory at a byte level. The GPU receives 72 bytes: three vertices × 24 bytes per vertex (6 ×f32= 6 × 4 bytes). No copy, no serialization — just a pointer reinterpretation.BufferUsages::VERTEX— declares this buffer will be bound as a vertex buffer in the pipeline. wgpu's validation layer will reject any attempt to use this buffer for staging, uniform, or storage access. Usage bits are chosen at creation and cannot be changed.
S6: Compiling the Render Pipeline
New concept: the render pipeline is a compiled GPU configuration. A render pipeline bundles every decision the GPU needs to execute a draw: which shaders to run, how to interpret vertex buffer bytes, what topology to use, whether to cull back faces, what blend mode to apply, and where to write the output. Pipeline creation is not a simple struct allocation — it compiles these choices into a 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
Before the pipeline descriptor, you must tell wgpu how to parse the byte stream in the vertex buffer into per-vertex attributes:
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,
},
],
};
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 uses this to step through the buffer: vertex 0 starts at byte 0, vertex 1 at byte 24, vertex 2 at byte 48.step_mode: Vertex— advance the buffer by one stride for every vertex the vertex shader processes. The other option isInstance, which advances per draw instance in instanced rendering. For a single triangle,Vertexis correct: each of the three vertices has its own position and color.- First attribute —
shader_location: 0: reads 3 floats (F32x3) at byte offset 0 of each vertex. These 3 floats map to the shader location@location(0)in the vertex shader — thepositionparameter. The GPU delivers[x, y, z]to that function argument. - Second attribute —
shader_location: 1: reads 3 floats at offset 12 (size_of::<[f32; 3]>()= 3 × 4 = 12). Skips past the position array to the color array inside each vertex. Maps to@location(1)in the shader — thecolorparameter. If the offset were 0 instead of 12, the shader would receive the position values as the color input, rendering a triangle with gradient colors derived from position data.
The Complete Render Pipeline Descriptor
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,
});
Field-by-Field Walkthrough
RenderPipelineDescriptor has 9 fields. Every field must be present. The
structure does not use ..Default::default() at the descriptor level — each
field is filled explicitly.
label: Some("Triangle Pipeline") — Debug string. Shown in GPU profilers
(RenderDoc, Nvidia Nsight) and wgpu validation error messages. Omitting it
produces anonymous pipelines that are impossible to trace during debugging.
layout: None — Derives the pipeline layout from the shader module
automatically. When no push constants or bind groups are used, None tells wgpu
to infer the layout. If you later add @group(n) bindings to your shader, you
must provide a RenderPipelineLayout created with device.create_render_pipeline_layout().
vertex — VertexState (4 fields):
module: &shader_module— references the compiled shader module from S4.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. 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 hints. Default uses the backend's standard flags for optimization and SPIR-V version.
primitive — PrimitiveState (7 fields):
topology: TriangleList— every 3 consecutive vertices form one triangle. For 3 vertices, this produces exactly 1 triangle. If we had 6 vertices, it would produce 2 independent triangles.strip_index_format: None— only set forTriangleStriporLineStriptopologies when using restart indices. Not applicable toTriangleList.front_face: Ccw— counter-clockwise winding defines the front face of a triangle. Combined withcull_mode, this determines which triangles are visible. Because our vertices are listed CCW in S5, triangles drawn in that order face toward the viewer.cull_mode: Some(wgpu::Face::Back)— discard triangles whose winding indicates a back face. For a single triangle viewed from the front, this is harmless but establishes correct culling for 3D geometry where back faces are guaranteed not to be visible.unclipped_depth: false— depth values outside [0.0, 1.0] are clipped (the standard behavior).trueallows depth values beyond the normal range to pass through — used for specific depth-testing tricks.polygon_mode: Fill— render the full interior of the triangle. Other options areLine(wireframe edges) andPoint(vertex points only).conservative: false— the rasterizer fragments only pixels provably inside the triangle.truefragments every pixel that might intersect the triangle — used for conservative rasterization (shadow volumes, occlusion queries).
depth_stencil: None — No depth buffer or stencil buffer. Without depth
testing, triangles are drawn in submission order: later draws overwrite earlier
draws at the same pixel. For a single triangle this is not a concern.
multisample — MultisampleState (3 fields):
count: 1— no multisampling. Each pixel produces one fragment. Higher values (2, 4, 8) activate MSAA, sampling multiple points per pixel and reducing aliasing at the cost of framebuffer bandwidth.mask: !0— all sample bits are enabled. This mask allows you to selectively disable individual MSAA samples (advanced use case).alpha_to_coverage_enabled: false— do not use the alpha channel of the fragment color as a coverage mask. Enabled for transparent edge antialiasing (e.g., font rendering).
fragment — FragmentState (4 fields):
module: &shader_module— same shader module as the vertex shader.entry_point: Some("fs_main")— selects the fragment shader entry point. Must match@fragment fn fs_main(...)in the WGSL.targets— array of color target states, one per render pass output attachment.&[Some(...)]means one color target present.Noneat this index would mean a render pass with no color output (e.g., depth-only pass).ColorTargetStatehas exactly 3 fields (noview_formatsfield):format: config.format— MUST match the surface format fromSurfaceConfiguration. 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 (REPLACEmode). With blending, new and existing colors are 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.
compilation_options: Default::default()— fragment shader compilation flags, same as the vertex compilation options above.
multiview_mask: None — no multiview rendering. Multiview is for
stereoscopic (VR) or multi-viewport single-pass rendering. Not used here.
cache: None — no pipeline cache. A pipeline cache stores compiled shader
binaries to speed up subsequent pipeline creation. Useful when creating many
pipelines dynamically; for a single pipeline, caching has no practical benefit.
S7: The Render Loop — Recording and Submitting Commands
New concept: command buffers are scripts, not function calls. You cannot call
GPU operations directly from CPU code. Instead, you record commands into a
command buffer — a script that the GPU
queue executes asynchronously. Think of it like building an assembly listing:
each recording method appends an instruction. When the script is complete, you
submit it atomically to the queue. The GPU executes
all instructions in parallel, in whatever order it determines is optimal. There
is no .await on a draw call. The CPU returns immediately after submission and
continues the next frame while the GPU works in the background.
Key insight #4 — Command buffers are scripts, not function calls:
create_command_encoder()opens a recording session.begin_render_pass()starts a scoped drawing block.render_pass.draw()appends a draw command.encoder.finish()seals the script.queue.submit()dispatches it. The GPU executes it later, in parallel. There is no.awaiton a draw call.
Render Loop Cycle
[RedrawRequested event]
│
▼
get_current_texture() → [Success?] → Yes → record commands
│ │
└── No (Timeout/Occluded) → skip frame │
▼
device.poll() → encoder.begin_render_pass() → draw() → submit() → present()
The render(&mut self) Method Signature
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
and fast — they only encode instructions and push them to the queue; they do not
wait for GPU completion.
Acquiring a Back Buffer from the Swapchain
let frame = self.surface.get_current_texture();
get_current_texture() is how you acquire a back buffer from the
swapchain. This is the framebuffer you render
into for this frame. In a triple-buffered swapchain (PresentMode::Mailbox),
there are up to two spare back buffers waiting for you. get_current_texture()
hands you the next available one.
In wgpu 29, this method returns CurrentSurfaceTexture, a standalone enum with
7 variants describing the state of the swapchain's next back buffer:
Key insight #5 — 7 surface texture variants you must handle:
CurrentSurfaceTexture::Success(frame)— render normally.CurrentSurfaceTexture::Suboptimal(frame)— render (buffer available but not ideal, e.g., format mismatch).CurrentSurfaceTexture::Timeout— skip frame (GPU late).CurrentSurfaceTexture::Occluded— skip frame (window fully covered).CurrentSurfaceTexture::Outdated— surface changed, reconfigure.CurrentSurfaceTexture::Lost— surface destroyed, cannot recover without re-init.CurrentSurfaceTexture::Validation { source, description }— API validation caught an error, skip frame and log.
WHY match on the enum: get_current_texture() returns a
CurrentSurfaceTexture enum, not a Result. You match on the variant
directly. Success and Suboptimal both carry a SurfaceTexture you can
render into — the only difference is that Suboptimal signals the buffer may
not be ideal (e.g., a format downgrade). The Rust compiler enforces exhaustive
matching across all 7 variants.
The Complete render Implementation
fn render(&mut self) {
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
wgpu::CurrentSurfaceTexture::Timeout => {
log::warn!("Surface timeout — skipping frame");
return;
}
wgpu::CurrentSurfaceTexture::Occluded => {
log::warn!("Surface occluded — skipping frame");
return;
}
wgpu::CurrentSurfaceTexture::Outdated => {
log::warn!("Surface outdated — resizing");
let size = self.window.inner_size();
self.resize(size);
return;
}
wgpu::CurrentSurfaceTexture::Lost => {
log::error!("Surface lost — GPU resources invalidated; full re-init required");
// Production recovery: signal App to drop `self.state`,
// then recreate on the next RedrawRequested or in a
// dedicated recovery callback. See callout below.
return;
}
wgpu::CurrentSurfaceTexture::Validation { source, description } => {
log::error!("Surface validation error: {:?} — {}", source, description);
return;
}
};
// Drive GPU work: shader compilation, memory allocation, fence signaling
if let Err(e) = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }) {
log::error!("Device poll failed: {e}");
return;
}
let texture_view = frame.texture.create_view(&Default::default());
let mut encoder = self.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor {
label: Some("Main Command Encoder"),
},
);
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Main Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &texture_view,
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,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.draw(0..3, 0..1);
} // render_pass drops here — render pass ends automatically
self.queue.submit(std::iter::once(encoder.finish()));
frame.present();
}
Step by Step
surface.get_current_texture() — Acquires the next available back buffer
from the swapchain. The swapchain cycles through
2–3 pre-allocated back buffers. This call returns immediately if a buffer is
available; it does not block on the GPU.
Surface Lost recovery pattern:
Lostmeans the compositor destroyed the surface (display server restart, GPU reset, hotplug, etc.). Every GPU resource tied to that surface — theSurface,Device,Queue, pipeline, buffers — is irrecoverably invalidated. You cannot reuse any of them. The production pattern is to setself.state = NoneinApp, then on the nextRedrawRequested(or in a dedicated recovery callback), re-run the fullState::new()initialization chain from S3. This recreates the adapter, device, surface, and all child resources. Without this, continued renders against a lost surface will either panic or silently produce corrupted output.
device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }) — Synchronous call that drives
in-flight GPU work to completion: shader compilation fences, memory allocation,
and queue signaling. Without this, resources accumulate because the device does
not reclaim finished work. Called once per frame. Returns
Result<(), MaintainError> — if the device is lost, you recover by
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.
texture.create_view(&Default::default()) — A texture view
is how wgpu references a texture's memory inside a render pass. The GPU does
not accept raw texture handles in render pass attachments — it requires a view
that describes the mip level range, aspect, and dimension format.
Default::default() creates a full-view covering all mip levels and all aspects.
device.create_command_encoder(&desc) — Opens a recording session. The
command encoder is where you append
instructions. Think of it as building a function body: you add statements, then
finish() closes the function and returns the compiled buffer.
encoder.begin_render_pass(&desc) — Starts a scoped drawing block. The
render pass descriptor defines the target
attachments (color, depth, stencil). The returned RenderPass is a scoped
guard — when it drops, the render pass ends automatically.
Render Pass Color Attachment
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &texture_view,
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 }),
store: wgpu::StoreOp::Store,
},
})],
RenderPassColorAttachment has exactly 4 fields:
view: &texture_view— the framebuffer we draw into. Must match the color target format in the render pipeline.depth_slice: None— only used for 3D texture slices. Not applicable to 2D rendering.resolve_target: None— only used for MSAA resolve. When multisampling is active, the render pass writes to a multisampled buffer and resolves into this target. We have no MSAA, soNone.ops— 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.LoadOp::Loadkeeps existing pixels (used in UI compositing where you draw on top of previous content).store: StoreOp::Store— after drawing, keep what was written. The GPU writes the result back to the texture so the swapchain can present it.StoreOp::Discardthrows away the result — used for offscreen renders where only the depth/stencil result matters.
depth_stencil_attachment: None — No depth or stencil buffer. When you
have a depth texture, it goes here.
timestamp_writes: None — GPU hardware timestamps for profiling. Not used
in production rendering; requires a query set.
occlusion_query_set: None — hardware occlusion queries (count fragments
that pass the depth test). Useful for visibility-based culling.
multiview_mask: None — multiview rendering mask for VR / multi-viewport.
Binding State and Drawing
render_pass.set_pipeline(&self.pipeline) — Tells the GPU which
render pipeline to use for subsequent
draw calls. The pipeline encapsulates the shader programs, vertex format,
primitive topology, and output configuration. Must be set before any draw call
in a render pass. Switching pipelines mid-pass is expensive and should be
minimized.
WHY this is necessary: the GPU hardware does not store pipeline state between frames. Every render pass starts with no pipeline bound. You must set it every frame.
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)) — Binds the
vertex buffer to slot 0.
buffer.slice(..) creates a buffer slice
covering the full buffer (equivalent to buffer.slice(0..)). Slot 0 corresponds
to the first layout in the pipeline's vertex buffer layouts array. If you had
multiple vertex buffers (e.g., separate position and instance buffers), you'd
bind them to slots 0, 1, etc.
render_pass.draw(0..3, 0..1) — The draw command. Two Range<u32>
arguments:
- First range
0..3— vertex range. Draw vertices 0, 1, 2 (three vertices forming one triangle). - Second range
0..1— instance range. Draw instance 0 (one instance).
WHY two ranges: the vertex range controls which vertices from the buffer are
read. The instance range controls instanced rendering — the same geometry drawn
multiple times with different instance-data attributes. For a single triangle,
one draw call with 0..1 instances is correct.
Render pass scope drop — When the render_pass variable goes out of scope
(the closing } in the block), the drop implementation ends the render pass
and performs validation. If you forgot to set the pipeline or bind a required
buffer, wgpu reports the error at drop time, not at draw time.
encoder.finish() — Seals the command encoder. Returns the finished
command buffer ready for submission.
After finish(), the encoder cannot be used again.
queue.submit(iter) — Dispatches one or more command buffers to the GPU.
Takes an iterator of command buffers. We submit exactly one: the frame's command
buffer. This is a fire-and-forget call — it queues the work and returns
immediately. The GPU executes it asynchronously, in parallel with your next
frame's CPU work.
surface_texture.present() — Queues the rendered back buffer for display.
This tells the swapchain: "this buffer is done, show it on screen." If you
forget this, you render to a buffer nobody sees. The swapchain cycles the
buffer from "render target" to "front buffer" on the next vsync.
Why the Match Arms Differ
CurrentSurfaceTexture::Success(frame)/Suboptimal(frame)— the swapchain delivered aSurfaceTextureyou can render into.Successmeans the buffer is ideal.Suboptimalmeans the buffer is available but may not be ideal (e.g., format mismatch, downgraded resolution). Both carry the sameSurfaceTexture. Extractframe.textureto create a view, render, then callframe.present().CurrentSurfaceTexture::Timeout— the GPU exceeded the wait threshold for a back buffer. Skip the frame. The GPU will catch up.CurrentSurfaceTexture::Occluded— the window is fully covered by another window. Skip the frame; there's no point rendering to an invisible surface.CurrentSurfaceTexture::Outdated— the swapchain was created for a resolution that no longer matches the window. Reconfigure the surface usingself.window.inner_size()to match the current dimensions.CurrentSurfaceTexture::Lost— the GPU or display server has been reset. Without re-creating the device and surface, you cannot recover. In a real application, you'd trigger a full re-initialization.CurrentSurfaceTexture::Validation { source, description }— the wgpu validation layer caught an API misuse. Log the diagnostic and skip the frame.
S8: Handling Window Resize
WHY surface.configure() on resize: The swapchain allocates back buffers at a
fixed dimension. When the window size changes, the old back buffers no longer
match the window's display surface. Presenting a mismatched-size buffer causes
undefined behavior — the display server clips, stretches, or rejects it.
surface.configure() allocates new back buffers matching the new dimensions and
discards the old ones.
WHY width.max(1): On some display servers, minimizing a window briefly
reports 0 × 0 size before restoring. A zero-dimension surface allocation
panics. Clamping to 1 ensures the swapchain always has valid dimensions.
WHY std::mem::take(&mut self.config.view_formats): The view_formats field
of SurfaceConfiguration is an owned Vec<TextureFormat>. When constructing
the new configuration, you move the vector out of the old config rather than
cloning it. mem::take replaces the field with Vec::new() (zero allocation)
and returns the original vector. This avoids a heap allocation for what is
typically a 1-element vec.
fn resize(&mut self, size: wgpu::dpi::PhysicalSize<u32>) {
if size.width > 0 && size.height > 0 {
let config = wgpu::SurfaceConfiguration {
usage: self.config.usage,
format: self.config.format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: self.config.present_mode,
desired_maximum_frame_latency: self.config.desired_maximum_frame_latency,
alpha_mode: self.config.alpha_mode,
view_formats: std::mem::take(&mut self.config.view_formats),
};
self.surface.configure(&self.device, &config);
self.config = config;
}
}
FIELD BY FIELD:
usage / format / present_mode / alpha_mode — carried over from the
old config unchanged. These properties are negotiated once at init time
and do not change on resize.
width / height — the new dimensions, clamped to at least 1.
desired_maximum_frame_latency — swapchain back-pressure setting. Kept from
the old config. This value controls how many frames the swapchain buffers
between CPU submission and GPU presentation. A value of 2 (triple buffering)
provides smooth frame pacing under variable CPU/GPU load. See S3 init step 5.
view_formats — additional texture formats the surface can create views
with. std::mem::take() moves the owned vector from the old config into the
new config. After take(), the old config's view_formats is an empty Vec.
This avoids a clone() of the vector. Since the old config is about to be
overwritten by self.config = config, the emptied field is irrelevant.
surface.configure(&self.device, &config) — takes a reference to the
Device and the new SurfaceConfiguration. This is not async. It allocates the
new swapchain buffers and replaces the old ones. Any in-flight renders using
old buffers complete normally; the new buffers are available after this call
returns.
When resize Is Called
In our App::window_event handler (S2), the WindowEvent::Resized(size) arm
calls state.resize(size). Since State owns an Arc<Window> (see S3),
resize() has access to the window internally and needs only the new
dimension. The resize fires once for every dimension change. On fast window resizing, you may receive dozens of resize events in
succession. surface.configure() is fast enough to handle this — each call
discards old buffers and allocates new ones. The GPU continues processing
in-flight frames with the old buffer dimensions; there is no visual glitch
because the swapchain handles the transition seamlessly.
S9: Where All the Code Goes
The full source is the codeblocks in sections S2–S8, assembled in order into
src/main.rs and src/shader.wgsl.
File Structure
src/
├── main.rs # Sections S2, S3, S5 (structs), S7 (render), S8 (resize)
├── shader.wgsl # Section S4 (the complete WGSL shader)
main.rscombines the winit event loop (S2), the init chain andStatestruct (S3), theVertextype andVERTICESconstant (S5), therendermethod (S7), and theresizemethod (S8).shader.wgslis the single file from S4: vertex shader, fragment shader, and theVertexOutputstruct.
Refer to concepts/GLOSSARY.md for term definitions used throughout these sections. See TROUBLESHOOTING.md for common issues and their fixes.
S10: Running It
Run the project:
cargo run
Expected console output: wgpu adapter info (GPU model, driver name), shader
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
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.
Expected CPU usage: 100% on one core due to ControlFlow::Poll driving a
continuous redraw loop. This is normal for a demo that redraws every vsync.
See TROUBLESHOOTING.md for common issues.
S11: What You've Learned and What's Next
Summary
You have built a complete GPU-rendered application from scratch. Here is what each piece does:
-
The 5-layer wgpu init chain: Instance → Surface → Adapter → Device + Queue → SurfaceConfiguration. Each layer adds a capability: driver connection, window binding, GPU selection, resource management, and swapchain allocation.
-
The render pipeline: Shaders, topology, and vertex layout compiled into a GPU configuration. Created once, reused every frame. Expensive to create, cheap to execute.
-
The command buffer model: Record instructions on the CPU, submit atomically to the queue, GPU executes asynchronously. No
.awaiton a draw call. -
The swapchain and framebuffer: Double-buffered rendering through PresentMode::Mailbox. Acquire a back buffer, render into it, present it to the display.
-
GPU interpolation: Vertex attributes automatically blended across triangle surfaces. You supply values at three points; the rasterizer computes every value in between.
What's Next
With the render loop and pipeline foundation in place, the next steps are:
- Textures and bind groups — loading images onto the GPU and sampling them in fragment shaders
- Uniforms and 3D transforms — projection, view, and model matrices for positioning geometry in 3D space
- Lighting and material models — diffuse, specular, and PBR shading
- Depth buffering and z-fighting — per-pixel depth testing for correct overlap ordering
- Compute shaders and GPU compute pipelines — general-purpose GPU computation outside the graphics pipeline
Prerequisite note — matrix math: Every topic above ultimately depends on matrix mathematics. Transforms (model, view, and projection matrices) move geometry from local object space through world space, camera space, and finally into clip space. In this tutorial, all vertex positions are hardcoded NDC coordinates so we can focus on the rendering pipeline itself. Real applications compute these coordinates via matrix multiplication: a transformation matrix is uploaded to the GPU as a uniform, and the vertex shader multiplies each vertex by that matrix before outputting
clip_position. If linear algebra is unfamiliar, study it before diving into the next tutorials. Recommended resources: Learn OpenGL's linear algebra section for a graphics-oriented treatment, and 3Blue1Brown's Essence of Linear Algebra for an intuitive visual foundation.
Keep concepts/GLOSSARY.md handy as you move forward.