Почему игры на MacBook дёргаются даже при 100+ fps — и как я сделал кадры ровными

Игры на MacBook часто дёргаются даже тогда, когда счётчик показывает 100 кадров в секунду и больше. У меня так было с Counter-Strike 2 на MacBook Pro 14 с M3 Pro: 110–120 fps, а на медленном повороте картинка всё равно идёт рывками.
Я записал, когда каждый кадр на самом деле появляется на экране. Дело оказалось не в нехватке мощности, а в том, как кадры ложатся на сетку экрана ProMotion. В цифрах это выглядит так:

Серые столбики — как игра идёт сама по себе, синие — после того, как я выровнял кадры.
Дальше о том, почему так выходит и как я к этому пришёл.
Это продолжение истории про мышь: там я разбирался, почему прицел «плывёт» из-за того, что 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:
Поток игры придерживается до начала следующего кадра по ровной сетке (для 80 кадров это 12,5 мс).
Вывод кадра в 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 рывки заметнее всего, напишите в комментариях, начну с неё.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.