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.
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.
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(()),
}
}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);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);
}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();
}
}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);
}
}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);
}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;
}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"- The executable goes in
Contents/MacOS, the assets and an installable client driver (ICD) manifest inContents/Resources, and the three libraries inContents/Frameworks. install_name_toolrewrites the executable's references to those libraries, which point at the build machine's Homebrew and SDK locations, to@executable_path/../Frameworks/…, so they're found inside the bundle wherever it's installed.- Everything is ad-hoc signed (signed with no developer identity): Apple Silicon only runs signed
code, and rewriting the library references invalidated the original signature. It isn't notarised
(checked by Apple), so
a recipient may need to right-click and choose Open the first time (
PACKAGING.md).
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"
}
}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
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
Appimplements winit'sApplicationHandler. Start-up happens once inresumed;about_to_waitrequests a redraw every iteration, and each redraw runs one frame.- Mouse look uses raw device motion; window events go to ImGui first; focus loss clears movement; U toggles the pointer for the UI.
- Errors are stored, end the loop, and become a failure exit code. Teardown runs in reverse dependency order, with the surface destroyed before the window.
- The packaging script builds a self-contained, ad-hoc-signed
.appcarrying assets, Assimp, the Vulkan loader, KosmicKrisp and the ICD manifest thatmain.rsselects.