docs: write sections S1-S3 (intro, app skeleton, init chain)

This commit is contained in:
2026-05-30 17:42:21 -05:00
parent dbe6bdee9a
commit 4d429cf212

440
docs/01-rainbow-triangle.md Normal file
View File

@@ -0,0 +1,440 @@
# 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](concepts/GLOSSARY.md#rasterizer)
applies to every [varying](concepts/GLOSSARY.md#varying).
If you haven't read the [concept overview](concepts/graphics-pipeline.md), do so
now. [Coordinate systems](concepts/coordinate-systems.md) explains how the GPU
positions geometry. [Shader basics](concepts/shader-basics.md) 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](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.
### 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
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.
- **`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
boundary.
- **`ControlFlow::Poll`** — continuous redraw mode. winit fires
`RedrawRequested` as 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](concepts/GLOSSARY.md#present-mode)
controls the actual vsync behavior.
### Dependencies
Add these to your `Cargo.toml`:
```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 via `log` when misconfigurations or driver issues are detected.
### Complete Code
```rust
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(window, 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](concepts/GLOSSARY.md#swapchain) [present mode](concepts/GLOSSARY.md#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:
1. **[Instance](concepts/GLOSSARY.md#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.
2. **[Surface](concepts/GLOSSARY.md#surface)** — binds the instance to a
specific window's swapchain. The surface is the wgpu representation of the
window's display buffer.
3. **[Adapter](concepts/GLOSSARY.md#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).
4. **[Device](concepts/GLOSSARY.md#device) + [Queue](concepts/GLOSSARY.md#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.
5. **[SurfaceConfiguration](concepts/GLOSSARY.md#surface-configuration)** —
allocates the swapchain [framebuffers](concepts/GLOSSARY.md#framebuffer) for
this window at a specific resolution and pixel format.
### The State Struct
```rust
struct State {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
}
```
- **`surface`** — connects to the window's display buffer. The `'static` lifetime
is safe because `App` owns the window and lives for the entire lifetime of the
process. The surface mediates all [swapchain](concepts/GLOSSARY.md#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](concepts/GLOSSARY.md#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](concepts/GLOSSARY.md#present-mode). When the window is resized,
we reconfigure the surface with updated dimensions.
- **`pipeline`** — the compiled [render pipeline](concepts/GLOSSARY.md#render-pipeline).
A render pipeline is an immutable configuration combining a shader, a vertex
buffer layout, a primitive topology, and a [color target](concepts/GLOSSARY.md#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
```rust
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,
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](concepts/GLOSSARY.md#command-buffer) that is submitted
to this queue. On Vulkan, the device corresponds to `VkDevice` and the queue
to a `VkQueue`.
**Step 5 — SurfaceConfiguration:** This allocates the
[swapchain](concepts/GLOSSARY.md#swapchain) [framebuffers](concepts/GLOSSARY.md#framebuffer).
We negotiate the pixel format with the driver (preferring an
[sRGB](concepts/GLOSSARY.md#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](concepts/GLOSSARY.md#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.