Причина написания статьи
При разработке или портировании игры для 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
, нелогичным для вашей клавиатуры? Но это уже история для другой статьи.
Комментарии (19)
sunnybear
03.10.2024 11:05+1Я правильно понял, что из мыши 8к они сделали мышь 1.5к, - и рады этому?
onyxmaster
03.10.2024 11:05+2Нет, неправильно. Мышь 1.5к стала для событий работы с окном, ввод для самой игры остался 8к.
T101
03.10.2024 11:05+4Существует мнение, что 1к герц уже почти на грани человеческих чувств. И что все эти 2к, 4к, 8к не делают ничего, кроме как жрут батарейку как не в себя
dfgwer
03.10.2024 11:05да человек вряд ли отличит 1к от 8к частоты обновления мыши.
Но общая задержка, input lag, это сумма всех задержек.
Задержка ввода + задержка обработки софта + задержка вывода
И это надо срезывать со всех сторон, -1мс ввода это -1мс глобальной задержки.
IhnatKlimchuk
03.10.2024 11:05+17Мышку модную купил - производительность просела
Музыку громко слушаешь - жесткий диск разваливется
Пошел сделать чай с OLED дисплеем - привет выгоревший узор
Забыл открыть окно - перегрев и баня
Купил новый процессор - убивает себя напряжением
Купил новую видеокарту - давай подожжем дом коннектором
Перезагружу OS -пользовательбета тестер, держи обновление с O(n^2) сортировкой, Copilot и Recall сверхуИнтересно читать про такие нюансы. Наверное, такое и раньше было, но сейчас это предается огласке
alek0585
03.10.2024 11:05Перезагружу OS -
пользовательбета тестер, держи обновление с O(n^2) сортировкой, Copilot и Recall сверхуСлышал, что в винде рекламу показывает теперь. В лучших традициях амиго
Ravenholn
03.10.2024 11:05Так это ещё с 8й версии. Когда плитки появились, туда сразу начали пропихивать всякое говно. Теперь плиток нет но в свежей системе в пуске всеравно куча говна типо тиктока и т.д
ropblha
03.10.2024 11:05Не знаю - у меня стоит XPшный Classic Shell уже лет наверно 10 - и я не видел ни плиток ни дальнеиших улучшений. Старый добрый пуск и никакой рекламы. При переустановки винды - установка своей оболчки для интерфейса идет первым пунктом.
nEkToSAN
03.10.2024 11:05А где эту рекламу искать? У меня Win11 Pro, инсталлятор с сайта майков скачивал, ставил лиц ключик без всяких КМС или других взломов лизензирования, не замечал никакой рекламы. Что я делаю не так?
kenomimi
03.10.2024 11:05+2А как 8к мышка подключается? В hid дескрипторе можно минимум 1мс частоту опроса выставить, что дает потолок в 1КГц. Там не hid, а нечто кастомное?
eee
03.10.2024 11:05+2Вот тут обсуждение темы подключения 8к мыши в линуксе. Я так понял мышь объявляет себя как USB 2.0 устройство (480 мбит/c), а дальше ОС и мышь договариваются о частоте общения.
YMA
03.10.2024 11:05+4А мышь с частотой опроса 8к и пятизначным dpi уже может подрабатывать микрофоном? :)
GidraVydra
03.10.2024 11:05+3Джуном она может подрабатывать. А с программируемыми кнопками так и вообще мидлом.
forever_live
03.10.2024 11:05+1Похоже, автор оригинальной статьи в конце концов нашли правильное решение, несмотря на то, что они там, видимо, совсем не представляют, как работает виндовая очередь сообщений и не понимают особенности обработки в ней сообщений от устройств ввода: винда сама прореживает сообщения, склеивая повторяющиеся, если вы не забираете сообщения чаще, чем они вам нужны.
Flavor1337
03.10.2024 11:05Как человек, который, к сожалению или к счастью, потратил на Apex Legends почти 4 тысячи часов, и прошел путь от овоща с kd 1.1 до закрытия сезонов с kd 3.2 и винрейтом под 20%, а также беспроблемному solo q to master, могу сказать, что 4/8к не являются чем-то типа game breaker. Как известно, в играх с высоким TTK очень важен хороший трекинг, и я уже 2 года назад решил попробовать viper 8khz, так как хвалили эту мышь именно про-игроки в Апекс в первую очередь по форме. По итогам полугода игры я пришел к выводу, что форма и эргономика у неё действительно шикарная, а вот 8к (точнее 4к, т.к. тогда движок Ареха 8к не держал, а сейчас хз даже) - фича интересная, так как трекинг на 240 герцовом монике действительно стал чуть более гладким, но кардинально на геймплей это не влияет. То, что ты не затрекаешь на 1кгц, ты не затрекаешь и на 2, и на 4, и на 8. Процент пиков, когда тебе 4к действительно решат исход, не будет разительно большим. Были клачти в close-range, когда казалось, что из-за повышения плавности я всаживал всю обойму с ппшки четко во врага, а раньше мне так четко затрекать не удавалось. Да, для про-игрока этот процент будет выше, так как у него моторика намного лучше, чем у любителя. Поэтому для обычного игрока пусть и нормального уровня, эти 8к ничего не дадут. Сам сейчас сижу на Атлантис V2 и кайфую.
shirvash
03.10.2024 11:05Лично сталкивался с такой проблемой в игре Black & White 2. Мышь современная, 1000 Гц, а игра старенькая, рассчитана только на 125 Гц. Играть было невозможно, курсор постоянно лагал. Помогла родная софтина от мыши, которая позвонили понизить частоту до 125 Гц.
fen-sei
Неожиданно!