RustyVulkan GuideContents

Part 6 · Chapter 16 · ~7 min prose skim

The app shell

The code that holds everything together: the window and its event loop, keyboard and mouse, shutting down in the right order, and packaging the result as a Mac app.

Overview The big picture

An event loop drives everything

A desktop program doesn't run in a straight line. It hands control to the operating system's event loop, which calls back into the program whenever something happens: the app is ready to start, a key is pressed, the mouse moves, the window is resized or closed, or it's time to draw. RustyVulkan uses winit for this. App implements winit's ApplicationHandler interface, a set of methods winit calls as those events arrive.

The application's life cycle main App::new().run() resumed window, Vulkan, scene, passes winit event loop window_event: keys, resize, focus, close, redraw device_event: mouse look about_to_wait: ask to redraw RedrawRequested move camera render graph: one frame (Ch. 9–10) exit → App::drop ordered teardown, then exit status
After start-up, the program lives inside the event loop, drawing a frame every time it's asked to, until a close, Escape or the menu's Exit ends the loop.

Controls

It's a first-person view: W A S D walk over the terrain, the mouse looks around (the pointer is hidden and locked to the window), U toggles between mouse-look and a free pointer for the ImGui menus, F switches fullscreen, and Escape quits.

Starting, failing and stopping

Everything Vulkan is built only once winit says the app is "resumed" and a window can exist. If anything goes wrong while building, or later while drawing, the error is stored, the loop is told to exit, and the program ends with a failure exit code rather than crashing or carrying on half-broken. On the way out, everything is destroyed in reverse dependency order, as Chapter 2 described.

A Mac app

A packaging script turns the build into RustyVulkan.app, a self-contained application that carries its assets, the Assimp library, the Vulkan loader and the KosmicKrisp driver inside it, so the receiving Mac needs neither Rust nor the Vulkan SDK. It targets Apple Silicon Macs on macOS 26 or newer.

Back to contents →

Deeper dive

Running the loop

/// Runs the winit event loop until exit.
pub fn run(mut self) -> Result<(), Box<dyn Error>> {
    let event_loop = EventLoop::new()?;
    event_loop.set_control_flow(ControlFlow::Poll);
    event_loop.run_app(&mut self)?;
    match self.fatal_error.take() {
        Some(error) => Err(error),
        None => Ok(()),
    }
}
src/app/app.rs:156-165

ControlFlow::Poll keeps the loop spinning instead of sleeping until the next input event, which suits a renderer that wants to draw continuously. run_app blocks until the loop exits; any fatal error stored along the way is then returned to main, which turns it into a failure exit status (Chapter 1).

Start-up in resumed

fn resumed(&mut self, event_loop: &ActiveEventLoop) {
    // Winit can emit Resumed more than once. Keep the existing ownership
    // tree rather than replacing live GPU resources and the ImGui context.
    if self.window.is_some() {
        return;
    }
    match self.create_window(event_loop) {
        Ok(window) => self.window = Some(window),
        Err(error) => {
            self.fatal_error = Some(Box::new(error));
            event_loop.exit();
            return;
        }
    }
// ...
    match VulkanBase::new(self.window.as_ref().unwrap(), event_loop) {
        Ok(vulkan_base) => {
            self.vulkan_base = Some(vulkan_base);
        }
        Err(e) => {
            self.fatal_error = Some(e);
            event_loop.exit();
            return;
        }
    }
// ...
    if let Err(e) = self.set_up_scene() {
        self.fatal_error = Some(e);
        event_loop.exit();
        return;
    }

    self.render_graph.add(RenderPassNode::Shadow);
    self.render_graph.add(RenderPassNode::Main);
    let show = self.vulkan_base.as_ref().unwrap().engine_settings.show_ui;
    self.render_graph.set_pass_enabled(RenderPassNode::UI, show);
src/app/app.rs:465-478,490-499,517-526

The order is the order of the guide: window, ImGui context, the Vulkan backend (Chapter 3), the ImGui renderer, the scene (Chapter 12), and finally the passes. The guard at the top matters because winit's contract allows resumed to be called more than once (on macOS it fires once, but on mobile platforms it fires whenever the app returns to the foreground), and rebuilding would replace live GPU resources. DEVELOPMENT.md notes that the full mobile suspend-and-resume cycle isn't handled. The window is created with the pointer captured for first-person look:

/// Enables the cursor mode used while playing in first-person view.
pub fn capture_first_person_cursor(window: &Window) {
    // Locked works on most platforms; confined is a useful fallback for window systems
    // without relative-pointer support.
    if window
        .set_cursor_grab(winit::window::CursorGrabMode::Locked)
        .is_err()
    {
        window
            .set_cursor_grab(winit::window::CursorGrabMode::Confined)
            .ok();
    }
    window.set_cursor_visible(false);
}
src/app/app.rs:42-55

Events

fn device_event(
    &mut self,
    _event_loop: &ActiveEventLoop,
    _device_id: winit::event::DeviceId,
    event: DeviceEvent,
) {
    input::handle_device_event(self, event);
}

fn window_event(
    &mut self,
    event_loop: &ActiveEventLoop,
    window_id: winit::window::WindowId,
    event: WindowEvent,
) {
    input::handle_window_event(self, event_loop, window_id, event);
}

fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
    if let Some(window) = &self.window {
        window.request_redraw();
    }
}
src/app/app.rs:529-551

about_to_wait runs when the loop has handled all pending events. Requesting a redraw every time is what keeps frames coming; each RedrawRequested then moves the camera and runs the render graph (Chapter 9). Mouse look uses device events, the raw relative motion of the mouse, rather than the pointer position, so it keeps working while the hidden pointer is locked in place:

