Loopy Windows Event Loops

Intro

Aside from “the triangle” rite-of-passage, game devs and engine programmers have a completely different rite-of-passage to contend with, which is setting up an event loop and creating a window.

The “first” event loop most people use is some variation of the following:

while (true)
{
  MSG msg;

  while (PeekMessage(&msg, nullptr, 0u, 0u, PM_REMOVE))
  {
    if (msg.message == WM_QUIT)
    {
      return 0;
    }
    
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }

  simulate_game();
  render();
}

Invariably, in starter code, this loop runs on the main thread (along with everything else). This is, of course, a perfectly serviceable starting point, but at some point, the engine will run into limitations with this setup.

This post is about what those limitations are, and how the event loop could be structured instead.

In most sample/demo code I see in the wild (DirectX samples, Vulkan samples, Dear Imgui samples, and so on), the demos exhibit the following issues:

  • The application is frozen while the window is being moved.
  • The application is frozen while the window is being resized.

In a shipping game or application, this isn’t really ideal. Lots of games are played in windowed mode, after all (including the game I am making), and pausing the game sim while these window manipulations are happening makes for a pretty bad experience.

Note that the freezing application issue happens even if your rendering happens on a dedicated thread, so the answer isn’t just “render on a separate thread.”

Root cause

These are actually both symptoms of the same underlying cause. First, let’s recapitulate some definitions:

  • The client area is the part of the window controlled by the user.
  • The non-client area is everything else (window borders, minimize/maximize buttons, window title, etc.).

When the user clicks something in the client area, you receive an event like WM_LBUTTONDOWN. In contrast, a mouse-button click in the non-client area generates an event like WM_NCLBUTTONDOWN. Similarly, other events pertaining to the non-client area will have an NC prefix before the event label.

If you click the window title bar to drag-move the window, this is roughly what happens:

  • The WM_NCLBUTTONDOWN message is dispatched once you click the NC area.
  • You pull the message off the queue with PeekMessage and send it to your window procedure.
  • You don’t handle the message (why would you), and the message is then passed off to the default window procedure with DefWindowProc.
  • The call to DefWindowProc does not return until the user releases the mouse button to indicate that the move operation has finished.
    • Within the call to DefWindowProc, your message handler is still called, not from your message loop, but an OS internal one.
    • This internal message loop (sometimes referred to as a modal event loop) dispatches its own messages, like WM_SYSCOMMAND and WM_ENTERSIZEMOVE.

Something similar happens when a window resize occurs. After the initial WM_NCLBUTTONDOWN message, you will find that DispatchMessage won’t return until after the resize finishes.

OK, so that’s the problem, how should we fix it?

A mediocre option: WM_PAINT

One common suggestion I see is to respond to WM_PAINT messages, which fire when a window is resized, provided the window was created with the CS_VREDRAW and CS_HREDRAW style flags. Idea being that in your window procedure, you have some code that looks like:

// A "meh" solution

switch (message)
{
  // Other events
  
  case WM_ENTERSIZEMOVE:
    move_resize_active = true;
    break;

  case WM_EXITSIZEMOVE:
    move_resize_active = false;
    break;
  
  case WM_PAINT:
    if (move_resize_active)
    {
      // Pseudocode
      resize();
      render();
    }
    return 0;
  
  // ...
}

There are a feeeeew issues with this.

First, this doesn’t work at all with window movement. Windows never dispatches WM_PAINT messages to the the loop while moving the window, because why should it? As far as Windows is concerned, the content in your window is still valid, so it isn’t going to request a repaint. For 99% of applications, this is the correct choice, since most applications don’t actually need to update and re-render continuously. Furthermore, Windows has no way to know what frequency you would want to repaint at.

The second issue is that even if this did work, your render_scene call would just render the scene as it existed when the resize operation started. Re-rendering things without running the simulation isn’t all that useful. If instead you did something like:

