Игры на MacBook часто дёргаются даже тогда, когда счётчик показывает 100 кадров в секунду и больше. У меня так было с Counter‑Strike 2 на MacBook Pro 14 с M3 Pro: 110–120 fps, а на медленном повороте картинка всё равно идёт рывками.

Я записал, когда каждый кадр на самом деле появляется на экране. Дело оказалось не в нехватке мощности, а в том, как кадры ложатся на сетку экрана ProMotion. В цифрах это выглядит так:

Промежутки между кадрами на экране в CS2: как есть и с «Ровными кадрами»
Промежутки между кадрами на экране в CS2: как есть и с «Ровными кадрами»

Серые столбики — как игра идёт сама по себе, синие — после того, как я выровнял кадры.

Дальше о том, почему так выходит и как я к этому пришёл.

Это продолжение истории про мышь: там я разбирался, почему прицел «плывёт» из‑за того, что macOS отдаёт движение мыши раз в кадр экрана. Мышь с тех пор читается напрямую, а рывки остались.

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

Как я мерил

У каждого кадра в Metal есть момент, когда он на самом деле появился на экране: это presentedTime у drawable. Я записывал его для каждого кадра и смотрел на промежутки между соседними кадрами.

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

Условия: встроенный экран ProMotion 120 Гц, мак от батареи, CS2 на de_dust2 с ботами, fps_max 120, вертикальная синхронизация выключена, игра на весь экран. Ровным я считал промежуток, который отличается от среднего меньше чем на миллисекунду.

кадров в секунду

ровных промежутков

задержка до экрана: обычно / в худших 5%

как есть

118

18%

20,8 / 32 мс

«Ровные кадры»

80

87%

19,2 / 24,4 мс

При 118 кадрах в секунду промежутки на экране должны быть около 8,5 мс. На деле это 4,17, 8,33 и 12,5 мс вперемешку, что и видно на графике.

Откуда рывки при высоком fps

Экран ProMotion показывает кадры не в любой момент, а по сетке с шагом 4,17 мс. Между кадрами может пройти 8,33, 12,5 или 16,67 мс, но не 8,5 и не 10.

Поэтому ровно держатся только частоты, которые ложатся на эту сетку: 120, 80, 60, 48, 40.

Игра на 118 кадрах (или на 100 в тяжёлой сцене) на сетку не попадает. Каждый кадр съезжает на ближайшую ступень, соседние выходят то чаще, то реже. В итоге средние 118 fps на экране выглядят хуже, чем ровные 80.

Что я пробовал

Придержать готовый кадр

Самая очевидная идея: раз кадры приходят слишком рано, придержать готовый кадр до нужной ступени. Для этого у CAMetalLayer есть presentAfterMinimumDuration:.

На тестовой программе ровность и правда выросла до 89%, но частота упала до 60 вместо 80, а задержка выросла на 47 мс. Игра рисовала быстрее, чем кадры выпускались на экран, и они копились в очереди.

Показывать кадры по расписанию

Следующей мыслью было попросить систему показать кадр к конкретному времени через presentAtTime:. Это оказалось тупиком.

Как только приложение начинает выводить кадры по расписанию (presentAtTime: или presentAfterMinimumDuration:), экран ProMotion переходит на жёсткие 120 Гц.

Промежутки становятся ровно 8,33 и 16,67 мс, и вместо 80 ровных кадров получается их смесь: в меню MiSide вышло 27% ровных. По‑моему, это самое полезное, что стоит знать, если вы пишете свою игру под Mac.

Ограничить частоту в начале кадра

Так работает fps_max. Начало кадров стало ровным, а экран остался рваным: на тестовой программе 28% ровных промежутков.

Трасса CS2 объяснила почему. Поток, который вызывает Present, доходит до него примерно за 2 мс, основную работу делают другие потоки. А уже после Present D3DMetal (прослойка Apple, которая переводит DirectX в Metal) готовит кадр ещё 8–12 мс. Неровность появляется именно там, после Present.

Подстроиться под фазу экрана

Без вертикальной синхронизации ProMotion выводит кадр сразу, как только тот готов. Подстраиваться там не к чему.

Что в итоге заработало

Понадобились две вещи, обе внутри Present:

  1. Поток игры придерживается до начала следующего кадра по ровной сетке (для 80 кадров это 12,5 мс).

  2. Вывод кадра в Metal придерживается до одной и той же точки после задуманного начала кадра. Точку я беру по 98-му процентилю времени, за которое кадр обычно готовится.

Второй пункт заработал не сразу. Сначала я отсчитывал точку от момента, когда поток просыпался, и опоздание одного кадра переезжало на следующий: ровных выходило 51%. Когда стал считать от задуманного начала по сетке, стало 87% против 18% без режима.

В CS2 задержка при этом не выросла. Обычно она почти та же (19,2 мс против 20,8), а в худших 5% кадров даже меньше: 24,4 против 32 мс. Когда кадры не толкаются в очереди, хвост короче.

Когда это не нужно

Первая версия автоматического режима решала по времени кадра, и на CS2 это не сработало: время кадра у неё ровное, а экран рваный. За две минуты режим 41 раз переключился между 80 и 120.

Сейчас он смотрит на сам экран. Если ровных промежутков меньше 45%, пробует ступень ниже и через 2,5 секунды проверяет: стало ровнее, значит держит, нет, значит отпускает и пробует позже.

Меню MiSide на 115 кадрах и так ровное (75–86%), а ограничение до 80 делает его хуже (52%). Такие игры режим не трогает.

Цена

Кадров становится меньше: 118 превращаются в 80. В соревновательном шутере это плохой обмен, там решает каждый кадр. В одиночных играх, гонках, экшенах, открытых мирах, рывки заметны сразу, а разницу в отклике между 118 и 80 кадрами почти не чувствуешь.

Задержка тоже может немного вырасти. В CS2 она не выросла, а на тестовой программе прибавилось около 2,5 мс.

Работает это только на весь экран. Если окно не развёрнуто или поверх него лежит другое, macOS сводит кадры с остальным экраном по сетке 8,33 мс, и ровные 80 снова рассыпаются. Режим это замечает и ничего не держит.

И остаются сбои, на которые я повлиять не могу: примерно раз в полсекунды кадр идёт до экрана 16–20 мс вместо обычных 6,5.

Мерил я на одном маке и в двух играх, так что на других машинах цифры могут отличаться.

Если вы пишете игру под Mac

  • Мерьте момент появления кадра на экране, а не время кадра.

  • Не выводите кадры по расписанию: экран уйдёт в жёсткие 120 Гц.

  • Ограничивайте частоту делителем частоты экрана (80, 60, 40) и в начале кадра, а не придерживайте готовый.

  • Ровный вывод бывает только на весь экран.


Всё описанное я встроил в своё приложение Uncork, это переключатель «Ровные кадры» в настройках бутылки, с тем самым автоматическим режимом.

Следующий замер хочу сделать в одиночной игре. Если знаете такую, где на Mac рывки заметнее всего, напишите в комментариях, начну с неё.