Platform & Window: the OS Event Loop
Your game's window says "(Not Responding)" during a level load, freezes while the player drags its border, and looks blurry on a high-DPI laptop. All three come from the platform layer: the code that asks the operating system for a window, pumps its event queue, and hands the GPU a drawable surface. It's small but full of sharp edges, including two completely different control-flow shapes depending on which library you use. This page builds it in C++ and Rust, so you can open a window on any desktop OS, keep it responsive, and connect it to Vulkan.
- Open a window with GLFW or winit and get the native handle a GPU API needs.
- Explain the Win32 message pump, and keep it serviced so Windows never marks your game "(Not Responding)".
- Choose between an owned poll loop and event-driven callbacks, and drop the fixed-timestep accumulator into either.
- Read a monotonic clock and cap the frame rate without the judder a coarse OS timer causes.
- Create a Vulkan surface from the window on Windows, Linux or macOS.
- Handle resizes, high-DPI framebuffers, the modal drag loop and main-thread rules without freezes, blur or crashes, including by hand on Win32.
01The platform layer
You want the same game to run on Windows, Linux and macOS. On each one the operating system owns the window, the input devices, and the stream of events; your engine borrows a drawable region and a feed of events. Each OS exposes them through a different native API: Win32 (Windows' native windowing API), X11 and Wayland (Linux's older and newer display systems), and Cocoa/AppKit (macOS's UI framework)[1].
Engines wrap those differences behind a thin platform layer so the rest of the codebase sees one window type and one event type. The libraries that are that layer for most projects are SDL and GLFW, two cross-platform C libraries, and winit, the standard Rust windowing crate. The framing to hold for the whole tutorial:
GLFW, SDL, and winit give you a window, an event stream, and an opaque OS handle. They do not draw anything. You bring Vulkan (or OpenGL, or Direct3D) yourself and connect it to that handle. Keeping the window and the renderer decoupled is what lets a Rust engine swap winit for SDL without touching its Vulkan code (§7).
02Creating a window
glfwCreateWindow(1280, 720, "Engine", nullptr, nullptr) gives you a window on screen, and nothing draws in it until a renderer is attached. Underneath that one call, every platform does the same three things: describe the window, create it, and get back an opaque handle, a token you pass back to every later call that concerns that window (HWND on Windows, an X11 window id, a Wayland surface, an NSWindow). A library normalizes that into one call.
The handle is the thing you hand to the GPU later for surface creation, so creating the window and creating the renderer are two separate steps even though they feel like one. §5 has complete GLFW and winit programs that open a window and run the game loop, and §8 shows the same thing written directly against Win32, which is what those libraries do for you on Windows.
03The OS event model
A level load blocks the main thread for six seconds, and a second before it finishes Windows swaps the window for a faded ghost copy with "(Not Responding)" in the title bar. That happens because the OS doesn't call your code when a key goes down. It puts a message in a queue and waits for you to come get it. On Windows the loop that collects them is the : retrieve a message, translate it, dispatch it to your window procedure, the callback you registered to handle that window's messages[2].
The pipeline below is a sequence of Win32 calls, not arithmetic, and every thread that owns a window runs some version of it:
In words: pull a message off the queue (GetMessage waits for one, PeekMessage returns at once), optionally turn key presses into characters, then dispatch it so the OS calls your WndProc. Each arrow means "then". Hover any name to see what it does.
GetMessageblocks until a message arrives and returns 0 onWM_QUIT(and −1 on error, which is why the idiom iswhile (GetMessage(...) > 0))[3]. Games typically usePeekMessagewithPM_REMOVEinstead, which is non-blocking, so the loop can render every iteration whether or not events are waiting[4].TranslateMessageonly turns keyboard virtual-key messages (layout-independent key codes such asVK_LEFT) into character messages; it does not "translate all messages."DispatchMessagecalls yourWndProcfor that message and returns when it returns[2].
Linux and macOS differ in detail but not in spirit. X11 is a connection you pull events off; Wayland is a protocol where the compositor, the process that draws every window onto the screen, requests a size and the client must acknowledge it. macOS's NSApplication owns its run loop and dispatches NSEvents; a library can pump that loop by hand (GLFW and SDL do), but during a live resize AppKit runs its own tracking loop, much like the Win32 modal loop. Platforms like these, plus iOS and the web, are why the cross-platform Rust library (winit) owns your loop outright (§4).
Windows attaches a timer to pending messages: if one sits in a window's queue for 5 seconds without being retrieved, the Desktop Window Manager (DWM, Windows' compositor) declares the window hung and replaces it with a ghost copy showing a bitmap of its last contents and "(Not Responding)" in the title[5]. The OS can't tell "busy" from "hung"; it only knows messages aren't being retrieved. A single long frame, or a blocking call inside WndProc, is enough.
Worked example
The six-second load runs on the main thread, with no pump. The player keeps clicking and pressing keys, about 8 input events a second. At 0.5 s, 4 events are waiting; at 4.5 s, 36. At 5.0 s there are 40, and the oldest has sat unretrieved for 5 seconds, so Windows ghosts the window. At 6.0 s the load ends, the next pump drains all 48 at once, and the window comes back. Pumping once inside the load loop, even every half second, keeps the oldest message under 5 s old and the window never ghosts.
The widget is the pump, running that example:
04Poll loop vs event-driven
A game wants to redraw 60 or more times a second whether or not the player touches anything. A level editor sitting idle shouldn't burn a CPU core redrawing the same picture. A platform layer can take two shapes, and they decide where your loop lives and whether it can sleep.
- Owned poll loop (SDL, GLFW): you own
while (running). Each frame you call poll-events to drain the queue, then update and render. The library is a service you call[6][7]. - (winit; also SDL3's main-callback mode): the framework owns the loop and calls you. You implement handlers and hand control back every iteration[8][9]. winit 0.30 moved to this trait-based API, deprecating the older closure passed to
EventLoop::run[10].
The callback shape exists because some platforms (iOS, the web) require the system to own the loop and call into you, and on others (macOS live resize, Win32 modal loops) the system takes the loop over at times anyway. The poll loop is the convenient shape where the platform allows it. GLFW frames its choice plainly: glfwPollEvents "processes only those events that have already been received and then returns immediately... the best choice when rendering continuously, like most games do," while glfwWaitEvents sleeps until something happens, which suits tools and editors[6]. winit makes the same choice through ControlFlow: Wait, the default, sleeps until an event arrives, and Poll keeps the loop running for games[11].
The widget feeds the same event stream into both shapes:
Worked example
A game under Poll with vsync (presenting each image in step with the display's refresh) on a 60 Hz display draws 60 frames a second, input or not. An editor under Wait whose user presses a key every 1.6 seconds draws one frame per key press, about 0.6 frames a second, and its loop sleeps the rest of the time. The same editor under Poll would draw 60, almost all of them identical.
05Plugging in the fixed timestep
You've written a fixed-timestep loop for a GLFW build, and now the Rust port's handler gets called once per frame by winit instead. The accumulator from the Game Loop tutorial drops into both shapes. The algorithm is identical; only where the state lives and who calls the body change.
Need a refresher on the fixed-timestep accumulator?
Real frames arrive at uneven intervals, but physics is most stable when stepped by a constant dt (here 1/60 s). The fix: each frame, add the elapsed wall-clock time to an accumulator, then run update(dt) in a loop while the accumulator holds at least one whole dt, subtracting dt each pass. Simulation advances in fixed chunks no matter how the frame rate wobbles.
The leftover in the accumulator (less than one dt) is how far you are between two simulation steps. Passing accumulator / dt to render as an interpolation factor lets the picture sit smoothly between the last two states instead of snapping to the most recent one. The Game Loop tutorial derives all of this.
// A complete GLFW host: a window, the loop you own, a fixed-timestep
// accumulator, and the framebuffer size a renderer needs. Nothing is drawn
// yet; with no vsync to wait on, this loop spins as fast as the CPU allows.
#define GLFW_INCLUDE_NONE // no OpenGL headers; a Vulkan build defines GLFW_INCLUDE_VULKAN instead
#include <GLFW/glfw3.h>
#include <cstdio>
struct Game {
double accumulator = 0.0;
long long steps = 0, frames = 0;
int framebufferWidth = 0, framebufferHeight = 0;
bool swapchainStale = false; // set on resize; the renderer rebuilds before its next present
};
void update(Game& game, double dt) { (void)dt; ++game.steps; } // the simulation, at a fixed dt
void render(Game& game, double alpha) { (void)alpha; ++game.frames; } // draw, blended by alpha
int main() {
if (!glfwInit()) return 1;
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // no OpenGL context: Vulkan brings its own
glfwWindowHint(GLFW_SCALE_TO_MONITOR, GLFW_TRUE); // Windows, X11: grow the window with the display scale
GLFWwindow* window = glfwCreateWindow(1280, 720, "Engine", nullptr, nullptr);
if (!window) { glfwTerminate(); return 1; }
Game game;
glfwSetWindowUserPointer(window, &game); // how a C callback finds the game again
// Render at the framebuffer size, in pixels. On macOS and Wayland at 2x, a 1280x720 window
// has a 2560x1440 framebuffer; on Windows and X11 GLFW's screen coordinates are pixels already.
glfwGetFramebufferSize(window, &game.framebufferWidth, &game.framebufferHeight);
glfwSetFramebufferSizeCallback(window, [](GLFWwindow* resized, int width, int height) {
Game* resizedGame = static_cast<Game*>(glfwGetWindowUserPointer(resized));
resizedGame->framebufferWidth = width;
resizedGame->framebufferHeight = height;
resizedGame->swapchainStale = true;
});
float scaleX = 1.0f, scaleY = 1.0f;
glfwGetWindowContentScale(window, &scaleX, &scaleY);
int windowWidth = 0, windowHeight = 0;
glfwGetWindowSize(window, &windowWidth, &windowHeight); // above 1280x720 on a scaled Windows or X11 display
std::printf("window %dx%d (screen coordinates), framebuffer %dx%d, content scale %.2f\n",
windowWidth, windowHeight, game.framebufferWidth, game.framebufferHeight, scaleX);
const double dt = 1.0 / 60.0;
double previous = glfwGetTime();
while (!glfwWindowShouldClose(window)) { // you own the loop
glfwPollEvents(); // handle what has arrived and return; a Win32 drag blocks in here (§8)
double now = glfwGetTime();
double frameTime = now - previous;
previous = now;
if (frameTime > 0.25) frameTime = 0.25; // after a long stall, catch up at most 15 steps
game.accumulator += frameTime;
while (game.accumulator >= dt) { update(game, dt); game.accumulator -= dt; }
render(game, game.accumulator / dt);
if (now >= 2.0) glfwSetWindowShouldClose(window, GLFW_TRUE); // demo: quit after 2 s
}
std::printf("%lld fixed steps in %.2f s; the loop ran %lld times\n", game.steps, glfwGetTime(), game.frames);
glfwDestroyWindow(window);
glfwTerminate();
}
// A complete winit host: the event loop owns control and calls your handler;
// the fixed-timestep accumulator lives on the struct between calls. Nothing is
// drawn yet; with no vsync to wait on, Poll keeps the loop spinning.
// Cargo.toml: winit = "0.30"
use std::time::Instant;
use winit::application::ApplicationHandler;
use winit::dpi::{LogicalSize, PhysicalSize};
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId};
struct Game {
window: Option<Window>,
accumulator: f64,
previous: Instant,
started: Instant,
steps: u64,
frames: u64,
framebuffer: PhysicalSize<u32>,
swapchain_stale: bool, // set on resize; the renderer rebuilds before its next present
}
impl Game {
const DT: f64 = 1.0 / 60.0;
fn update(&mut self, _dt: f64) { self.steps += 1; } // the simulation, at a fixed dt
fn render(&mut self, _alpha: f64) { self.frames += 1; } // draw, blended by alpha
}
impl ApplicationHandler for Game {
// Create the window here: winit recommends waiting for the first resumed,
// because some platforms (Android) can't make a render surface before it.
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attributes = Window::default_attributes()
.with_title("Engine")
.with_inner_size(LogicalSize::new(1280.0, 720.0));
let window = event_loop.create_window(attributes).expect("create window");
// Pixels, not logical units: at scale 2.0 a 1280x720 window is 2560x1440.
self.framebuffer = window.inner_size();
println!("window 1280x720 (logical), framebuffer {}x{}, scale factor {:.2}",
self.framebuffer.width, self.framebuffer.height, window.scale_factor());
self.window = Some(window);
self.previous = Instant::now();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => { self.framebuffer = size; self.swapchain_stale = true; }
WindowEvent::RedrawRequested => { // the per-frame body
let now = Instant::now();
let frame_time = (now - self.previous).as_secs_f64().min(0.25); // cap catch-up after a stall
self.previous = now;
self.accumulator += frame_time;
while self.accumulator >= Game::DT {
self.update(Game::DT);
self.accumulator -= Game::DT;
}
self.render(self.accumulator / Game::DT);
if (now - self.started).as_secs_f64() >= 2.0 { event_loop.exit(); } // demo: quit after 2 s
}
_ => {}
}
}
// Called when the loop has handled every pending event and is about to wait.
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(window) = &self.window { window.request_redraw(); } // ask for the next frame
}
}
fn main() {
let event_loop = EventLoop::new().expect("event loop (must be on the main thread)");
event_loop.set_control_flow(ControlFlow::Poll); // keep running when no events arrive
let now = Instant::now();
let mut game = Game {
window: None, accumulator: 0.0, previous: now, started: now, steps: 0, frames: 0,
framebuffer: PhysicalSize::new(0, 0), swapchain_stale: false,
};
event_loop.run_app(&mut game).expect("run");
println!("{} fixed steps in {:.2} s; the loop ran {} times",
game.steps, game.started.elapsed().as_secs_f64(), game.frames);
}
In GLFW you call the loop body inside your while; in winit the body is a method (RedrawRequested) and its state lives on the struct, because winit calls you. The accumulator math is identical. Both programs quit after two seconds and print about 120 fixed steps, plus a loop count in the hundreds of thousands or millions: nothing is drawn yet, so nothing makes the loop wait. Presenting with vsync (the Vulkan module) or a frame cap (§6) is what paces it. The size callbacks mark the swapchain (the images the GPU draws into and the display shows) stale; §8 covers why.
Worked example
Declare the accumulator as a local inside the RedrawRequested arm instead of a field, and it starts at zero on every call. With dt = 1/60 s (16.67 ms) at 144 Hz, each call adds 6.94 ms, never a whole step, so update never runs and the world freezes while the screen keeps redrawing. At 40 fps each call adds 25 ms, runs one step and throws away 8.33 ms, so the game runs at 16.67 / 25 = 67% speed. As a field on Game, the leftover carries to the next call and both cases keep real time.
06Clocks, sleep & frame pacing
Your game sits on its menu screen with vsync off, and one CPU core runs at 100% drawing the same picture a thousand times a second while the laptop fan spins up. The obvious fix, sleeping until the next frame is due, can turn a 60 fps cap on Windows into frames that swing between a few milliseconds and 31. Two platform facts decide this: which clock you read, and how precisely the OS can wake a sleeping thread.
The clock. Frame times come from a monotonic high-resolution counter, never the time of day, which can jump when the system clock is corrected. On Windows that's QueryPerformanceCounter (QPC), which Microsoft recommends for time stamps of 1 microsecond resolution or better that don't need to match an outside time reference[12]. C++'s std::chrono::steady_clock and Rust's Instant are portable wrappers over the same kind of counter, and GLFW's glfwGetTime in §5 reads one too.
The sleep. A sleeping thread wakes on a timer interrupt, which rarely falls at the exact instant it asked for. Microsoft's documentation for Sleep says the system clock "ticks" at a constant rate, and a wait of more than one tick but less than two can last anywhere between one and two ticks[13]. On the machines Bruce Dawson has used, the default tick is 15.625 ms (64 a second), so Sleep(1) called at a random moment wakes on the next tick, or the one after if the next is less than 1 ms away: anywhere from 1.0 to 16.625 ms later[14]. timeBeginPeriod(1) asks for 1 ms ticks. Since Windows 10 version 2004 the request no longer speeds up processes that didn't make one, and on Windows 11 it isn't guaranteed while the process's windows are minimized or hidden[15]. Since version 1803 a waitable timer created with CREATE_WAITABLE_TIMER_HIGH_RESOLUTION gets the same precision without a timeBeginPeriod call: the OS raises the tick rate itself, only for the part of the wait that needs it[16]. Linux wakes sleepers more precisely, but each thread has a timer slack, 50 µs by default, by which the kernel may wake it late in order to group wake-ups[17].
Worked example
A 60 fps cap with 4 ms of work leaves 12.67 ms to sleep. With 15.625 ms ticks and a frame that starts on a tick, Sleep(12) can't end at 16 ms, because no tick falls there. A thread that wakes on the first tick after its 12 ms have passed, the behavior Dawson describes, wakes at 31.25 ms. (Microsoft's documentation also lets a wait shorter than one tick end early, here at 15.625 ms. Either way the frame isn't 16.67 ms.) After a 31.25 ms frame the loop is behind schedule, so the next frame skips the sleep and runs short to catch up. The average over a second can still come out at 60 fps while no single frame lasts 16.67 ms, which the eye sees as judder. With 1 ms ticks the same sleep ends at 16 or 17 ms.
The usual cure is to sleep until a little before the deadline and then spin: loop on the clock, yielding, until the deadline passes. The spin costs CPU for a millisecond or two per frame and buys a wake-up within microseconds, though a busy machine can still preempt the spinning thread. In a game that presents with vsync, the swapchain's wait for the display does the pacing and the loop doesn't sleep at all; a cap matters for tools, menus, servers and vsync-off modes.
// Capping the frame rate with a sleep, and with a sleep followed by a short spin.
// Prints how late each approach wakes up after its deadline on this machine.
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <thread>
#include <vector>
using Clock = std::chrono::steady_clock; // monotonic: unaffected by changes to the wall clock
using Seconds = std::chrono::duration<double>;
// Wait until the deadline: sleep while more than spinMargin remains, then spin on
// the clock for the rest, because the OS may wake a sleeping thread late.
void waitUntil(Clock::time_point deadline, Seconds spinMargin) {
for (;;) {
Seconds remaining = deadline - Clock::now();
if (remaining <= Seconds::zero()) return;
if (remaining > spinMargin)
std::this_thread::sleep_for(remaining - spinMargin);
else
std::this_thread::yield(); // spin: stay awake, but let other threads run
}
}
// Run frames of `work` capped at targetFps; return how late each frame's wait ended, in µs.
std::vector<double> runCapped(int frames, double targetFps, Seconds work, Seconds spinMargin) {
const auto period = std::chrono::duration_cast<Clock::duration>(Seconds(1.0 / targetFps));
const auto workTime = std::chrono::duration_cast<Clock::duration>(work);
std::vector<double> lateMicros;
Clock::time_point deadline = Clock::now();
for (int frame = 0; frame < frames; ++frame) {
Clock::time_point workEnd = Clock::now() + workTime;
while (Clock::now() < workEnd) {} // stand-in for update and render
deadline += period; // a fixed schedule: one late frame doesn't shift the rest
waitUntil(deadline, spinMargin);
lateMicros.push_back(Seconds(Clock::now() - deadline).count() * 1e6);
}
return lateMicros;
}
void report(const char* label, std::vector<double> lateMicros) {
std::sort(lateMicros.begin(), lateMicros.end());
double mean = 0;
for (double late : lateMicros) mean += late / lateMicros.size();
std::printf("%-22s mean %7.1f us late, 99th percentile %7.1f us, worst %7.1f us\n", label, mean,
lateMicros[lateMicros.size() * 99 / 100], lateMicros.back());
}
int main() {
const int frames = 240; // two seconds at 120 fps
const double targetFps = 120.0; // an 8.33 ms frame
const Seconds work(0.003); // 3 ms of "game" per frame
report("sleep only", runCapped(frames, targetFps, work, Seconds(0)));
report("sleep, then spin 1 ms", runCapped(frames, targetFps, work, Seconds(0.001)));
}
// Capping the frame rate with a sleep, and with a sleep followed by a short spin.
// Prints how late each approach wakes up after its deadline on this machine.
use std::thread;
use std::time::{Duration, Instant}; // Instant is monotonic: unaffected by changes to the wall clock
// Wait until the deadline: sleep while more than spin_margin remains, then spin on
// the clock for the rest, because the OS may wake a sleeping thread late.
fn wait_until(deadline: Instant, spin_margin: Duration) {
loop {
let now = Instant::now();
if now >= deadline { return; }
let remaining = deadline - now;
if remaining > spin_margin {
thread::sleep(remaining - spin_margin);
} else {
thread::yield_now(); // spin: stay awake, but let other threads run
}
}
}
// Run frames of `work` capped at target_fps; return how late each frame's wait ended, in µs.
fn run_capped(frames: usize, target_fps: f64, work: Duration, spin_margin: Duration) -> Vec<f64> {
let period = Duration::from_secs_f64(1.0 / target_fps);
let mut late_micros = Vec::with_capacity(frames);
let mut deadline = Instant::now();
for _ in 0..frames {
let work_end = Instant::now() + work;
while Instant::now() < work_end {} // stand-in for update and render
deadline += period; // a fixed schedule: one late frame doesn't shift the rest
wait_until(deadline, spin_margin);
late_micros.push(Instant::now().duration_since(deadline).as_secs_f64() * 1e6);
}
late_micros
}
fn report(label: &str, mut late_micros: Vec<f64>) {
late_micros.sort_by(f64::total_cmp);
let mean = late_micros.iter().sum::<f64>() / late_micros.len() as f64;
println!("{label:<22} mean {mean:7.1} us late, 99th percentile {:7.1} us, worst {:7.1} us",
late_micros[late_micros.len() * 99 / 100], late_micros[late_micros.len() - 1]);
}
fn main() {
let frames = 240; // two seconds at 120 fps
let target_fps = 120.0; // an 8.33 ms frame
let work = Duration::from_millis(3); // 3 ms of "game" per frame
report("sleep only", run_capped(frames, target_fps, work, Duration::ZERO));
report("sleep, then spin 1 ms", run_capped(frames, target_fps, work, Duration::from_millis(1)));
}
Run it and the "sleep only" line reports how late your OS wakes a sleeping thread, averaged over 240 frames; the spin line usually lands within a few microseconds, with an occasional late frame when the scheduler runs something else on the core. On Windows the two tabs measure different sleeps: Rust's thread::sleep already uses a high-resolution waitable timer on Windows 10 version 1803 and later[18], while MSVC's sleep_for ends in Sleep with whole milliseconds[19], so only the C++ line shows the tick rounding. The deadline advances by a fixed period every frame instead of restarting from "now", so one late frame doesn't push every later frame back with it.
07Handing a surface to the GPU
Your Vulkan renderer runs on Windows and you want it on a Wayland desktop too, without rewriting the drawing code. That works because Vulkan's core knows nothing about windows; presentation comes through the WSI (Window System Integration) extensions. The cross-platform VK_KHR_surface defines the VkSurfaceKHR type, and one platform extension (VK_KHR_win32_surface, VK_KHR_xcb_surface, VK_KHR_wayland_surface, a Metal surface on macOS) actually creates it from the native handle[20].
Both halves are true at once. Once the (the Vulkan object that stands for the window's drawable area) exists, the (the set of images that take turns on screen) and all the drawing are platform-independent, but the call that makes the surface is tied to the OS. The libraries hide it: GLFW's glfwCreateWindowSurface picks the right extension for you (and requires the window be created with GLFW_NO_API, proof again that GLFW gives you a surface, not a renderer)[21]. In Rust, raw-window-handle, a tiny crate that defines one handle type both sides understand, decouples the window from the GPU library, and ash-window creates the surface from that handle[22][23].
Worked example
On Windows the Vulkan instance needs two extensions enabled before a surface can exist: VK_KHR_surface, which defines the VkSurfaceKHR type, and VK_KHR_win32_surface, which creates one from an HWND. On a Wayland desktop the second becomes VK_KHR_wayland_surface, and on X11 VK_KHR_xcb_surface[20]. Those platform pieces, one extension name and the one call that creates the surface (GLFW or ash-window picks both for you), are all that change; the swapchain, pipelines and draw calls built on the surface are the same on all three.
VkSurfaceKHR surface;
// GLFW selects VK_KHR_win32_surface / _xcb_ / _wayland_ for you.
if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS)
throw std::runtime_error("failed to create surface");
// Required instance extensions come from the display handle; enable them first.
let required_extensions = ash_window::enumerate_required_extensions(window.display_handle()?.as_raw())?;
// create_surface is unsafe and the returned surface has NO Drop; destroy it yourself.
let surface = unsafe {
ash_window::create_surface(&entry, &instance,
window.display_handle()?.as_raw(), window.window_handle()?.as_raw(), None)?
};
08Resize, DPI, threads
The player drags the window border and the game freezes until they let go; on a laptop with a high-DPI screen the image comes out blurry or crammed into one corner; a worker thread creates the window and the program crashes on macOS. These are three loose ends the platform layer surfaces, and the rest of the engine has to respect them.
- Resize. The window reports a new size (Win32
WM_SIZE; winitWindowEvent::Resized). The Vulkan swapchain is sized to the old extent and goes stale, so it must be recreated. The mechanism (handlingVK_ERROR_OUT_OF_DATE_KHR) belongs to the Vulkan module; here, just know resize is an event that invalidates GPU-side state. On Win32 it also arrives inside a modal loop, a nested message loop the OS runs itself: while the user drags the border, the default window procedure runs its own message pump (betweenWM_ENTERSIZEMOVEandWM_EXITSIZEMOVE), and your outer loop doesn't run until the drag ends[24]. - DPI. DPI (dots per inch) is a screen's pixel density. There are two sizes: the logical window size in screen coordinates, and the physical framebuffer size, the grid of pixels the GPU actually renders into. They differ on high-DPI displays. Render targets and viewports use pixels (the framebuffer size), not logical size, or everything is blurry or clipped[25]. On Windows the process must also declare itself DPI-aware, preferably as Per-Monitor V2, the mode where the app sizes itself for each monitor's scale and Windows never stretches it. It declares this in its application manifest (an XML resource embedded in the .exe) or by calling
SetProcessDpiAwarenessContextbefore creating any window. An unaware process is rendered at 96 DPI and bitmap-stretched by the OS, which is its own blur[26]. A Per-Monitor V2 process getsWM_DPICHANGEDinstead when the window moves to a monitor with another scale, with a suggested new window rectangle it's expected to apply[27]. GLFW and winit opt in for you; a hand-written Win32 layer has to. - Threads. Window creation and event handling belong on the main thread; macOS's AppKit requires it, and winit panics if you create the event loop elsewhere (Windows and Linux allow an opt-out through a platform extension; macOS doesn't). On Win32 a window's messages go to the queue of the thread that created it, so that thread must pump them. Rendering can move to other threads, but the window and its events stay put.
Worked example
On a Mac with a Retina display (content scale 2.0), a window created at 1280×720 has a 2560×1440 framebuffer. Set the Vulkan viewport from the window size and the scene fills only the top-left quarter of the image, 1280×720 of its 2560×1440 pixels[25].
On Windows at 150% scaling (144 DPI), a process that never declared itself DPI-aware is told its 1280×720 window is 1280×720, renders that many pixels, and Windows stretches the result to 1920×1080 on screen: every pixel smeared over 1.5 screen pixels, which is the blur.
The widget renders a test pattern (1-pixel lines, a disc, some text) the way each setup would, then shows the window's pixels magnified:
The widget below simulates the Win32 modal move loop. Drag the mock window by its title bar (or turn on the automatic drag). While you hold it, the game's own loop is stuck inside DispatchMessage, and the ball only moves when something draws a frame:
The complete program below does everything in this section by hand on Win32: it declares Per-Monitor V2 awareness, sizes a 1280×720 client area (the inside of the window, without the title bar and borders) for the window's DPI with AdjustWindowRectExForDpi, pumps with PeekMessage so the loop never blocks, applies the suggested rectangle on WM_DPICHANGED, and starts a timer on WM_ENTERSIZEMOVE so the game keeps stepping during a drag. That works because the modal loop is an ordinary GetMessage/DispatchMessage loop: it hands any message addressed to a window to that window's procedure[24], and WM_TIMER is a low-priority message that GetMessage generates when the timer expires and nothing more urgent is waiting[28]. DefWindowProc turns WM_CLOSE into DestroyWindow, and the program's WM_DESTROY handler posts the quit message that ends the pump.
// The platform layer by hand on Win32: Per-Monitor V2 DPI awareness, a
// PeekMessage pump that never blocks, a fixed-timestep accumulator on
// QueryPerformanceCounter, and a timer that keeps the game ticking while
// Windows runs its own loop during a window drag. Link with user32.
#define UNICODE
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdio>
struct Game {
LARGE_INTEGER ticksPerSecond{}, previous{};
double accumulator = 0.0;
long long steps = 0, frames = 0, framesDuringDrag = 0;
int framebufferWidth = 0, framebufferHeight = 0;
UINT dpi = 96;
bool dragging = false;
static constexpr double kDt = 1.0 / 60.0;
// One iteration of the game loop: measure elapsed time, step the simulation, draw.
void frame() {
LARGE_INTEGER now;
QueryPerformanceCounter(&now);
double frameTime = double(now.QuadPart - previous.QuadPart) / double(ticksPerSecond.QuadPart);
previous = now;
if (frameTime > 0.25) frameTime = 0.25; // after a long stall, catch up at most 15 steps
accumulator += frameTime;
while (accumulator >= kDt) { ++steps; accumulator -= kDt; } // update(kDt)
++frames; // render(accumulator / kDt)
if (dragging) ++framesDuringDrag;
}
};
Game game;
constexpr UINT_PTR kDragTimer = 1;
LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) {
switch (message) {
case WM_SIZE: // the client area, in physical pixels under Per-Monitor V2
game.framebufferWidth = LOWORD(lParam);
game.framebufferHeight = HIWORD(lParam);
return 0; // a renderer would mark its swapchain for recreation here
case WM_DPICHANGED: { // moved to a monitor with another scale: use the suggested rect
game.dpi = HIWORD(wParam);
const RECT* suggested = reinterpret_cast<const RECT*>(lParam);
SetWindowPos(window, nullptr, suggested->left, suggested->top,
suggested->right - suggested->left, suggested->bottom - suggested->top,
SWP_NOZORDER | SWP_NOACTIVATE);
return 0;
}
case WM_ENTERSIZEMOVE: // a drag begins and DefWindowProc enters its own message loop,
game.dragging = true; // which still dispatches WM_TIMER to this window
SetTimer(window, kDragTimer, USER_TIMER_MINIMUM, nullptr);
return 0;
case WM_EXITSIZEMOVE:
game.dragging = false;
KillTimer(window, kDragTimer);
return 0;
case WM_TIMER:
if (wParam == kDragTimer) game.frame(); // keep the game running inside the drag
return 0;
case WM_DESTROY:
PostQuitMessage(0); // ends the pump in main
return 0;
}
return DefWindowProc(window, message, wParam, lParam); // WM_CLOSE -> DestroyWindow, etc.
}
int main() {
// Without this the process counts as DPI-unaware: it renders at 96 DPI and
// Windows bitmap-stretches the result on a scaled display, which blurs it.
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
WNDCLASSEX windowClass{};
windowClass.cbSize = sizeof(windowClass);
windowClass.lpfnWndProc = windowProc;
windowClass.hInstance = GetModuleHandle(nullptr);
windowClass.hCursor = LoadCursor(nullptr, IDC_ARROW);
windowClass.lpszClassName = L"EngineWindow";
RegisterClassEx(&windowClass);
HWND window = CreateWindowEx(0, L"EngineWindow", L"Engine", WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
nullptr, nullptr, windowClass.hInstance, nullptr);
if (!window) return 1;
// A 1280x720 client area at 100% scale, scaled for this monitor; the title bar and
// borders come on top, at this DPI's sizes.
game.dpi = GetDpiForWindow(window);
RECT rect{0, 0, MulDiv(1280, game.dpi, 96), MulDiv(720, game.dpi, 96)};
AdjustWindowRectExForDpi(&rect, WS_OVERLAPPEDWINDOW, FALSE, 0, game.dpi);
SetWindowPos(window, nullptr, 0, 0, rect.right - rect.left, rect.bottom - rect.top,
SWP_NOMOVE | SWP_NOZORDER);
ShowWindow(window, SW_SHOW);
QueryPerformanceFrequency(&game.ticksPerSecond);
QueryPerformanceCounter(&game.previous);
for (bool running = true; running;) {
// Drain everything waiting, then run a frame. PeekMessage returns at once when
// the queue is empty, so the loop never sleeps here.
MSG message;
while (PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) {
if (message.message == WM_QUIT) running = false;
TranslateMessage(&message); // key presses -> WM_CHAR
DispatchMessage(&message); // calls windowProc
}
if (running) game.frame();
}
std::printf("%lld steps, %lld frames (%lld during drags), framebuffer %dx%d at %u DPI\n",
game.steps, game.frames, game.framesDuringDrag,
game.framebufferWidth, game.framebufferHeight, game.dpi);
}
// The platform layer by hand on Win32: Per-Monitor V2 DPI awareness, a
// PeekMessage pump that never blocks, a fixed-timestep accumulator on
// QueryPerformanceCounter, and a timer that keeps the game ticking while
// Windows runs its own loop during a window drag.
// Cargo.toml: windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Graphics_Gdi",
// "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", "Win32_System_LibraryLoader",
// "Win32_System_Performance"] }
use std::cell::RefCell;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
use windows_sys::Win32::UI::HiDpi::{
AdjustWindowRectExForDpi, GetDpiForWindow, SetProcessDpiAwarenessContext,
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
use windows_sys::Win32::UI::WindowsAndMessaging::*;
struct Game {
ticks_per_second: i64,
previous: i64,
accumulator: f64,
steps: u64,
frames: u64,
frames_during_drag: u64,
framebuffer: (u32, u32),
dpi: u32,
dragging: bool,
}
impl Game {
const DT: f64 = 1.0 / 60.0;
// One iteration of the game loop: measure elapsed time, step the simulation, draw.
fn frame(&mut self) {
let mut now = 0;
unsafe { QueryPerformanceCounter(&mut now) };
let frame_time = ((now - self.previous) as f64 / self.ticks_per_second as f64).min(0.25); // cap catch-up
self.previous = now;
self.accumulator += frame_time;
while self.accumulator >= Game::DT { self.steps += 1; self.accumulator -= Game::DT; } // update(DT)
self.frames += 1; // render(alpha)
if self.dragging { self.frames_during_drag += 1; }
}
}
// The window procedure can't take extra arguments, so the game lives in a thread-local.
// Only the main thread touches it, and no borrow is held across DispatchMessageW.
thread_local! {
static GAME: RefCell<Game> = const { RefCell::new(Game {
ticks_per_second: 1, previous: 0, accumulator: 0.0, steps: 0, frames: 0,
frames_during_drag: 0, framebuffer: (0, 0), dpi: 96, dragging: false,
}) };
}
const DRAG_TIMER: usize = 1;
const fn wide(text: &str) -> [u16; 13] { // "EngineWindow" + NUL, as UTF-16
let bytes = text.as_bytes();
let mut out = [0u16; 13];
let mut i = 0;
while i < bytes.len() { out[i] = bytes[i] as u16; i += 1; }
out
}
static CLASS_NAME: [u16; 13] = wide("EngineWindow");
unsafe extern "system" fn window_proc(window: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
match message {
WM_SIZE => { // the client area, in physical pixels under Per-Monitor V2
let size = ((lparam & 0xFFFF) as u32, ((lparam >> 16) & 0xFFFF) as u32);
GAME.with_borrow_mut(|game| game.framebuffer = size);
0 // a renderer would mark its swapchain for recreation here
}
WM_DPICHANGED => { // moved to a monitor with another scale: use the suggested rect
GAME.with_borrow_mut(|game| game.dpi = ((wparam >> 16) & 0xFFFF) as u32);
let suggested = &*(lparam as *const RECT);
SetWindowPos(window, std::ptr::null_mut(), suggested.left, suggested.top,
suggested.right - suggested.left, suggested.bottom - suggested.top,
SWP_NOZORDER | SWP_NOACTIVATE);
0
}
WM_ENTERSIZEMOVE => { // a drag begins and DefWindowProc enters its own message loop,
GAME.with_borrow_mut(|game| game.dragging = true); // which still dispatches WM_TIMER
SetTimer(window, DRAG_TIMER, USER_TIMER_MINIMUM, None);
0
}
WM_EXITSIZEMOVE => {
GAME.with_borrow_mut(|game| game.dragging = false);
KillTimer(window, DRAG_TIMER);
0
}
WM_TIMER => {
if wparam == DRAG_TIMER { GAME.with_borrow_mut(Game::frame); } // keep running inside the drag
0
}
WM_DESTROY => { PostQuitMessage(0); 0 } // ends the pump in main
_ => DefWindowProcW(window, message, wparam, lparam), // WM_CLOSE -> DestroyWindow, etc.
}
}
fn main() {
unsafe {
// Without this the process counts as DPI-unaware: it renders at 96 DPI and
// Windows bitmap-stretches the result on a scaled display, which blurs it.
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
let instance = GetModuleHandleW(std::ptr::null());
let window_class = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
lpfnWndProc: Some(window_proc),
hInstance: instance,
hCursor: LoadCursorW(std::ptr::null_mut(), IDC_ARROW),
lpszClassName: CLASS_NAME.as_ptr(),
..std::mem::zeroed()
};
RegisterClassExW(&window_class);
let title: Vec<u16> = "Engine\0".encode_utf16().collect();
let window = CreateWindowExW(0, CLASS_NAME.as_ptr(), title.as_ptr(), WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
std::ptr::null_mut(), std::ptr::null_mut(), instance, std::ptr::null());
if window.is_null() { return; }
// A 1280x720 client area at 100% scale, scaled for this monitor; the title bar and
// borders come on top, at this DPI's sizes.
let dpi = GetDpiForWindow(window);
GAME.with_borrow_mut(|game| game.dpi = dpi);
let mut rect = RECT { left: 0, top: 0, right: (1280 * dpi / 96) as i32, bottom: (720 * dpi / 96) as i32 };
AdjustWindowRectExForDpi(&mut rect, WS_OVERLAPPEDWINDOW, 0, 0, dpi);
SetWindowPos(window, std::ptr::null_mut(), 0, 0, rect.right - rect.left, rect.bottom - rect.top,
SWP_NOMOVE | SWP_NOZORDER);
ShowWindow(window, SW_SHOW);
GAME.with_borrow_mut(|game| {
QueryPerformanceFrequency(&mut game.ticks_per_second);
QueryPerformanceCounter(&mut game.previous);
});
let mut running = true;
while running {
// Drain everything waiting, then run a frame. PeekMessageW returns at once when
// the queue is empty, so the loop never sleeps here.
let mut message: MSG = std::mem::zeroed();
while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
if message.message == WM_QUIT { running = false; }
TranslateMessage(&message); // key presses -> WM_CHAR
DispatchMessageW(&message); // calls window_proc
}
if running { GAME.with_borrow_mut(Game::frame); }
}
}
GAME.with_borrow(|game| println!("{} steps, {} frames ({} during drags), framebuffer {}x{} at {} DPI",
game.steps, game.frames, game.frames_during_drag, game.framebuffer.0, game.framebuffer.1, game.dpi));
}
- Input. Keyboard, mouse and raw-input messages arrive through the same
windowProc; the Input Systems module covers turning them into actions. - A renderer. The program never draws or presents, so with nothing to wait on its loop spins one core. The surface (§7), the swapchain and vsync come next.
- Swapchain recreation.
WM_SIZEonly records the new size. A renderer recreates its swapchain there, and skips drawing while the window is minimized and the size is 0×0. - A render thread. The drag timer keeps the game ticking at the timer's rate. Moving rendering to its own thread removes the dependence on the message loop entirely, at the cost of synchronizing with it.
- Error handling. Every call can fail; a shipping layer checks
RegisterClassEx,CreateWindowExand the DPI calls, and falls back on older Windows versions that lack the per-monitor functions.
Wrong answers, and why: the resize freeze is the modal message loop, not a GPU limit or a cleared queue; in current winit you render in RedrawRequested via run_app, not in about_to_wait or the deprecated run closure; judder under a frame cap comes from tick-rounded sleeps, not the clock; and viewports take framebuffer pixels.
09Pitfalls
WndProc starves the pump past 5 seconds; the OS ghosts the window.DispatchMessage; render from WM_PAINT/a timer or another thread.ControlFlow::Wait with no request_redraw(). Use Poll and request a redraw each frame.EventLoop::run is deprecated in 0.30; use run_app + ApplicationHandler.GLFW_NO_API for Vulkan.steady_clock, Instant).10What's next
The window is open and the loop is running. Next is Input Systems: turning the raw key and gamepad events this layer delivers into actions, dead zones, and buffers. Later in the series, the GPU Pipeline and your first Vulkan triangle give the surface created in §7 a swapchain and something to draw. The full path is on the series hub.
- Jason Gregory. Game Engine Architecture, "Human Interface Devices" and the platform/OS layer. gameenginebook.com. The platform-abstraction layer and the OS event model.
- Microsoft. "Using Messages and Message Queues." learn.microsoft.com. The
GetMessage/TranslateMessage/DispatchMessageloop and thatDispatchMessageinvokes the window procedure. - Microsoft. "GetMessage function." learn.microsoft.com. Blocks for a message; returns 0 on
WM_QUIT, −1 on error (the> 0idiom). - Microsoft. "PeekMessage function." learn.microsoft.com. Non-blocking message retrieval, the games path that renders every iteration.
- Microsoft. "Preventing Hangs in Windows Applications." learn.microsoft.com. The timer on pending messages, the 5-second threshold after which the DWM declares a window hung, and the "(Not Responding)" ghost window.
- GLFW. "Input guide: event processing." glfw.org.
glfwPollEvents(the games path) vsglfwWaitEvents(tools). - SDL. "SDL_PollEvent." wiki.libsdl.org. Non-blocking event drain, the per-frame poll-loop pattern, main-thread.
- winit. ApplicationHandler trait. docs.rs/winit. The required
resumed/window_eventmethods and rendering inRedrawRequested(winit 0.30.x). - SDL. "Main callbacks in SDL3." wiki.libsdl.org.
SDL_AppInit/Iterate/Event/Quit, and why iOS and the web force the callback model. - winit. Changelog. docs.rs/winit.
ApplicationHandler/run_appadded in 0.30.0;EventLoop::rundeprecated. - winit. ControlFlow enum. docs.rs/winit.
Pollfor games vsWait(the default) for reactive UIs. - Microsoft. "Acquiring high-resolution time stamps." learn.microsoft.com.
QueryPerformanceCounterfor time stamps of 1 µs resolution or better that don't need an external time reference. - Microsoft. "Sleep function." learn.microsoft.com. The system clock ticks at a constant rate; a wait between one and two ticks can last anywhere between one and two ticks.
- Bruce Dawson. "Windows Timer Resolution: The Great Rule Change." 2020. randomascii.wordpress.com. A default timer interval of 15.625 ms on every machine the author had used recently, so
Sleep(1)at a random time wakes 1.0 to 16.625 ms later; the Windows 10 2004 change totimeBeginPeriod. - Microsoft. "timeBeginPeriod function." learn.microsoft.com. Requests a finer timer resolution; since Windows 10 version 2004 it no longer affects the global resolution or processes that didn't call it, and on Windows 11 a minimized or hidden window-owning process isn't guaranteed the higher resolution.
- Microsoft. "CreateWaitableTimerExW function" and "High-resolution timers." learn.microsoft.com; High-resolution timers.
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, supported from Windows 10 version 1803; high-resolution timers make the system clock interrupt faster, "at least, temporarily", and only when needed to meet a timer's deadline. - Linux man-pages. "prctl(2)":
PR_SET_TIMERSLACK. manpages.debian.org. Timer slack lets the kernel wake a thread up to that many nanoseconds late; init's value, inherited by default, is 50 µs. - The Rust standard library.
library/std/src/sys/thread/windows.rs,sleep. github.com/rust-lang/rust. On Windows,thread::sleeptries a high-resolution waitable timer first ("Windows 10, version 1803+") and falls back toSleep. - Microsoft STL.
stl/inc/threadandstl/src/sharedmutex.cpp. github.com/microsoft/STL.sleep_forcallssleep_until, which loops on_Thrd_sleep_for, a call toSleepwith a millisecond count. - The Khronos Group. Vulkan Specification, Window System Integration (WSI). docs.vulkan.org.
VK_KHR_surfaceplus the platform surface extensions feedingVK_KHR_swapchain. - GLFW. "Vulkan guide." glfw.org.
glfwCreateWindowSurfaceand theGLFW_NO_APIrequirement. - raw-window-handle. github.com/rust-windowing/raw-window-handle. The provider/consumer split (winit/SDL/GLFW provide handles; wgpu/ash consume them) that decouples windowing from the GPU library.
- ash-window. create_surface. docs.rs/ash-window. Creating a
VkSurfaceKHRfrom a raw window/display handle, and the required-extensions helper. - Raymond Chen. "Thread messages are eaten by modal loops," The Old New Thing, 2005. devblogs.microsoft.com; Microsoft, "WM_ENTERSIZEMOVE message". A modal loop is an ordinary
GetMessage/DispatchMessageloop that hands window messages to their window procedures; a click on the title bar or sizing border enters the moving or sizing modal loop, which ends whenDefWindowProcreturns. - GLFW. "Window guide." glfw.org. Window creation, the framebuffer-size callback, content scale, and windowed full screen.
- Microsoft. "High DPI Desktop Application Development on Windows." learn.microsoft.com. DPI-unaware apps render at 96 DPI and are bitmap-stretched (blurry); Per-Monitor V2 apps are never stretched and get
WM_DPICHANGED. With "SetProcessDpiAwarenessContext function": call it before creating any UI. - Microsoft. "WM_DPICHANGED message." learn.microsoft.com. The suggested window rectangle in
lParam, applied withSetWindowPos. - Microsoft. "WM_TIMER message" and "SetTimer function." learn.microsoft.com; SetTimer.
WM_TIMERis low priority, generated byGetMessage/PeekMessagewhen no other messages are waiting; timeouts belowUSER_TIMER_MINIMUM(10 ms) are raised to it.