case WM_PAINT:
  if (move_resize_active)
  {
    simulate_game();
    render();
  }
  return 0;

now you have a different problem where the simulation frequency is coupled to whatever frequency the OS decides to dispatch WM_PAINT (which depends on how quickly the user performs the resize). As implemented above, this is also going to impact the responsiveness of the resize operation.

What if the user starts a resize option, and then doesn’t actually move the mouse, such that WM_PAINT messages aren’t generated? Well… yea this isn’t going to cut it.

Another mediocre option: WM_TIMER

Another suggestion I see from time to time is to invoke SetTimer at the start of a modal move/resize. The timer would then periodically push WM_TIMER events onto the message queue. This way, even during a move operation, you could technically advance the game sim and render the scene in response to WM_TIMER messages, before killing the timer when the modal loop finishes.

I also find this solution pretty dissatisfactory. For one thing, the WM_TIMER events can be really inconsistent due to not just Windows timer resolution but whatever other code is running within the OS modal loop. Another issue is that when the user clicks the non-client area, the modal loop has technically started immediately before you get the WM_ENTERSIZEMOVE message. Clicking the non-client area without dragging the mouse would result in a “freeze” prior to the timer being established. There are workarounds to this, like starting the timer in response to the initial non-client interaction, intercepting the WM_SYSCOMMAND message, and other state management shenanigans in your window procedure, but I’ve found all these workarounds to still feel janky for one reason or another.

However you look at it, the underlying problem is that for the game sim and render loop, we want to control the pacing, but the loop written as-is allows the OS to control the pacing. That’s bad.

Even less ideal: manual move/resize

Yet another (even less ideal) option is to try to intercept non-client events and try to handle window moves and resizes yourself.

In principle, this sounds easy, but having tried this myself over the years, there are too many footguns for me to recommend this approach. If you wanted to do this, it should just be a matter of:

  • Handling WM_NCHITTEST to determine if the user is trying to initiate a move or resize.
  • Preventing WM_SYSCOMMAND from being handled by the default procedure.
  • Manually calling SetWindowPos as needed in response to WM_MOUSEMOVE events.

Maybe in an hour or so, you might have something that appears to work. Down the line though, you realize there were a bunch of things your implementation didn’t consider:

  • Window snapping doesn’t work (this is where your window resizes to encompass a region in your workspace).
  • Moving the window across monitors also isn’t always working as expected.
  • Events like WM_SIZE and WM_MOVE aren’t being emitted as expected, so code written prior to your manual implementation has broken in subtle ways.

The other confounding factor is that even if you fix the issues above and go through the trouble of making your window positioning virtual desktop aware, there is no guarantee that your code will function in future versions of Windows (in fact, it’s almost certain it won’t). The modal move/resize code is hiding a good deal of complexity, and trying to redo it all is not only a lot of work, it’s also not actually possible because the exact behavior of the modal event loop isn’t specified anywhere. This changed on me going from Windows 7 to Windows 10 (the final version of Windows ever, I was told), and then again from Windows 10 to Windows 11. To be clear, forwards compatibility is something that Windows actually does exceptionally well, but messing with the event loop is perhaps one of the few areas where you don’t want to get overly fancy.

Dedicated event thread

Since the problem is that the OS modal event loop hijacks our own event loop with an internal one, a potential solution is to operate the event loop in a dedicated thread instead.

This is the approach I take, and if you choose to do this also, here are a few things you should be aware of.

Window creation

First, messages pertaining to a window must be handled on the thread that created the window. This is a hard restriction, and there’s no way I am aware of to change the thread association of a window once it’s been created.

You have the option of creating a separate thread per window, but this significantly complicates the architecture down the line, not to mention the performance headache of all these window threads fighting for timeslices from the scheduler to handle events in short bursts.

The simplest way to handle this is to leverage a dedicated custom message to request that a window be created. In the Rookery, window creation is synchronous and the code looks roughly like:

