DEV Community

Adam Sawicki
Adam Sawicki

Posted on • Originally published at asawicki.info on

Lost clicks and key presses on low FPS

There is a problem with handling input from mouse and keyboard in games and other interactive applications that I just solved. I would like to share my code for the solution. When your app uses a loop that constantly calculates and renders frames, like games usually do, it may seem natural to just read current state of every mouse and keyboard key (whether it's down or up) on each frame. You may then caculate derived information, like whether a button has just been pressed on released, by comparing new state to the state from previous frame. This is how Dear ImGui library works. So first solution could look like this:

void UpdateFrame()
{
    // Fill ImGui::GetIO().DeltaTime, KeyCtrl, KeyShift, KeyAlt etc.
    ImGui::GetIO().MouseDown[0] = (GetKeyState(VK_LBUTTON) & 0x8000) != 0;
    ImGui::GetIO().MouseDown[1] = (GetKeyState(VK_RBUTTON) & 0x8000) != 0;
    ImGui::GetIO().MouseDown[2] = (GetKeyState(VK_MBUTTON) & 0x8000) != 0;
    for(uint32_t i = 0; i < 512; ++i)
        ImGui::GetIO().KeysDown[i] = (GetKeyState(i) & 0x8000) != 0;

    ImGui::NewFrame();

    if(ImGui::IsKeyPressed('A'))
        // Do something...
}
Enter fullscreen mode Exit fullscreen mode

There is one problem with this approach. If user presses and releases a key for a very short time, so that both press and release happens between two frame, it will go unnoticed. This is very annoying. It happens especially when:

  • Framerate in your program is low.
  • Clicks are very fast because they are generated programatically e.g. automatic double-click generated by X-Mouse Button Control or other mouse software.

First step towards solving this is to react to "real" events that are sent by the operating system:

LRESULT WINAPI WndProc(HWND wnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch(msg)
    {
    case WM_LBUTTONDOWN:
    case WM_LBUTTONDBLCLK:
        SetCapture(wnd);
        ImGui::GetIO().MouseDown[0] = true;
        return 0;
    case WM_RBUTTONDOWN:
    case WM_RBUTTONDBLCLK:
        SetCapture(wnd);
        ImGui::GetIO().MouseDown[1] = true;
        return 0;
    case WM_MBUTTONDOWN:
    case WM_MBUTTONDBLCLK:
        SetCapture(wnd);
        ImGui::GetIO().MouseDown[2] = true;
        return 0;
    case WM_LBUTTONUP:
        ImGui::GetIO().MouseDown[0] = false;
        ReleaseCapture();
        return 0;
    case WM_RBUTTONUP:
        ImGui::GetIO().MouseDown[1] = false;
        ReleaseCapture();
        return 0;
    case WM_MBUTTONUP:
        ImGui::GetIO().MouseDown[2] = false;
        ReleaseCapture();
        return 0;
    case WM_KEYDOWN:
        if (wParam < 512)
            ImGui::GetIO().KeysDown[wParam] = true;
        return 0;
    case WM_KEYUP:
        if (wParam < 512)
            ImGui::GetIO().KeysDown[wParam] = false;
        return 0;
    case WM_CHAR:
        // Text input should be processed here, not in WM_KEYDOWN.
        // wParam is entered characater.
        return 0;
    case WM_SYSCOMMAND:
        // Stop ALT from freezing entire application.
        if((wParam & 0xfff0) == SC_KEYMENU)
            return 0;
        break;
    // Handle WM_CREATE, WM_DESTROY, WM_CLOSE and everything else you need...
    }
    return DefWindowProc(wnd, msg, wParam, lParam);
}

void UpdateFrame()
{
    // Fill ImGui::GetIO().DeltaTime, KeyCtrl, KeyShift, KeyAlt etc.

    ImGui::NewFrame();

    if(ImGui::IsKeyPressed('A'))
        // Do something...
}
Enter fullscreen mode Exit fullscreen mode

This doesn't solve the problem though. When both press and release events happen between two frames, state in ImGui::GetIO() is changed back and forth and still goes unnoticed in the next frame.

Of course it would be best to just react directly to these Windows messages, but if you need to pass input state to ImGui or some other system that just expects current state at the beginning of each new frame, solution is to create our own queue of input messages. These messages will be posted in WndProc and consumed in UpdateFrame, where ImGui::GetIO() state will also be updated. UpdateFrame can consume multiple messages from the queue, unless they refer to the same key - then the second message and all following messages must be left for the next frame. It will introduce some lag, but with this approach to handling input there is no other way to detect a very fast double-click other than to spread it into multiple frames:

Frame i: MouseDown[button] = true;
Frame i+1: MouseDown[button] = false;
Frame i+2: MouseDown[button] = true;
Frame i+3: MouseDown[button] = false;
Enter fullscreen mode Exit fullscreen mode

My final solution looks like this:

struct SMouseEvent
{
    int8_t Button;
    bool Down;
};
struct SKeyboardEvent
{
    uint16_t Key;
    bool Down;
};
static const uint32_t EVENT_QUEUE_MAX_COUNT = 8;
static SMouseEvent g_MouseEvents[EVENT_QUEUE_MAX_COUNT];
static uint32_t g_MouseEventCount;
static SKeyboardEvent g_KeyboardEvents[EVENT_QUEUE_MAX_COUNT];
static uint32_t g_KeyboardEventCount;

void AddMouseEvent(int8_t button, bool down)
{
    if(g_MouseEventCount < EVENT_QUEUE_MAX_COUNT)
        g_MouseEvents[g_MouseEventCount++] = { button, down };
}

void AddKeyboardEvent(uint16_t key, bool down)
{
    if(g_KeyboardEventCount < EVENT_QUEUE_MAX_COUNT)
        g_KeyboardEvents[g_KeyboardEventCount++] = { key, down };
}

LRESULT WINAPI WndProc(HWND wnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch(msg)
    {
    case WM_LBUTTONDOWN:
    case WM_LBUTTONDBLCLK:
        SetCapture(wnd);
        AddMouseEvent(0, true);
        return 0;
    case WM_RBUTTONDOWN:
    case WM_RBUTTONDBLCLK:
        SetCapture(wnd);
        AddMouseEvent(1, true);
        return 0;
    case WM_MBUTTONDOWN:
    case WM_MBUTTONDBLCLK:
        SetCapture(wnd);
        AddMouseEvent(2, true);
        return 0;
    case WM_LBUTTONUP:
        AddMouseEvent(0, false);
        ReleaseCapture();
        return 0;
    case WM_RBUTTONUP:
        AddMouseEvent(1, false);
        ReleaseCapture();
        return 0;
    case WM_MBUTTONUP:
        AddMouseEvent(2, false);
        ReleaseCapture();
        return 0;
    case WM_KEYDOWN:
        if (wParam < 512)
            AddKeyboardEvent((uint16_t)wParam, true);
        return 0;
    case WM_KEYUP:
        if (wParam < 512)
            AddKeyboardEvent((uint16_t)wParam, false);
        return 0;
    case WM_CHAR:
        // Text input should be processed here, not in WM_KEYDOWN.
        // wParam is entered characater.
        return 0;
    case WM_SYSCOMMAND:
        // Stop ALT from freezing entire application.
        if((wParam & 0xfff0) == SC_KEYMENU)
            return 0;
        break;
    // Handle WM_CREATE, WM_DESTROY, WM_CLOSE and everything else you need...
    }
    return DefWindowProc(wnd, msg, wParam, lParam);
}

void UpdateFrame()
{
    ImGuiIO& io = ImGui::GetIO();
    // Fill io.DeltaTime, KeyCtrl, KeyShift, KeyAlt etc.

    // Process mouse events.
    {
        uint32_t processCount = g_MouseEventCount;
        // Limit processCount to avoid multiple events with same button.
        for(uint32_t i = 0; i + 1 < processCount; ++i)
        {
            for(uint32_t j = i + 1; j < processCount; ++j)
            {
                if(g_MouseEvents[j].Button == g_MouseEvents[i].Button)
                {
                    processCount = j;
                    break;
                }
            }
        }
        // Apply mouse events.
        for(uint32_t i = 0; i < processCount; ++i)
        {
            const SMouseEvent& event = g_MouseEvents[i];
            io.MouseDown[event.Button] = event.Down;
        }
        // Remove events from queue.
        for(uint32_t i = 0; i < g_MouseEventCount - processCount; ++i)
            g_MouseEvents[i] = g_MouseEvents[processCount + i];
        g_MouseEventCount -= processCount;
    }

    // Process keyboard events.
    {
        uint32_t processCount = g_KeyboardEventCount;
        // Limit processCount to avoid multiple events with same key.
        for(uint32_t i = 0; i + 1 < processCount; ++i)
        {
            for(uint32_t j = i + 1; j < processCount; ++j)
            {
                if(g_KeyboardEvents[j].Key == g_KeyboardEvents[i].Key)
                {
                    processCount = j;
                    break;
                }
            }
        }
        // Apply keyboard events.
        for(uint32_t i = 0; i < processCount; ++i)
        {
            const SKeyboardEvent& event = g_KeyboardEvents[i];
            io.KeysDown[event.Key] = event.Down;
        }
        // Remove events from queue.
        for(uint32_t i = 0; i < g_KeyboardEventCount - processCount; ++i)
            g_KeyboardEvents[i] = g_KeyboardEvents[processCount + i];
        g_KeyboardEventCount -= processCount;
    }

    ImGui::NewFrame();

    if(ImGui::IsKeyPressed('A'))
        // Do something...
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)