Почему на Mac “плывёт” прицел: macOS отдаёт мышь раз в кадр экрана

в 8:39, , рубрики: appkit, cs2, HID, IOKit, MacOS, NSEvent, promotion, мышь

Я играю в Counter‑Strike 2 на MacBook Pro 14 с M3 Pro. Кадров хватает, 100 с лишним в секунду, а прицел всё равно ощущается ватным: мелкие доводки то недолетают, то перелетают. Сначала я грешил на ускорение указателя, потом на лимит кадров, потом на саму игру. Не помогало ничего, и в какой‑то момент я просто записал, как события мыши доходят до приложения.

Замер

Две ленты с одного и того же движения мыши, снятые одновременно:

  • системный тап (CGEventTap) с аппаратными метками времени, то есть то, что на самом деле шлёт мышь;

  • поток NSEvent в окне обычного Cocoa‑приложения, то есть то, что macOS отдаёт программе.

Мышь Logitech через USB‑приёмник с опросом 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 мс: сверху события от мыши, снизу события в приложении; ниже гистограммы интервалов за все 20 секунд

Одни и те же 100 мс: сверху события от мыши, снизу события в приложении; ниже гистограммы интервалов за все 20 секунд

За одни и те же 100 мс мышь прислала 81 событие, а приложение получило 13. Всё, что пришло за кадр экрана, система складывает в одно событие и отдаёт его раз в кадр.

Я тут не первый. На форуме разработчиков Apple есть тред Changes in Mouse Event Reporting Frequency in macOS 26.2, где прямо написано, что система прореживает события мыши до частоты экрана. У движка sokol (чистый C, никаких прослоек) про то же самое issue #1344. Так что дело в системе, а не в конкретной игре.

Почему на рабочем столе этого не видно, а в игре видно

Курсор рабочего стола перерисовывается с той же частотой, с какой приходят пачки, поэтому пачка просто становится одним шагом курсора. У игры свой темп кадров, и с темпом пачек он почти никогда не совпадает.

Простая арифметика для 100 fps: кадр длится 10 мс, пачка приходит раз в 8,33 мс. За 50 мс проходит 5 кадров и 6 пачек, значит четыре кадра получают по одной пачке, а пятый две. Если вести мышь ровно, на каждом пятом кадре прицел прыгает вдвое дальше, двадцать раз в секунду. На 80 fps кадры получают попеременно одну и две пачки. Это расчёт, а не замер, но ощущается ровно так. Лимит кадров переставляет рывки с места на место, а убрать их не может.

Что пробовал

Ускорение указателя стоит выключить в любом случае (Системные настройки → Мышь): с ним одно и то же движение руки поворачивает на разный угол. К пачкам оно отношения не имеет.

NSEvent.isMouseCoalescingEnabled = false советуют во всех старых тредах. В том же issue у sokol его проверили: на Tahoe с выключенным склеиванием события приходят с провалами по 50–150 мс, и они вернулись к доставке раз в кадр. Сам я этот флаг не мерил.

Сработало чтение мыши напрямую с устройства, мимо очереди событий.

Как читать каждый отчёт мыши

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 time отчёта
    // X и Y одного отчёта приходят с одной отметкой времени: копим, пока она не сменится.
}, nil)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))

Что выяснилось по дороге:

  • Нужно разрешение «Мониторинг ввода» (IOHIDCheckAccess / IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)). Без него коллбэк просто молчит, ошибки нет. Разрешение привязано к подписи приложения: с ad‑hoc подписью каждая пересборка тихо его сбрасывает. У меня из‑за этого одна сессия CS2 прошла без прямого чтения, и я не сразу понял почему.

  • Значения сырые, без системной кривой ускорения. Для шутера это как раз то, что нужно.

  • Некоторые мыши шлют движение через два HID‑интерфейса, и одно смещение приходит дважды. Фильтровать надо по устройству.

  • Коллбэк лучше повесить на отдельный поток со своим run loop: главный занят отрисовкой и событиями окна.

  • Читать напрямую стоит, только пока игра прячет курсор. Когда курсор виден, пусть работает обычный путь, иначе щелчки по интерфейсу разъедутся с курсором.

Зачем мне это

Я делаю Uncork, приложение для Windows‑игр из Steam на маках с Apple Silicon, и всё это началось с CS2. Теперь мышь там читается так, как описано выше, пока игра держит курсор.

Если вы упирались в то же самое в своей игре или эмуляторе под macOS, напишите, как обходили. Особенно интересно, помогает ли кому‑нибудь isMouseCoalescingEnabled на Tahoe.

Автор: aonetimeusername

Источник


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js