// Called from the main thread
void Window::create()
{
  // event_thread_id is the thread ID of our
  // dedicated message pump thread.
  //
  // create_window_message is a constant defined
  // to be WM_APP + 1
  PostThreadMessage(
    event_thread_id,
    create_window_message,
    (u64)this,
    0ll);

  // created_ is a std::atomic<bool> member variable
  // which is initially false.
  created_.wait(false);
  
  // When this function returns, the window is
  // created and ready.
}

// This function is called in response to the
// create_window_message posted on the dedicated
// event thread.
void Window::create_on_event_thread()
{
  // Other random setup...

  // Actually make the window.
  hwnd_ = CreateWindowEx( ... );
  
  // Set the created_ flag and wake the caller.
  created_.store(true);
  created_.notify_one();
}

Of course, for this to work, your dedicated message pump thread code needs to be running as well. An initial implementation might look similar to the basic event loop you started with, with an extra branch to handle our custom message.

// BAD: Illustrative purposes only

// Assume MessagePumpThread::run is the entry point
// for the thread's procedure.
void MessagePumpThread::run()
{
  while (true)
  {
    MSG msg;

    while (PeekMessage(&msg, nullptr, 0u, 0u, PM_REMOVE))
    {
      if (msg.message == WM_QUIT)
      {
        return;
      }
      else if (msg.message == create_window_message)
      {
        Window* window = (Window*)msg.wParam;
        window->create_on_event_thread();
        continue;
      }
    
      TranslateMessage(&msg);
      DispatchMessage(&msg);
    }
  }
}

This code has a few issues we need to fix before it’s fully serviceable. Let’s go through each one.

Missed notification problem

The code as written won’t always work because when you call PostThreadMessage, you may find that the message is never received on the other side. This can happen early on if you try to call create_window before the message pump thread has actually started trying to receive messages. Before the first call to PeekMessage, Windows hasn’t established a message queue yet for the thread, so if you call create_window before the event loop has started, the message may be missed entirely (classic missed notification problem).

The main remedy is to modify the thread procedure to do something like this:

void WindowsEventLoopThread::run()
{
  MSG msg;
  PeekMessage(&msg, nullptr, 0u, 0u, PM_NOREMOVE);
  
  // event_loop_thread_ready_ is a std::atomic<bool>
  event_loop_thread_ready_.store(true);
  event_loop_thread_ready_.notify_one();
  
  // Rest of the function...

Here, we’ve added a PeekMessage call at the beginning to essentially tell Windows “please make a message queue” for this thread. Then, the thread sets an atomic flag and signals that the flag has changed.

Prior to this, the main thread is responsible for kicking off the event loop thread, and then waits until event_loop_thread_ready_ is set. This way, the first call to Window::create won’t result in a missed notification.

OK, that’s one problem resolved. With the correction as is, assuming your main thread loop now just looks like:

while (should_run)
{
  // Ignore how we receive inputs for now
  simulate_game();
  render();
}

things will work and the game will continue to simulate and render while the window … with caveats.

Energy wastage

Aside from the fact that we haven’t talked about how the dedicated event thread communicates with the rest of the game yet (we’ll discuss that later), we have another issue to contend with. As written, the WindowsEventLoopThread::run() function is obscenely wasteful.

Here’s the rough structure of the function again:

void WindowsEventLoopThread::run()
{
  // Setup code ...

  while (true)
  {
    MSG msg;
    while (PeekMessage(&msg, nullptr, 0u, 0u, PM_REMOVE))
    {
      // translate/dispatch and custom message handling
    }
  }
}

The problem is that this function is a busy loop. Even if there are no messages in the queue, it will run continuously until the game exits, effectively hogging an entire CPU core. If you test on a beefy dev machine with lots of cores, you might not notice it for a while (although you’d hear your fans spin up), but on a 2-4 core machine, this mistake will definitely be felt.

This wasn’t as much of a problem when this event loop happened on the main thread, because each PeekMessage spin-loop was interleaved with functions that advanced the game simulation and rendered the scene. In other words, the game itself acted as a form of backpressure.

When the event loop is operating in a separate thread though, there’s nothing implemented yet that says “whoa, slow down,” so we need to implement this.

Energy efficiency is something I really wish devs worried about more. While "moar frames" at all costs is certainly "a strategy", I can't help but think that prioritizing efficiency (and the player's energy bill) should be a bit higher up the totem pole. I can't impose this preference on others, but I certainly endeavor to at least be mindful of energy expenditure in my own code.

