← All projects

nivalis

A game engine in Rust on top of wgpu. Currently renders 2D - text, sprites, and a debug overlay. I built it to learn how the graphics stack works, not to ship a game.

WIP Rust wgpu winit glyphon imgui WGSL

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

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.

rustrenderer.rs
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.

rustlayer.rs
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.

rustrenderer.rs
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