All tutorials Mighty Professional
Build a Game Engine · The runtime spine

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.

Time~80 min LevelBeginner to mid PrereqsThe Game Loop tutorial helps (its accumulator gets plugged into the loop here, with a refresher). You can read C++ or Rust. StackC++ (GLFW) · Rust (winit)
◂ Build a Game Engine Phase 3 · The runtime spine Next · Input Systems ▸
What you'll be able to do by the end

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:

A windowing library hands you a window and a native handle, not a renderer

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:

eq. 1 · the Win32 message loop GetMessage / PeekMessage → TranslateMessage → DispatchMessage → WndProc

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.

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).

Service the pump or the OS declares you hung

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:

The blue dots are the OS event queue, the box below it is the pump, and the readouts show the queue length and the time since the last pump. At the default 40 ms frame the pump runs every frame and the readouts sit at "queued events: 0" and "0.0 s". Drag the frame cost to 6000 ms (one long, blocking frame after another) and events pile up between pumps: at 8 events a second the readout passes 4, 8, 12 every half second and reaches 40 at 5.0 s; once five seconds pass without a message being retrieved, the title flips to "(Not Responding)" and the window turns red (Windows shows a faded ghost copy instead), the Win32 ghosting rule. At 6 s the pump drains the queue and it starts over. The event rate only changes how many events pile up; the hang rule is about how long the oldest one has waited. Lower the cost and it recovers.

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.

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.

Left is a poll loop you own, stepping through poll, update and render; right is a framework that owns the loop and calls your two handlers. The counters under each column count frames drawn. Under the default Poll both climb about 60 a second. Press "Event-driven: Wait" and the right column reads idle and its counter stops, then goes up by one each time an event arrives (one every 1.6 s in this demo) while the left keeps climbing. That's why a game sets Poll and asks for a redraw each frame.

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.

The same loop, two control-flow shapes (complete programs)
// 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.

Each bar is one frame's duration; the dashed line is the target. Switch the three ways of waiting, then change the target and the work per frame. The widget models the rule Dawson describes (a sleeping thread wakes on the first timer tick at or after the time it asked for) rather than measuring your machine. At 60 fps with 4 ms of work, 15.625 ms ticks still average 60 fps, but no frame lands within 1 ms of 16.67: about two thirds last exactly one tick, 15.625 ms, so the loop runs ahead of its schedule, and the rest swing between about 3.5 and 31 ms as it falls behind and catches up. 1 ms ticks put about 97% of frames within 1 ms. Sleep, then spin (sleep to 2 ms before the deadline, spin the rest) hits every deadline, at the cost of the spin.
A frame cap: sleep only, or sleep then spin, measured on your machine (complete program)
// 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].

Rendering is portable; surface creation is platform-specific by design

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.

Create the surface (overview; the swapchain is the Vulkan module)
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.

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:

A 1280×720 window modeled at 1/8 scale, then magnified so single pixels are visible. Change the display scale, try both viewports, then turn off DPI awareness. A DPI-aware process with the viewport = framebuffer renders one pixel per screen pixel: sharp lines, full window. Set viewport = window size and at 150% the scene covers 1280×720 of the 1920×1080 image, 44% of it, in the top-left corner, with the clear color around it. Turn awareness off and the app renders 1280×720, which the OS stretches to 1920×1080: the right size, but each rendered pixel smeared over 1.5×1.5 screen pixels, so the thin lines go soft. Unaware, both viewport buttons give the same picture, because the app is told its window and its framebuffer are both 1280×720.

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:

A mock game window with a bouncing ball. Drag it by the title bar (or press drag automatically), once per strategy, and watch the frames-per-second readout. Game loop only: update and render both run in the stalled loop, so the ball freezes from the moment the drag starts until you let go and the readout drops to 0. On release, the 0.25 s catch-up cap from §5 throws away the rest of the stalled time, so the ball resumes close to where it froze. WM_TIMER in the drag: a timer started on WM_ENTERSIZEMOVE is dispatched by the modal loop, so frames keep coming; the widget draws 64 a second, the rate of the default 15.625 ms tick, while a real timer set to USER_TIMER_MINIMUM (10 ms) can't fire more than 100 times a second and fires less often when the tick is coarser. Render thread: the drawing (and here the simulation with it) never depended on the message loop, so it doesn't notice the drag. With the timer or the thread, the simulation keeps real time through the drag.

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 (complete program)
// 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));
}
What's intentionally missing

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