A more efficient event loop?

Now that we’ve identified the problem, let’s talk about ways to fix it. The simplest thing you can do is just slap a Sleep(1) in there as a makeshift throttle and call it a day. This works, but let’s try to do better.

One issue with the Sleep(1) approach is sleep accuracy. This is well-documented and you could have a period of time where your event loop thread doesn’t wake up for an arbitrarily long time if the global sleep resolution is set to something finer. You can “fix” this by setting timeBeginPeriod(1), but I’m still not a fan of this approach because it breaks one of the cardinal sins of engine programming:

Don’t wake up a thread if that thread has nothing to do.

– me

Instead, we could turn to GetMessage which is like PeekMessage, except it puts the thread in a waiting state until a message is available. Here’s one possible implementation of this:

// This code still has perf issues which we'll discuss in
// the next section, but is still worth discussing for now.
while (GetMessage(&msg, nullptr, 0u, 0u))
{
  // We don't check WM_QUIT here because GetMessage returns
  // false if the message it retrieves is the quit message.
  if (msg.message == create_window_message)
  {
    // create window code...
  }
  else
  {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }
  
  while (PeekMessage(&msg, nullptr, 0u, 0u))
  {
    if (msg.message == WM_QUIT)
    {
      return;
    }
    if (msg == create_window_message)
    {
      // create window code...
    }
    else
    {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
    }
  }
  
  // Exercise for the reader
  // Do something here to pass inputs and other events of
  // importance back to the game thread.
}

The code above takes advantage of the fact that messages are typically delivered in bursts of associated messages. The first call to GetMessage wakes up the thread when at least one message is available. Afterwards, we do the original PeekMessage-oriented event loop to retrieve all remaining messages, before re-entering the wait state.

Now, the thread will wake immediately when a message is available, and not wake spuriously, which is preferable to the unpredictable processing cadence we would have observed if we tried to issue the wake ourselves with Sleep usage. This is good right?

An even more efficient event loop

Well, not quite.

We’re getting closer to what I would consider the “best” input polling policy. As described, there’s actually still a performance problem that’s worth addressing, which is a lack of input coalescing.

Given a WM_MOUSEMOVE event, if your mouse is constantly moving, it might seem like Windows is just constantly generating input events. This isn’t really happening in reality though. What actually happens is that the various mouse input events are coalesced together until you start examining the message queue.

This means that as written, our dedicated event thread is likely going to constantly wake up whenever inputs are available, and then spin for a bit pulling off un-coalesced input events from the queue, before sleeping again (only to wake up very shortly afterwards).

This behavior is documented in Raymond's blog, which also mentions why you may get "spurious" mouse movement events from time-to-time. The mouse may move but the position may appear unchanged if the input events were coalesced.

Is it useful to read all these uncoalesced inputs far in advance of when we present the frame? Nope!

The solution then is that instead of GetMessage, we actually want some mechanism to kick the event thread and say “hey, please start reading inputs now, I’m going to need them”. You can do this with a futex or whatever mechanism you like, but “something” in the frame needs to tell the input thread to start retrieving all messages that have accumulated up to that point.

while (true)
{
  // This function waits for the "something" to kick the
  // event thread to indicate polling should start.
  wait_for_signal();
  
  while (PeekMessage(&msg, nullptr, 0u, 0u))
  {
    if (msg.message == WM_QUIT)
    {
      return;
    }
    if (msg == create_window_message)
    {
      // create window code...
    }
    else
    {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
    }
  }
  
  // Exercise for the reader
  // Do something here to pass inputs and other events of
  // importance back to the game thread.
}

