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:
- which GPU you want and what features you'll use;
- exactly how memory is allocated and what each piece is for;
- all the drawing state for a given kind of draw, baked into one object ahead of time;
- the commands to run, recorded into lists that you hand to the GPU yourself;
- when one piece of work must wait for another.
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:
- Instance: your program's connection to the Vulkan system.
- Device: one GPU, opened for use. You create almost everything from it.
- Queue: the inbox where you post work for the GPU.
- Command buffer: a recorded list of GPU commands ("draw this, then copy that").
- Pipeline: one object holding all the drawing state for a kind of draw, shaders included.
- Swapchain: the small set of images that take turns being shown in the window.
- Fence and semaphore: signals that make the CPU, or the GPU, wait until some earlier GPU work has finished.
- Pass: one stage of drawing a frame that renders into a particular set of images. This renderer has three: shadows, the main scene, and the UI on top.
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.
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:
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).
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
| Path | What lives there | Chapters |
|---|---|---|
src/main.rs | Process 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/textures | Models (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:
ash: the Vulkan bindings. Thelinkedfeature links the Vulkan loader at build time rather than finding it at run time.ash-windowandraw-window-handle: glue that turns a window into a Vulkan surface (Chapter 4).vk-mem: Rust bindings for AMD's Vulkan Memory Allocator, VMA (Chapter 5).winit: cross-platform windows and input events.imguiandimgui-winit-support: Dear ImGui, the immediate-mode debug UI in the screenshot (Chapter 15).cgmath: vectors and matrices.bytemuck: safely viewing structs as raw bytes for upload.image: PNG and JPEG decoding.russimp-ng: bindings to the Assimp model loader.shaderc(build-time only): Google's GLSL-to-SPIR-V compiler, used bybuild.rs.
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;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
}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(())
}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(())
}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(())
}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
- OpenGL's driver hid memory, validation, compilation and synchronisation. Vulkan hands all of that to you in exchange for predictable performance and room for multithreading.
- RustyVulkan reaches Vulkan through
ash. On macOS, Vulkan itself is provided by KosmicKrisp, a driver built on Metal, selected through the loader'sVK_DRIVER_FILESvariable. - Each frame: wait, acquire an image, record shadow, main and UI passes, submit, present, advance.
main.rsfixes the working directory for app bundles and picks the driver before any threads start, then hands everything toApp.