"(Not Responding)"A long frame or a blocking WndProc starves the pump past 5 seconds; the OS ghosts the window.
Freeze while resizingWin32 runs a modal pump inside DispatchMessage; render from WM_PAINT/a timer or another thread.
winit: nothing animatesControlFlow::Wait with no request_redraw(). Use Poll and request a redraw each frame.
winit: deprecated APIThe closure EventLoop::run is deprecated in 0.30; use run_app + ApplicationHandler.
Surface creation failsGLFW window made with a GL context; create it with GLFW_NO_API for Vulkan.
Blurry or cornered on a high-DPI displayA DPI-unaware process gets bitmap-stretched, so it looks blurry; a viewport sized in logical units fills only a corner. Declare Per-Monitor V2 and size the swapchain from the framebuffer in pixels.
Timing with the wall clockThe time of day can jump. Measure frames with a monotonic counter (QPC, steady_clock, Instant).
Sleep-only frame capSleeps end on timer ticks, so frames swing around the target. Sleep short, then spin, or let vsync pace the loop.
Restarting the schedule each frameSleeping "one period from now" makes every late frame push the rest back. Advance a fixed deadline instead.
Ignoring WM_DPICHANGEDA Per-Monitor V2 window that doesn't apply the suggested rectangle looks half-size on a 2x monitor.
Crash off the main threadCreating the window/event loop on a worker thread; keep it on main.

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.

  1. Jason Gregory. Game Engine Architecture, "Human Interface Devices" and the platform/OS layer. gameenginebook.com. The platform-abstraction layer and the OS event model.
  2. Microsoft. "Using Messages and Message Queues." learn.microsoft.com. The GetMessage/TranslateMessage/DispatchMessage loop and that DispatchMessage invokes the window procedure.
  3. Microsoft. "GetMessage function." learn.microsoft.com. Blocks for a message; returns 0 on WM_QUIT, −1 on error (the > 0 idiom).
  4. Microsoft. "PeekMessage function." learn.microsoft.com. Non-blocking message retrieval, the games path that renders every iteration.
  5. 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.
  6. GLFW. "Input guide: event processing." glfw.org. glfwPollEvents (the games path) vs glfwWaitEvents (tools).
  7. SDL. "SDL_PollEvent." wiki.libsdl.org. Non-blocking event drain, the per-frame poll-loop pattern, main-thread.
  8. winit. ApplicationHandler trait. docs.rs/winit. The required resumed/window_event methods and rendering in RedrawRequested (winit 0.30.x).
  9. SDL. "Main callbacks in SDL3." wiki.libsdl.org. SDL_AppInit/Iterate/Event/Quit, and why iOS and the web force the callback model.
  10. winit. Changelog. docs.rs/winit. ApplicationHandler/run_app added in 0.30.0; EventLoop::run deprecated.
  11. winit. ControlFlow enum. docs.rs/winit. Poll for games vs Wait (the default) for reactive UIs.
  12. Microsoft. "Acquiring high-resolution time stamps." learn.microsoft.com. QueryPerformanceCounter for time stamps of 1 µs resolution or better that don't need an external time reference.
  13. 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.
  14. 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 to timeBeginPeriod.
  15. 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.
  16. 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.
  17. 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.
  18. The Rust standard library. library/std/src/sys/thread/windows.rs, sleep. github.com/rust-lang/rust. On Windows, thread::sleep tries a high-resolution waitable timer first ("Windows 10, version 1803+") and falls back to Sleep.
  19. Microsoft STL. stl/inc/thread and stl/src/sharedmutex.cpp. github.com/microsoft/STL. sleep_for calls sleep_until, which loops on _Thrd_sleep_for, a call to Sleep with a millisecond count.
  20. The Khronos Group. Vulkan Specification, Window System Integration (WSI). docs.vulkan.org. VK_KHR_surface plus the platform surface extensions feeding VK_KHR_swapchain.
  21. GLFW. "Vulkan guide." glfw.org. glfwCreateWindowSurface and the GLFW_NO_API requirement.
  22. 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.
  23. ash-window. create_surface. docs.rs/ash-window. Creating a VkSurfaceKHR from a raw window/display handle, and the required-extensions helper.
  24. 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/DispatchMessage loop 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 when DefWindowProc returns.
  25. GLFW. "Window guide." glfw.org. Window creation, the framebuffer-size callback, content scale, and windowed full screen.
  26. 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.
  27. Microsoft. "WM_DPICHANGED message." learn.microsoft.com. The suggested window rectangle in lParam, applied with SetWindowPos.
  28. Microsoft. "WM_TIMER message" and "SetTimer function." learn.microsoft.com; SetTimer. WM_TIMER is low priority, generated by GetMessage/PeekMessage when no other messages are waiting; timeouts below USER_TIMER_MINIMUM (10 ms) are raised to it.

See also