Here, we aren’t relying on an unfiltered GetMessage to wake the thread any more, but a specific signal. This signal is ideally:

  • as late in the frame as possible (reduces input latency).
  • not so late that you miss the deadline for the next vblank.

In terms of implementing the wait_for_signal function, one option to consider is to wait on a signaled waitable swapchain event with a relatively low frame latency. However, you can’t wait for this directly on the input thread, since you’ll end up in yet another busy loop in periods of time when a swapchain buffer is available to present. Instead, you can designate a specific message to post from the main thread, and rely on GetMessage again with non-zero arguments for the min/max message arguments. Then, you would post this message whenever you want to kick a new frame, depending on how your engine’s frame is pipelined.

A useful thing to understand is how Windows handles thread priority boosting. When we are retrieiving inputs from the message queue, we obviously want the thread performing this work to take priority, since we want the input data to be available as soon as possible for the other engine subsystems.

The documented priority boost behavior indicates that the thread priority of the window-owning thread is dynamically boosted when input messages are retrieved. This priority decays over time down to the thread's base priority. It may be worth increasing the base priority of the event thread if you adopt this approach.

Properly handling resizes

Since one of the original goals was to figure out how to structure an event loop to solve the blocking move/resize problem, let’s just talking about how to handle resizes in general.

Common mistakes

I’m not going to offer links, but I see this question all the time in game dev forums and chat communities. “Why does my new Vulkan or DX12 game/engine/app crash somtimes during a resize?”

If you run into this problem, you are likely running into an issue along these lines:

  • First, check if your GPU flushing mechanism includes the present operation. A common mistake is that the graphics queue is signaled after the scene is rendered, but not after the present. Resizing the swapchain while a present is ongoing is obviously disallowed, so you have to ensure the present isn’t happening before issuing the swapchain resize. Issue a fence signal after the swapchain present and wait on that signal before doing a resize.
  • Second, check if you have in-flight rendering work accessing a swapchain render target view. This mistake is more common than you might think because most resource lifetime safety mechanisms are concerned with resources on the current frame. A swapchain resize is different though because it invalidates all buffers associated with the swapchain. If the prior frame is still in flight on the CPU in a different thread and you issue a swapchain resize, things will probably explode.

Other resize tweaks

Assuming you’ve handled the correctness part, there are still other improvements worth considering.

First, you might be wondering if it’s still worth creating windows with the CS_HREDRAW and CS_VREDRAW style flags after implementing a dedicated event thread. After all, since you’re rendering continuously, do you need those WM_PAINT events in your message queue?

Well, no, you don’t, but you may end up seeing that the scene being rendered isn’t well synchronized with the resize operation itself. Now that the message pump and game sim/render frames are decoupled, the actual window size won’t match the size the scene was rendered to (which affects UI layouts and perspective matrix aspect ratios, for example). A way to fix this is to keep the WM_PAINT messages and synchronize the message pump to the renderer when redraws are requested. In other words, block during the redraw event until a frame has rendered with the expected size. Assuming your game sim and rendering operates at a high framerate, this alone won’t overly impact the responsiveness of the resize, but this is a judgement call you’ll have to make.

Just because you’re asked to resize, doesn’t mean you have to resize

Another pitfall to be aware of is the danger of excessive swapchain resizes. If the swapchain doesn’t match the window size, by default, the swapchain output will be scaled to fit the window. Chances are, your renderer will have other render targets like depth buffers or visibility buffers that have their extents derived from the final swapchain size. If you resize every one of these buffers continuously while the window is being resized, you’re going to churn through a ton of allocations in the video memory allocator. This is something you definitely want to avoid, as it will make the resize operation laggy.

