Почему прицел плавает на Mac: macOS отдаёт мышь раз в кадр экрана
Замерено 22–25 сентября 2026 · MacBook Pro 14″, M3 Pro, ProMotion 120 Гц, macOS 26
Поиграйте на Mac в шутер с игровой мышью, и рано или поздно что-то покажется не так: счётчик показывает больше 100 кадров, картинка гладкая, а прицел ведёт себя как на резинке. Мелкие поправки перелетают или недолетают. Обычно объясняют «Mac не для игр» или ускорением указателя. Ускорение действительно мешает, но есть ещё одна причина, и она видна в цифрах. Насколько это заметно в самой игре, мы отдельным замером не показывали.
Что мы замеряли
Мы записывали, когда события движения мыши (NSEvent, mouseMoved)
доходят до приложения. Мышь Logitech, отчёты на 1000 Гц, по одному в миллисекунду. Экран — панель
ProMotion на 120 Гц.
Ожидали: событие примерно раз в миллисекунду. Получили: события приходят пачками с шагом 8,33 мс, ровно как обновляется экран. В среднем около шести отчётов мыши (до восьми при непрерывном движении) сливаются в одно событие с суммой их движения. То же самое мы видели в простаивающем приложении, которому ничто не мешает, а 13 сентября — внутри процесса игры (записи прогона нет).
Мы не первые, кто на это наткнулся. У библиотеки sokol (чистый C, без прослоек) есть issue №1344 «Sporadic input delay on MacOS Tahoe 26.0.1 (1000Hz mouse)»: на Tahoe с мышью на 1000 Гц ввод местами задерживается, а иногда за кадр приходит одно движение вместо нескольких. Причину там ищут в WindowServer, так что это смежная проблема. А в ветке форума Apple Developer Forums «Equivalent of coalescedTouchesForTouch in AppKit?» (и в «Changes in Mouse Event Reporting Frequency in macOS 26.2») разработчик пишет, что с macOS 26.2 движение мыши прореживается до частоты экрана, а интерфейса для исходных точек он не нашёл. Похоже на поведение системы, а не на особенность одной игры или одного движка.
Почему игра это чувствует, а рабочий стол нет
На рабочем столе пачки не видны: курсор перерисовывается с той же частотой, что и экран, поэтому пачка превращается в один шаг курсора. Игра устроена иначе. Она идёт со своей частотой кадров, и та почти никогда не совпадает с пачками.
Возьмём игру на 100 кадров в секунду, то есть кадр раз в 10 мс, и пачки раз в 8,33 мс. За 50 мс выходит 5 кадров и 6 пачек: четыре кадра получают по одной пачке движения, пятый — две. При ровной руке прицел прыгает вдвое дальше на каждом пятом кадре: двадцать рывков в секунду (это арифметика, а не замер). При 80 кадрах (12,5 мс) картина меняется, но не улучшается: кадры чередуются между одной и двумя пачками. Если частота кадров не делитель 120 (например, 100 или 80), лимит только переносит рывки с места на место; на 120, 60 или 40 кадр и пачка совпадают. Пока движение приходит порциями размером с кадр экрана, а игра идёт по своим часам, они бьются друг о друга.
Если бы каждый кадр получал все отчёты мыши из своего отрезка времени, движение было бы пропорционально длине кадра, и биения не было бы.
Что можно сделать без чужих приложений
- Выключить ускорение указателя (Системные настройки → Мышь). К пачкам оно не относится, но с ним одно и то же движение руки поворачивает вас на разный угол, и мышечная память не работает.
NSEvent.isMouseCoalescingEnabled = false, если вы пишете приложение сами. Этот флаг советуют в таких ветках годами. В драйвере игры у нас его отключение ничего не меняло (13 сентября), а у sokol от него отказались из подозрения, что он забивает очередь событий; отдельного замера на macOS 26.2 с ProMotion мы не делали.- Читать мышь через GameController — каждый отчёт и никакого запроса разрешения.
- Читать мышь с устройства через IOKit — каждый отчёт, но нужен «Мониторинг ввода».
Каждый отчёт без разрешения: GCMouse
GCMouse из GameController (macOS 11+) создан для игр, и оказалось, что он не идёт
по пути с частотой экрана. 25 сентября мы записали одни и те же 20 секунд тремя способами сразу:
системным перехватом событий (аппаратные метки времени), NSEvent и
GCMouse в одном окне. Logitech G309 через приёмник, 1000 Гц.
| Источник | События | В секунду | Медианный промежуток | P99 промежутка | Меньше 2 мс |
|---|---|---|---|---|---|
| Сама мышь (перехват событий) | 16 919 | 843 | 0,99 мс | 5,90 мс | 88,3 % |
| GCMouse | 16 921 | 844 | 0,99 мс | 5,96 мс | 87,5 % |
| NSEvent | 2 377 | 119 | 8,07 мс | 14,87 мс | 0,3 % |
У записывающего приложения не было разрешения «Мониторинг ввода» (IOHIDCheckAccess
вернул «unknown»), и запроса не появилось.
import GameController
let queue = DispatchQueue(label: "mouse", qos: .userInteractive)
NotificationCenter.default.addObserver(forName: .GCMouseDidConnect, object: nil, queue: .main) { note in
guard let mouse = note.object as? GCMouse else { return }
mouse.handlerQueue = queue
mouse.mouseInput?.mouseMovedHandler = { _, dx, dy in
// Один вызов на каждый отчёт устройства. Дельты линейны, без кривой ускорения;
// dy положительна вверх, в отличие от deltaY у NSEvent.
}
}
- Дельты линейны и, по нашим замерам, идут без кривой ускорения (напрямую с сырыми счётчиками HID мы их не сверяли). Для шутера это то, что нужно; для курсора применяйте свою кривую.
- Ось Y перевёрнута относительно
NSEventи HID-отчёта. lastEventTimestamp— секунды от эпохи Unix, а не от загрузки, поэтому без пересчёта он не сходится сmach_absolute_time.- Замер шёл с окном записи на переднем плане. Доставку в фоне и работу внутри других сред выполнения мы не проверяли.
Каждый отчёт с устройства: IOHIDManager
IOKit позволяет подписаться на HID-значения устройства. Обратный вызов срабатывает на каждый отчёт с относительным движением по 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-время отчёта
// X и Y одного отчёта имеют общую метку времени: копим, пока она не сменится.
}, nil)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
На что мы наступили:
- Нужно разрешение «Мониторинг ввода» (
IOHIDCheckAccess/IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)). Без него вызов молчит, без всякой ошибки. Разрешение привязано к подписи приложения: при ad-hoc подписи каждая пересборка молча его сбрасывает. - Некоторые мыши сообщают движение через два HID-интерфейса, и то же движение приходит дважды. Отсекайте по устройству.
- Ставьте обратный вызов в отдельный поток со своим циклом событий: главный поток занят отрисовкой и событиями окна, и отчёты встанут в очередь за ними.
- Читайте только когда нужно. Пока курсор виден, пусть работает обычный путь, иначе клики по интерфейсу поплывут. Мы переключаемся на прямое чтение, только когда игра спрятала курсор над своим окном.
Откуда это всё
Мы делаем Uncork, приложение для Mac, которое запускает Windows-игры из Steam на Apple Silicon. Началось с Counter-Strike 2: на 100+ кадрах прицел ощущался ватным, и никакая настройка игры не помогала. Замеры выше оттуда. Сегодня драйвер ввода Uncork читает мышь через IOHIDManager, пока игра держит курсор, и ведёт себя обычно в остальное время; результат с GCMouse — причина, по которой мы проверяем, можно ли убрать запрос «Мониторинг ввода». На нашем эталонном MacBook Pro с M3 Pro CS2 идёт со 100–120 кадрами на Dust II, метод описан на странице про CS2. Та же история подробнее — в статье на Хабре.
Если вы выпускали игру или эмулятор под macOS и упирались в ту
же стену, нам интересно, как вы её обошли — особенно помогает ли isMouseCoalescingEnabled
кому-нибудь на современных системах. Пишите на
hello@uncork.win.