/// Handles raw relative motion only while the cursor is captured for first-person look.
pub fn handle_device_event(app: &mut App, event: DeviceEvent) {
    if !app.input.cursor_released
        && let DeviceEvent::MouseMotion { delta } = event
    {
        let (dx, dy) = (delta.0 as f32, delta.1 as f32);
        let sensitivity = 0.1;
        app.camera.rotate(dx * sensitivity, -dy * sensitivity);
    }
}
src/app/input.rs:40-49

Window events are first forwarded to ImGui, so it sees every click and key press, before the app handles them itself:

if let Some(win) = &app.window {
    if window_id != win.id() {
        return;
    }
    if let (Some(platform), Some(imgui)) = (app.platform.as_mut(), app.imgui.as_mut()) {
        let full_event: Event<()> = Event::WindowEvent {
            window_id: win.id(),
            event: event.clone(),
        };
        platform.handle_event(imgui.io_mut(), win, &full_event);
    }
src/app/input.rs:58-68

Two details in the window-event handler are worth noting. Losing focus clears all movement keys, because the window may never receive their release events. And U uses a small latch so that holding the key down, which sends repeated "pressed" events, toggles the pointer only once:

(Code(KeyCode::KeyU), Pressed) if !app.input.cursor_toggle_locked => {
    app.input.cursor_toggle_locked = true;
    app.input.cursor_released = !app.input.cursor_released;
    if app.input.cursor_released {
        release_first_person_cursor(win);
    } else {
        capture_first_person_cursor(win);
    }
}
(Code(KeyCode::KeyU), Released) => {
    app.input.cursor_toggle_locked = false;
}
src/app/input.rs:150-161

Resize handling was covered with the swapchain (Chapter 4): it records a request, updates the camera's aspect ratio, and ignores zero-sized (minimised) windows.

Shutting down

App::drop (excerpted in Chapter 2) waits for the GPU, then releases the render passes, sky, grass, materials and textures, meshes and the ImGui renderer, and only then lets VulkanBase go, which destroys the device's own objects, the allocator, the device, the surface and the instance. Because the backend is moved out of its field and dropped inside drop's body, it's gone before Rust drops the remaining fields, including the window: the Vulkan surface is destroyed before the window it belongs to.

Packaging a Mac app

scripts/package_macos.sh builds a release binary and assembles the bundle. The core of it:

mkdir -p "$MACOS_DIR" "$RESOURCES_DIR/vulkan/icd.d" "$FRAMEWORKS_DIR"

cp target/release/RustyVulkan "$MACOS_DIR/RustyVulkan"
ditto assets "$RESOURCES_DIR/assets"
# SPIR-V is embedded in the executable. Omit any legacy source-tree build output.
rm -rf "$RESOURCES_DIR/assets/shaders/spv"
cp packaging/macos/Info.plist "$CONTENTS/Info.plist"
cp packaging/macos/libkosmickrisp_icd.json \
    "$RESOURCES_DIR/vulkan/icd.d/libkosmickrisp_icd.json"
cp "$ASSIMP_SOURCE" "$FRAMEWORKS_DIR/libassimp.6.dylib"
cp "$VULKAN_SOURCE" "$FRAMEWORKS_DIR/libvulkan.1.dylib"
cp "$KOSMICKRISP_SOURCE" "$FRAMEWORKS_DIR/libvulkan_kosmickrisp.dylib"
# ...
install_name_tool -change "$ASSIMP_SOURCE" \
    @executable_path/../Frameworks/libassimp.6.dylib "$MACOS_DIR/RustyVulkan"
install_name_tool -change "$VULKAN_LINK_REFERENCE" \
    @executable_path/../Frameworks/libvulkan.1.dylib "$MACOS_DIR/RustyVulkan"
# ...
codesign --force --sign - "$FRAMEWORKS_DIR/libassimp.6.dylib"
codesign --force --sign - "$FRAMEWORKS_DIR/libvulkan.1.dylib"
codesign --force --sign - "$FRAMEWORKS_DIR/libvulkan_kosmickrisp.dylib"
codesign --force --deep --sign - "$APP_BUNDLE"
scripts/package_macos.sh:46-57,66-69,79-82

The bundled manifest tells the Vulkan loader where the driver library is, relative to the manifest itself:

{
    "file_format_version": "1.0.0",
    "ICD": {
        "library_path": "../../../Frameworks/libvulkan_kosmickrisp.dylib",
        "api_version": "1.4.0"
    }
}
packaging/macos/libkosmickrisp_icd.json:1-7

That's the file main.rs points VK_DRIVER_FILES at when it finds itself running inside a bundle (Chapter 1), completing the loop from the very first chapter. Info.plist supplies the bundle's name, identifier, minimum macOS version (26) and high-resolution support.

Where to go next

DEVELOPMENT.md lists the checks to run after any change (cargo fmt, cargo clippy, cargo test, and running with validation while resizing, minimising, toggling fullscreen and MSAA), and the known gaps: some start-up and upload paths that don't yet clean up after a partial failure and helpers that panic on Vulkan errors. Two stricter validation modes are also worth knowing, because they catch problems the default checks can't see:

VK_KHRONOS_VALIDATION_VALIDATE_SYNC=true cargo run   # synchronisation hazards
VK_KHRONOS_VALIDATION_GPUAV_ENABLE=true cargo run   # GPU-side checks, e.g. indirect draws
Stricter validation modes

Run them now and then. Many Vulkan mistakes, such as a missing wait or an unenabled feature, look perfectly fine on one GPU and break on another, and that's the lesson Vulkan keeps teaching: "it looks right" is not the same as "it's correct".

Recap