Instead, try to resize the swapchain once when the resize operation changes, or if the swapchain size differs sufficiently from the actual window size during resize. You can still take the “actual window size” into account when computing the UI layout and rendering scene, even if the true swapchain dimensions are different (somewhat easier said than done, since you have to account for the scaling effect). This way, resizing will stay completely responsive and the scene will still appear to respond dynamically with the resize, despite some minor blurring due to DXGI scaling that will go away once the resize operation finishes.

Vulkan will complain about your swapchain surface being suboptimal if it doesn't match the OS window size. This is a case of a validation layer being, IMO, overly helpful to the point of being unhelpful. To wit, I ignore this message and move on with my life.

Finally, it’s a totally valid choice to decide to do none of the above and only support fixed resolutions that are changed in a settings menu. In my case, I am using the renderer and UI engine to also implement various tools and editors, so it was worth the effort to get resizing working just so.

Dedicated event thread … worth?

So far, this dedicated event thread appears to have materialized purely as a solution to handle modal moves and resizes. Isn’t this an excessive architectural change for what is a “minor problem?” The excessiveness, IMO, is mitigated by a few factors.

  • Once you have a dedicated event thread, nothing stops you from doing other useful work on that thread also. Polling gamepad state via XInput is a great example, since I’ve measured this operation to take 0.5ms or so even with no gamepads connected. You may also do some input conditioning here, like consolidating/integrating input motion events, depending on the game.
  • Having a separate data structure to accumulate your input events is something you want anyways. Trying to process inputs as they arrive is not a great policy, since you can end up in a situation where messages arrive more quickly than you’re able to process them.
  • It’s actually often possible to do useful work in parallel with your event processing. For example, audio, advancing particle sims, AI sims, animation, or other systems can happen in parallel without input from the user. This is particularly useful if you support (gratuitously) high poll-rate input devices which can generate a lot of data for you to churn through.
  • Finally, the dedicated thread introduces some complexity sure, but not as much as some of the other hacky workarounds I’ve seen to fixing the frozen-game-during-move-or-resize issue in the wild.

If you are a gamer that happens to stumble on this post, do consider not cranking up your mouse poll rate as high as it will go. Anything over 1 kHz is a power-wasting gimmick, and most code I've seen in the wild will not handle it well. Even if all games did handle this well, you are still just wasting CPU cycles for no perceivable benefit. Frankly, even a 1 kHz poll rate is gratuitous.

If you are a mouse manufacturer trying to push high poll rates as a marketing device, I see you, and I hate what you're doing.

Outro and final tips

Hopefully, I’ve made a decent case for a different paradigm for event processing than how it’s set up in most libraries or sample code out there. By way of really bad closure, here are a few more tips presented haphazardly since they didn’t really fit in elsewhere:

First, don’t call TranslateMessage message if you don’t need it! The point of this function is to check if the message passed corresponds to a virtual key or key combination that should result in a WM_CHAR event. If so, these events are pushed onto the queue in addition to the virtual keydown/up events (and you then need to process them).

TranslateMessage can take a decent amount of time in some scenarios, and you don’t really care about WM_CHAR events during gameplay. Instead, call this only when the user is inputting text. You can do this by pushing a custom message to the queue that’s says “OK start caring about text now” when the user focuses a text input widget in your UI.

Related to the above, you don’t have to call the default window procedure if you don’t need to. For a lot of messages, doing some handling and then propagating the message to the default procedure is wasteful, so do decide how you choose to handle each event. One example of this is WM_ACTIVATE. I’ve profiled engine code spending forever processing this event while the player has the LMB held down to fire a weapon…

In fact, you don’t have to call DispatchMessage in some circumstances also. The role of DispatchMessage is to find the correct window procedure to invoke (and the right thread to invoke it on), but custom messages, WM_QUIT or other message types can be handled directly with your message pump loop without dispatch.

And that’s a wrap, happy event looping!

Discuss this post on Patreon.