Из-за чего на Mac лагает курсор: macOS опрашивает мышь синхронно с частотой кадров
Одни и те же 100 мс: сверху события от мыши, снизу события в приложении; ниже гистограммы интервалов за все 20 секунд
Я запускаю Counter‑Strike 2 на MacBook Pro 14 с чипом M3 Pro. Производительности процессора хватает с запасом — стабильно держится выше 100 кадров в секунду, однако прицел всё равно ощущается «вязким»: микродоводки часто оказываются неточными, перелетая цель или, наоборот, не доходя до неё. Поначалу я грешил на системное ускорение курсора, затем на ограничитель фреймтрейта и оптимизацию самой игры. Никакие стандартные меры не давали результата, поэтому в один прекрасный момент я решил детально отследить путь сигналов от мыши непосредственно до самого приложения.
Анализ и замеры
Для наглядности я параллельно записал две последовательности движений манипулятора:
-
низкоуровневый системный перехватчик (
CGEventTap) с аппаратными таймстемпами, фиксирующий реальный поток данных от мыши; -
очередь
NSEventв стандартном Cocoa‑окне, отражающую то, что операционная система в конечном итоге передает софту.
Использовалась мышь Logitech, подключенная через радиоприёмник с частотой опроса 1000 Гц, экран работал на 120 Гц (длительность кадра — 8,33 мс), а операционной системой выступала macOS Tahoe. Фоновые процессы вроде Steam намеренно не отключались, сценарий был максимально бытовым. Фиксация данных велась непрерывно на протяжении двадцати секунд.
|
|
отправлено мышью |
получено приложением |
|---|---|---|
|
событий за 20,1 с |
13 452 |
2 269 |
|
медиана интервала |
1,02 мс |
8,08 мс |
|
p90 / p99 |
2,19 / 7,77 мс |
9,90 / 17,06 мс |
|
интервалов короче 2 мс |
82,6% |
0,2% |
|
интервалов 6–10 мс, около одного кадра |
1,0% |
88,6% |
За фиксированный отрезок в 100 мс сенсор сгенерировал 81 событие, тогда как игра получила всего 13. Суть в том, что все пакеты данных, пришедшиеся на один кадр обновления монитора, система принудительно агрегирует и выдает единым пакетом строго раз за фрейм.
Я столкнулся с этим не первый. На девелоперском форуме Apple в ветке Changes in Mouse Event Reporting Frequency in macOS 26.2 четко указано, что macOS теперь искусно прореживает мышиный трек до герцовки дисплея. Аналогичную проблему фиксируют создатели игрового движка sokol (полностью нативный Си без лишних надстроек) в issue #1344. Следовательно, корень проблемы кроется на системном уровне, а не в конкретном игровом проекте.
Почему рабочий стол лишен этой проблемы, а игры страдают
Курсор интерфейса ОС рендерится синхронизированно с пачками входных данных, поэтому пакеты просто транслируются в плавные перемещения. У игровых движков свой собственный цикл отрисовки, который практически никогда не совпадает с таймингами поступления этих порций.
Давайте обратимся к математике для сценария со 100 fps: длительность одного кадра составляет 10 мс, в то время как пачки прилетают каждые 8,33 мс. За 50 мс сменяется 5 кадров и генерируется 6 пакетов данных, из-за чего четыре кадра захватывают по одному пакету, а пятый — сразу два. При равномерном ведении мыши на каждом пятом фрейме прицел будет резко проскакивать вдвое большую дистанцию с частотой двадцать раз в секунду. При 80 fps кадры будут поочередно получать то один, то два пакета. Это расчетные данные, однако на практике ощущения полностью совпадают. Ограничение предельного фреймтрейта лишь смещает эти рывки в пространстве, но не устраняет их полностью.
Что я пытался предпринять
Ускорение указателя рекомендуется отключать в любом случае (через Системные настройки → Мышь): с ним идентичное физическое движение руки приводит к разным углам поворота обзора. Впрочем, к агрегации пачек эта настройка отношения не имеет.
Во многих устаревших обсуждениях советуют использовать флаг isMouseCoalescingEnabled = false. Однако в упомянутом тикете sokol выяснилось, что на версии Tahoe при отключенном бандлинге события начинают поступать с рваными паузами по 50–150 мс, из-за чего разработчики были вынуждены вернуться к стандартной доставке раз за кадр. Сам я этот параметр подробно не тестировал.
Реальным решением оказалось прямое чтение сырых данных с устройства в обход стандартной системной очереди событий.
Как реализовать прямое чтение отчетов HID
Фреймворк IOKit позволяет подписаться на низкоуровневые HID‑инпуты оборудования. Callback-функция активируется на каждый отдельный отчет, предоставляя относительные смещения по осям X/Y и аппаратный таймстемп:
import Foundation
import IOKit.hid
let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
IOHIDManagerSetDeviceMatching(manager, [kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop,
kIOHIDDeviceUsageKey: kHIDUsage_GD_Mouse] as NSDictionary as CFDictionary)
IOHIDManagerRegisterInputValueCallback(manager, { _, _, _, value in
let element = IOHIDValueGetElement(value)
guard IOHIDElementGetUsagePage(element) == UInt32(kHIDPage_GenericDesktop),
IOHIDElementIsRelative(element) else { return }
let usage = IOHIDElementGetUsage(element) // kHIDUsage_GD_X или kHIDUsage_GD_Y
let delta = IOHIDValueGetIntegerValue(value) // сырые отсчёты, без кривой ускорения
let stamp = IOHIDValueGetTimeStamp(value) // mach time отчёта
// X и Y одного отчёта приходят с одной отметкой времени: копим, пока она не сменится.
}, nil)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
Нюансы, всплывшие в процессе разработки:
-
Требуется специальный системный допуск «Мониторинг ввода» (проверяется через
IOHIDCheckAccess/ запросомIOHIDRequestAccess(kIOHIDRequestTypeListenEvent)). Без него callback будет молча игнорировать вызовы без генерации ошибок. Разрешение жестко привязано к цифровой подписи софта: при ad‑hoc подписании любая пересборка проекта автоматически сбрасывает этот статус. Из-за этого у меня однажды прошла целая игровая сессия CS2 без прямого чтения, и причина обнаружилась не сразу. -
Полученные значения абсолютно «сырые», без навязываемой системой кривой акселерации. Для шутеров это идеальный вариант.
-
Некоторые модели периферии передают движения через два независимых HID‑интерфейса одновременно, что приводит к дублированию смещений. Потребуется фильтрация на уровне конкретного девайса.
-
Обработчик лучше выносить в отдельный поток со своим собственным run loop, так как главный поток задействован под рендеринг и оконные события.
-
Прямое чтение целесообразно активировать только тогда, когда игра перехватывает и скрывает курсор. В моменты видимости системного указателя должен работать стандартный пайплайн, иначе клики по элементам интерфейса критически разойдутся с позицией курсора.
Для чего мне это понадобилось
Я развиваю Uncork — утилиту для запуска Windows‑игр из каталога Steam на компьютерах Mac с процессорами Apple Silicon, и вся эта история началась как раз из-за проблем с CS2. Теперь мышиный ввод обрабатывается по описанной выше схеме ровно в те моменты, когда игра захватывает курсор.
Если вы сталкивались с подобными лагами ввода в собственном софте или эмуляторах под macOS, поделитесь методами обхода в комментариях. Особенно любопытно узнать, помогает ли кому-нибудь переключение параметра isMouseCoalescingEnabled в актуальной macOS Tahoe.
Xbox не получила эксклюзивные права на облачный стриминг GTA VI, а ПК останется без облачной версии игры
Энтузиаст собрал миниатюрный аркадный автомат с управлением зубочисткой
Обзор игры Silent Hill Townfall с рекомендацией воздержаться от прохождения
Gears of War 3 теперь можно полностью пройти на ПК
Bungie представила план развития Marathon до марта 2027 года
Хидэо Кодзима объяснил, почему не раскрывает подробности хоррора OD
Хакер voices38 исчез после попытки Denuvo подать на него в суд
Браузерная версия GTA V оказалась заблокирована юристами Take-Two, однако игроки смогли обойти ограничения