RustyVulkan GuideContents

Part 1 · Chapter 01 · ~10 min prose skim

Why Vulkan, and a map of this repo

What changed when graphics programming moved from OpenGL to Vulkan, what one frame of this renderer looks like, and how the program gets itself running on a Mac.

Overview The big picture

There is a lot to set up before Vulkan draws anything. If you remember getting an OpenGL triangle on screen in fifty lines, a Vulkan version running to hundreds or even a thousand can feel like a wall. That upfront boilerplate is real: devices, memory, drawing state and synchronisation all need decisions that OpenGL made for you. This chapter starts with the setup, and the next chapters give those decisions meaning. You can follow the overviews first, then return to the source when you want the details.

OpenGL did a lot behind your back

OpenGL was designed in the early 1990s around a simple idea: the programmer sets some state ("this texture is bound, blending is on, use this shader") and then says "draw". The driver, the GPU vendor's software that sits between your program and the hardware, did everything else. It decided where in memory your textures lived. It checked every call for mistakes. It compiled your shaders. It worked out when it was safe to overwrite a buffer the GPU might still be reading. And it quietly waited, or copied, whenever it wasn't sure.

That made OpenGL approachable, but the driver had to guess what you'd do next, so a harmless-looking state change could cause a hidden stall or a shader recompile, and every vendor guessed differently. And because all that state lived in one global "context", using several CPU threads was awkward at best.

Vulkan makes you say it out loud

Vulkan, released by the Khronos Group in 2016 as OpenGL's successor, turns the deal around. The driver stops guessing. Instead, you tell it, explicitly and up front:

The result is famously verbose (a first triangle takes hundreds of lines), but the driver becomes thin and more predictable: more costs and dependencies are explicit, although the driver and operating system still affect performance. Much of this codebase is that explicit setup, and this guide is about reading it.

A few words you'll meet everywhere

Each of these gets its own chapter. For now, a one-line feel for them is enough:

Because Vulkan trusts you, mistakes aren't caught for you by default. During development you switch on validation layers: an optional checking layer that reports misuse as it happens. This project turns them on automatically in debug builds.

How this project is layered

RustyVulkan is written in Rust. Vulkan is a C API, so the project reaches it through ash, a thin Rust wrapper whose functions map almost one-to-one onto Vulkan's C functions. Below that, the stack depends on the operating system. Apple doesn't ship Vulkan; macOS's native GPU API is Metal. So on a Mac this project uses KosmicKrisp, a Vulkan driver that is implemented on top of Metal and ships with LunarG's macOS Vulkan SDK. The project targets Vulkan 1.3 and, per its README, needs an Apple Silicon Mac on macOS 26 or newer (KosmicKrisp requires Metal 4), the Vulkan SDK, and the Assimp model library from Homebrew.

The layers between RustyVulkan's code and the GPU main.rs → app/ window, input, scene setup, the event loop graphics/ meshes, textures, materials, shaders, pipelines, terrain, grass vulkan/ device, swapchain, frame loop, render graph, the passes, ImGui ash Rust bindings, nearly 1:1 with the Vulkan C API Vulkan loader finds the installed driver via a small JSON manifest KosmicKrisp a Vulkan driver implemented on Metal Metal → Apple GPU Elsewhere this would be the GPU vendor's own driver, but the project is set up for macOS.
Each layer only talks to the one directly below it.

One frame, end to end

Everything in later chapters exists to make this loop happen about 60 or more times a second. The renderer keeps two frames in flight: while the GPU draws one, the CPU prepares the next, each using its own "slot" of synchronisation objects. Each frame, the renderer:

The six steps of one frame 1 · Wait for the GPU to finish the frame that used this slot last time 2 · Acquire borrow the next swapchain image to draw into 3 · Record shadow pass → main pass → UI pass 4 · Submit hand the command buffer to the queue 5 · Present give the image back to be shown 6 · Advance move on to the next frame slot
The CPU races ahead and prepares the next frame while the GPU is still drawing the previous one. Steps 1 and 2 are where it waits, if it has to.

Compared with OpenGL, nothing here is implicit. There's no "swap buffers" call that quietly decides when work runs and when to wait. The renderer records the whole frame into a command buffer, submits it in one go, and every wait is one it asked for, using fences and semaphores (Chapter 9).

Skip to next overview →

Deeper dive

From here on we read the code itself. Excerpts are trimmed with // ...; the caption under each one gives the file and line range.

The repository at a glance

PathWhat lives thereChapters
src/main.rsProcess start-up: asset paths, driver selection, then hand off to App.1
src/app/The application: window, event loop, input, building the scene.12, 16
src/vulkan/The Vulkan backend: device setup, swapchain, per-frame sync, the render graph (which runs the three passes in order), and the renderer for the ImGui debug UI.3, 4, 9–12, 15
src/graphics/GPU resources and content: meshes, textures, materials, shaders, pipelines, camera, terrain, sky, rocks, grass.5–8, 11–14
assets/shaders/Shader source in GLSL (the same language OpenGL used), compiled to SPIR-V, Vulkan's binary shader format, by build.rs when you build.6, and wherever used
assets/meshes, assets/texturesModels (glTF/GLB: the hut, rocks, and test meshes such as the Sponza atrium and a duck) and texture images.5, 12
scripts/, packaging/Turning the build into a stand-alone macOS .app.16

