Uncork

Почему прицел плавает на 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 сентября — внутри процесса игры (записи прогона нет).

Одна и та же мышь за одни и те же 100 мс: 81 событие от мыши, 13 в приложении
22 сентября: за 20 секунд движения системный перехват насчитал 13 452 события мыши, приложение получило 2 269.

Мы не первые, кто на это наткнулся. У библиотеки 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 кадр и пачка совпадают. Пока движение приходит порциями размером с кадр экрана, а игра идёт по своим часам, они бьются друг о друга.

Если бы каждый кадр получал все отчёты мыши из своего отрезка времени, движение было бы пропорционально длине кадра, и биения не было бы.

Что можно сделать без чужих приложений

Каждый отчёт без разрешения: GCMouse

GCMouse из GameController (macOS 11+) создан для игр, и оказалось, что он не идёт по пути с частотой экрана. 25 сентября мы записали одни и те же 20 секунд тремя способами сразу: системным перехватом событий (аппаратные метки времени), NSEvent и GCMouse в одном окне. Logitech G309 через приёмник, 1000 Гц.

ИсточникСобытияВ секундуМедианный промежутокP99 промежуткаМеньше 2 мс
Сама мышь (перехват событий)16 9198430,99 мс5,90 мс88,3 %
GCMouse16 9218440,99 мс5,96 мс87,5 %
NSEvent2 3771198,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.
    }
}

Каждый отчёт с устройства: 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))

На что мы наступили:

Откуда это всё

Мы делаем 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.

← На главную