Причина написания статьи
При разработке или портировании игры для PC приходится иметь дело с пользовательским вводом, который обычно разделяется на три категории источников: мышь, клавиатуру и геймпады.
Поначалу может показаться, что работать с мышью и клавиатурой проще всего, но на самом деле это не так; по крайней мере, когда мы говорим о Windows. Множество очень популярных AAA-игр было выпущено с серьёзными проблемами ввода с мышами верхнего ценового диапазона, и в некоторых популярных движках по-прежнему существует эта проблема.
В этой статье мы изучим причины этого, а также создадим работающее, но неудовлетворительное решение. Думаю, для правильной работы с такими аксессуарами, как рули, джойстики и другие симуляторные устройства, требуется целый дополнительный уровень сложности, но пока мне пока не доводилось работать над игрой, в которой бы нужен был такой ввод, поэтому в статье мы не будем его рассматривать.
Хотя основная часть этой статьи посвящена вводу с мыши, мы недавно обнаружили нечто очень любопытное о производительности xinput, и поделимся этим ближе к концу статьи.
Введение – Raw Input
В Windows существует множество способов получения ввода от пользователя. Самый традиционный из них — это получение сообщений Windows, отправляемых в очередь сообщений вашего приложения. Именно так мы получаем ввод с клавиатуры и мыши в типичном приложении Windows. Однако когда дело касается игр, такой способ имеет некоторые недостатки.
Самый важный из этих недостатков заключается в том, что невозможно использовать очередь сообщений для получения точного и немодифицированного ввода мыши, что особенно важно для игры, в которой мышь используется для управления 3D-камерой. Традиционный ввод предназначен для управления курсором, поэтому до того, как он достигнет вашего приложения, система применяет к нему ускорение и другие преобразования; кроме того, она не обеспечивает субпиксельной точности.
Если в вашей игре управление выполняется только курсором, например, если это стратегия или адвенчура point-and-click, то вам, вероятно, вполне можно игнорировать эту статью и будет достаточно стандартных сообщений Windows.
Решение этой проблемы заключается в использовании Raw Input API, позволяющего получать ввод от таких устройств, как мыши и клавиатуры, в сыром, неизменном виде. Именно этот API использует большинство игр для получения ввода мыши; в статье по ссылке представлено хорошее введение по его использованию, которое здесь я повторять не буду.
Почему же в статье есть жалобные нотки? О, мы ещё только начали.
Работа с Raw Input
Если вы знакомы с Raw Input API или просто прочитали документацию по ссылке, то можете полагать, что я буду говорить о важности использования буферизированного ввода вместо обработки отдельных событий, но на самом деле ситуация бы была не так плоха и не стоила написания статьи. Реальная проблема заключается в том. что всё далеко не так просто – насколько я знаю, не существует обобщённого способа сделать это.
Давайте вернёмся немного назад: существует два способа получения сырого ввода от устройства:
-
Использование стандартных операций считывания из устройства, это самый простой способ. По сути, для этого лишь нужно получать в очередь сообщений дополнительные сообщения типа
WM_INPUT
, которые затем можно обрабатывать. -
Использование буферизованных операций чтения: получение доступа одновременно ко всем сырым событиям ввода при помощи вызова
GetRawInputBuffer
.
Как можно предположить, последний способ задумывался как более производительный, поскольку обработка сообщений отдельных событий при помощи очереди сообщений не особо эффективна.
Делать это, и делать это правильно, не так просто, как должно быть (или, возможно, я что-то пропустил). Насколько я знаю, чтобы избежать проблем, связанных с «потерей» сообщений, создаваемых в определённые моменты времени, при обработке только сырого ввода в пакетном виде нужно сделать что-то подобное:
processRawInput(); // здесь выполняется всё, связанное с `GetRawInputBuffer`
// просматриваем все сообщения *за исключением* WM_INPUT
// исключение: когда приложения нет фокуса, то мы просматриваем все сообщения, чтобы проснуться в нужный момент
MSG msg{};
auto peekNotInput = [&] {
if(!g_window->hasFocus()) {
return PeekMessage(&msg, NULL, 0, 0, PM_REMOVE);
}
auto ret = PeekMessage(&msg, NULL, 0, WM_INPUT-1, PM_REMOVE);
if (!ret) {
ret = PeekMessage(&msg, NULL, WM_INPUT+1, std::numeric_limits<UINT>::max(), PM_REMOVE);
}
return ret;
};
while (peekNotInput()) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
runOneFrame(); // здесь находится игровая логика
Как видно из показанного выше фрагмента кода, нужно просматривать все сообщения за исключением WM_INPUT
, чтобы точно не потерять какие-то сообщения, возникающие между моментами обработки пакетного сырого ввода и «обычными» сообщениями. В документации это сформулировано не очень чётко, и API тоже не особо упрощает задачу, но пара лишних строк кода решает проблему.
Но это всё равно не было бы особой проблемой; нормальный объём возни, вполне ожидаемый при работе с операционной системой, которой приходится поддерживать несколько десятков лет обратной совместимости. Так что давайте перейдём к реальной проблеме.
Реальная проблема
Допустим, вы сделали всё это правильно и теперь мы получаем от мыши сырой вывод с буферизацией, как и предполагалось. Можно подумать, что проблема решена, но нет. На самом деле мы всё ещё только начинаем.
Выше показано сравнение графика частоты кадров в одной и той же сцене. Единственное отличие заключается в том, что в нижней части мышью ожесточённо трясут, и не какой-то простой мышью, а дорогой, с частотой опроса 8 кГц. Как видите, одно лишь перемещение мыши рушит производительность, роняя её с мягкой границы FPS (примерно 360 FPS) до примерно 133 FPS с очень нестабильными показателями. И всё это всего лишь активным движением мыши.
Вы можете подумать «Ага, он показал этот пример, чтобы показать важность пакетной обработки!» Увы, но нет — показанное выше, к сожалению, и есть производительность игры при пакетной обработке сырого ввода. Давайте разберёмся, почему так происходит и что с этим делать.
Проклятье легаси-ввода
Если говорить вкратце, то проблема заключается в так называемом «legacy input». При инициализации сырого ввода для устройства при помощи RegisterRawInputDevices
можно задать флаг RIDEV_NOLEGACY
. Этот флаг не позволяет системе генерировать «легаси»-сообщения, например, WM_MOUSEMOVE
. И в этом-то и есть наша проблема: если не задать этот флаг, то система будет генерировать и сообщения сырого ввода, и легаси-сообщения, и последние всё равно будут засорять очередь сообщений.
Так почему же я жалуюсь на это? Можно ведь просто отключить легаси-ввод, ведь так? Это действительно решает проблему с производительностью, разумеется, если вы делаете всё правильно, как показано выше.
Вы поздравляете себя с успешно выполненной работой и переходите к следующей задаче. Спустя несколько дней сборка отправляется бета-тестерам и вы получаете баг-репорт о том, что окно игры больше нельзя перемещать. И тогда вы осознаёте, что отключили системе возможность перемещать окно, потому что это делается при помощи легаси-ввода.
Отключение легаси-ввода отключает все виды взаимодействий со вводом, которые обычно обрабатываются системой.
Что же с этим сделать? Вот краткий список всего, что я попробовал или даже полностью реализовал; всё это или не работало, или не было возможно, или оказалось просто глупым с точки зрения сложности:
-
Использовать отдельное окно только для сообщений и поток для обработки ввода. Это казалось хорошим решением, поэтому я решил его реализовать. По сути, для этого нужно создать совершенно отдельное невидимое окно и регистрировать с его помощью сырой ввод. Немного запарно, но мне казалось, что это позволит решить проблему, и решить её «правильно». Но увы, система всё равно продолжала с высокой частотой генерировать легаси-сообщения для основного окна, даже если устройство сырого ввода было зарегистрировано другим окном.
Сырой ввод влияет на весь процесс, несмотря на то, что API получает дескриптор окна.
-
Отключать легаси-ввод только в полноэкранных режимах. По крайней мере, это решит проблему у подавляющего большинства пользователей, но, насколько я знаю, сделать это невозможно. Похоже, нельзя переключаться между легаси-вводом и сырым вводом после его включения. Можно подумать, что поможет
RIDEV_REMOVE
, но он полностью удаляет весь генерируемый устройством ввод, и легаси, и сырой.Нельзя переключаться между легаси-вводом и сырым вводом после его включения.
-
Использовать отдельный процесс для передачи сырого ввода. Это довольно дурацкая идея, но я думал, что она на самом деле сработает. Можно создать отдельный процесс, передающий сырой ввод основному процессу, а затем использовать какую-нибудь IPC для передачи ввода. Это будет очень заморочено, и я не хочу поддерживать что-то подобное, но почти уверен, что это сработает.
-
Отключить легаси-ввод, создавать собственные события легаси-ввода с низкой частотой. Ещё одна идея из категории «дурацкая, но должна сработать», однако легаси-сообщений много, и поддерживать всё это тоже было бы настоящим кошмаром.
-
Переместить всё из потока, выполняющего обработку основной очереди сообщений. Я бы определённо попробовал этот подход, если бы начинал с нуля, но для его реализации потребовалось внести в имеющуюся кодовую базу огромные изменения,. И при этом один поток всё равно тратил бы кучу времени на бессмысленную обработку сообщений ввода.
Варианты 1 и 2 выглядели достаточно реалистично, но первый не сработал, а второй оказался невозможным. Остальные, на мой взгляд, слишком дурацкие, чтобы исследовать их для применения в готовой игре, или нереализуемы при портировании.
Так что теперь вы понимаете, почему для PC выпускают AAA-игры, ломающие 8-килогерцовые мыши, и почему я немного расстроен ситуацией. Что же мы сделали?
Наше решение
Наше нынешнее решение очень дурацкое и, казалось бы, работать не должно, или, по крайней мере, должно иметь серьёзные последствия, но пока оно, похоже, работает хорошо и не создаёт никаких проблем. Это что-то типа хака, но это лучшее, что мы пока смогли придумать.
В решении легаси-ввод остаётся включенным, но для настоящего ввода игры используется пакетный сырой ввод. Дурацкий трюк состоит в следующем: мы предотвращаем падение производительности, просто не обрабатывая больше, чем N
событий очереди сообщений за кадр.
Пока мы работаем с N=5
, но это достаточно произвольный выбор. Когда я попробовал это решение, у меня было много вопросов: что если будет накапливаться куча сообщений? Что если окно перестанет реагировать? Меня не волнует сам ввод в игре, потому что мы быстро и с очень низкой задержкой получаем все буферизованные события сырого ввода, но из-за накапливания сообщений окно может перестать реагировать на взаимодействия.
После большого количества тестов с 8-килогерцовой мышью ничего этого не обнаружилось, хотя мы и сильно пытались.
Вот в такой ситуации мы оказались: совершенно неудовлетворительное решение, которое, похоже, хорошо работает и обеспечивает сырой ввод с частотой 8 кГц без падения производительности и без влияния на легаси-взаимодействия с окном. Если вы знаете, как решить проблему правильно, то напишите мне комментарий к оригиналу поста, отправьте письмо, найдите меня на улице и подскажите. Да хотя бы отправьте почтового голубя. Я буду очень благодарен.
Примечание об XInput
Этот раздел совершенно не связан с остальной частью статьи, но мне он показался интересным и, возможно, будет для кого-то новым. Можно подумать, что при использовании XInput API для работы с геймпадами ошибиться практически невозможно. Это чрезвычайно простой API, и по большей мере мы просто используем XInputGetState
. Однако в документации есть любопытное примечание, которое очень легко упустить:
Из соображений производительности не вызывайте XInputGetState для слота пользователя ‘empty’ в каждом кадре. Мы рекомендуем делать промежутки в несколько секунд между проверками новых контроллеров.
Это не просто фраза: мы наблюдали падение производительности на 10-15% в чрезвычайно ограниченных ресурсами CPU случаях, когда всего лишь вызывали в каждом кадре XInputGetState
для контроллеров, если не было подключено ни одного контроллера!
Понятия не имею, почему API спроектирован таким образом и почему у него нет какого-то внутреннего отслеживания на основе событий, которое бы сделало вызовы отключенных слотов контроллеров практически «бесплатными», но ситуация именно такова. Вам придётся реализовать собственный механизм отката, чтобы избежать этого падения производительности, потому что не существует альтернативного API (по крайней мере, в чистом XInput), сообщающего, подключен ли контроллер.
Это ещё одна область, в которой имеющийся API достаточно неудобны — обычно мы стремимся к тому, чтобы никакой N-ный кадр не занимал больше времени, чем его соседи, поэтому необходимо переносить всё это в другой поток. Но с этим всё равно гораздо проще справиться, чем с проблемой сырого ввода с мыши и высокой частоты опросов.
Заключение
Игровой ввод в Windows неидеален. Надеюсь, эта статья сэкономит кому-нибудь время, и, как я сказал выше, мне бы хотелось узнать, как решить эту проблему правильно.
А мы ведь ещё даже не говорили о клавиатурных раскладках! Если вы пользователь с QWERTZ, то, наверно, задавались вопросом, почему по умолчанию действия привязаны к клавишам Z
, X
и C
, нелогичным для вашей клавиатуры? Но это уже история для другой статьи.
Автор: PatientZero