The problem
Most engine tutorials stop at "here's a spinning cube." I wanted to understand the parts nobody demos: how a swapchain is configured, why text needs its own atlas and cache, how resize actually propagates through three different subsystems. So this is an engine I have to take apart, not a game.
Why these choices
- wgpu over raw Vulkan. It abstracts Vulkan, Metal, and DX12 behind one API, so I can learn the concepts - adapter, device, queue, bind groups - without taking on each backend's boilerplate.
- WGSL shaders in separate files. Loaded with
include_str!so the source is embedded in the Rust binary at build time. Shader validation happens when the renderer creates its pipeline, not as a Rust compile error. - Polling loop, no vsync.
ControlFlow::Pollplusrequest_redraw()keeps redraws moving without waiting for a vsync event. It makes the loop easier to profile, though frame timing isn't deterministic. - imgui on wgpu. A debug overlay is the fastest way to see what state the renderer is actually in. Worth the extra pipeline.
- glyphon for text. Proper font shaping and an atlas I don't have to write. I get cached glyphs for free and only learn the atlas problem by reading their source.
Surface configuration
Adapter, then device, then the swapchain. The two things worth calling
out: requesting mailbox present mode, which presents
without tearing on backends that support it, and using the
same Bgra8UnormSrgb format for both the surface and the
glyph atlas so text doesn't need a second format path.
let mut config = surface
.get_default_config(&adapter, size.width, size.height)
.unwrap();
config.format = swapchain_format; // Bgra8UnormSrgb
config.present_mode = wgpu::PresentMode::Mailbox;
surface.configure(&device, &config);
A quad is four vertices
2D sprites are four Vertex structs built from a transform -
position plus scale, no index buffer. Keeping it this explicit meant I
never had to wonder about winding order or index offsets while I was
still learning the pipeline.
pub fn calculate_vertices(&self) -> [Vertex; 4] {
let (x, y, z) = (self.position[0], self.position[1], self.position[2]);
let (sx, sy) = (self.scale[0], self.scale[1]);
[
Vertex { position: [x - sx / 2.0, y - sy / 2.0, z], uv: [0.0, 0.0] },
Vertex { position: [x + sx / 2.0, y - sy / 2.0, z], uv: [1.0, 0.0] },
Vertex { position: [x + sx / 2.0, y + sy / 2.0, z], uv: [1.0, 1.0] },
Vertex { position: [x - sx / 2.0, y + sy / 2.0, z], uv: [0.0, 1.0] },
]
}
Handling window resize
A resize isn't one call, it's four: reconfigure the surface, update the text viewport, re-read the scale factor, and re-shape every cached text buffer. Miss the last one and text keeps the old line width. The early return on a zero dimension exists because minimize sends you one, and a zero-sized surface can't be configured.
pub fn handle_resize(&mut self, size: PhysicalSize<u32>) {
if size.height == 0 || size.width == 0 {
return; // minimize fires this
}
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
self.window.request_redraw();
self.text_renderer.viewport.update(&self.queue, glyphon::Resolution {
width: self.config.width,
height: self.config.height,
});
self.text_renderer.scale_factor = self.window.scale_factor() as f32;
self.text_renderer.physical_size = size.cast();
let logical_width = size.width as f32 / self.text_renderer.scale_factor;
for (_, b) in self.text_renderer.buffers.iter_mut() {
b.set_size(&mut self.text_renderer.font_system, Some(logical_width - 20.0), None);
b.shape_until_scroll(&mut self.text_renderer.font_system, false);
}
}
Current limitations
- No 3D yet. No camera, no glTF, no mesh loading. That's the whole point of the next phase and the reason it says "3D graphics" on the card rather than "game engine."
- Colours get randomised at startup as a leftover test. It's still in
Renderer::new. - Asset pools are a vector of paths with index-based handles. Removing an item shifts every id after it. A generation counter or a hash map fixes it.
- No input handling at all, so sprite repositioning is blocked until that lands.