The libraries it depends on, from Cargo.toml, are worth knowing by name because you'll see them in use lines throughout:

Declaring the modules

The top of main.rs tells the compiler which source folders make up the program:

mod app;
mod graphics;
mod vulkan;

use app::app::App;
use std::path::Path;
#[cfg(target_os = "macos")]
use std::path::PathBuf;
src/main.rs:11-18

main: configure, then run

fn main() -> std::process::ExitCode {
    if let Err(error) = configure_runtime() {
        eprintln!("Could not configure application runtime: {error}");
        return std::process::ExitCode::FAILURE;
    }
    if let Err(e) = App::new().run() {
        eprintln!("Application error: {}", e);
        return std::process::ExitCode::FAILURE;
    }
    std::process::ExitCode::SUCCESS
}
src/main.rs:84-94

configure_runtime fixes up the process environment, then App::new().run() runs the application until the window closes. If either fails, the error is printed and the process exits with a failure status.

Finding the assets when launched from Finder

The renderer loads models and textures by relative paths under assets/. That works under cargo run, which starts the program in the repository folder. When the app is double-clicked in Finder, though, the working directory is meaningless. So configure_runtime checks whether the executable lives inside a macOS app bundle (Something.app/Contents/MacOS/) and, if so, moves the working directory to the bundle's Contents/Resources, where the packaging script copies the assets (Chapter 16). Shaders are compiled during the build and embedded in the executable.

fn configure_runtime() -> std::io::Result<()> {
    let executable = std::env::current_exe()?;
    let bundle_resources = executable
        .parent()
        .and_then(|macos| macos.parent())
        .filter(|path| path.file_name().is_some_and(|name| name == "Contents"))
        .map(|contents| contents.join("Resources"));

    if let Some(resources) = &bundle_resources {
        std::env::set_current_dir(resources)?;
    }

    configure_kosmickrisp(bundle_resources.as_deref())?;
    Ok(())
}
src/main.rs:23-37

Choosing the Vulkan driver on macOS

A machine can have several Vulkan drivers installed. The Vulkan loader, the library that ash links to, discovers them through small JSON ICD manifests (ICD stands for "installable client driver"), each pointing at a driver library. The environment variable VK_DRIVER_FILES overrides discovery and names the manifest to use. RustyVulkan sets it to KosmicKrisp's manifest, unless you've already set it yourself for testing.

#[cfg(target_os = "macos")]
fn configure_kosmickrisp(bundle_resources: Option<&Path>) -> std::io::Result<()> {
    if std::env::var_os("VK_DRIVER_FILES").is_some() {
        return Ok(());
    }

    let mut candidates = Vec::new();
    if let Some(resources) = bundle_resources {
        candidates.push(resources.join("vulkan/icd.d/libkosmickrisp_icd.json"));
    } else {
        if let Some(sdk) = std::env::var_os("VULKAN_SDK") {
            candidates.push(PathBuf::from(sdk).join("share/vulkan/icd.d/libkosmickrisp_icd.json"));
        }
        candidates.push(PathBuf::from(
            "/usr/local/share/vulkan/icd.d/libkosmickrisp_icd.json",
        ));
    }
// ...
    let driver_manifest = candidates
        .into_iter()
        .find(|candidate| candidate.is_file())
        .ok_or_else(|| {
            std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "KosmicKrisp ICD not found. Install the macOS Vulkan SDK with System Global Installation enabled, or set VK_DRIVER_FILES explicitly.",
            )
        })?;

    // This runs before winit or Vulkan starts any threads, so no other code can
    // concurrently read the process environment while it is changed.
    unsafe {
        std::env::set_var("VK_DRIVER_FILES", &driver_manifest);
    }
    println!(
        "🌌 Using KosmicKrisp Vulkan driver: {}",
        driver_manifest.display()
    );
    Ok(())
}
src/main.rs:39-77

The search order is: the app bundle; otherwise the SDK named by $VULKAN_SDK; otherwise the SDK's system-wide install under /usr/local. If none exists, it fails early with instructions rather than letting Vulkan fail mysteriously later.

.find(…) returns the first candidate that is a real file, as an Option; .ok_or_else(…) turns a None into an error so ? can return it.

On other operating systems the second, empty version of the function is compiled instead, and the loader finds the GPU vendor's driver as usual:

#[cfg(not(target_os = "macos"))]
fn configure_kosmickrisp(_bundle_resources: Option<&Path>) -> std::io::Result<()> {
    Ok(())
}
src/main.rs:79-82

What happens next

App::new().run() creates a winit event loop. When the operating system says the app is ready, it opens the window, builds the whole Vulkan backend (VulkanBase::new), loads the scene, and registers the shadow and main passes, plus a UI pass that can be toggled on and off. Then it draws a frame whenever winit asks. The rest of this guide follows that path roughly in order.

Recap