Как заставить игру работать с частотой 60fps +56


Представьте себе задачу: у вас есть игра, и вам нужно, чтобы она работала с частотой 60 fps на 60-герцовом мониторе. Ваш компьютер достаточно быстр для того, чтобы рендеринг и обновление занимали несущественное количество времени, поэтому вы включаете vsync и пишете такой игровой цикл:

while(running) {
    update();
    render();
    display();
}

Очень просто! Теперь игра работает с 60fps и всё идёт как по маслу. Готово. Спасибо, что прочитали этот пост.


Ну ладно, очевидно, что всё не так хорошо. Что если у кого-то слабый компьютер, который не может рендерить игру с достаточной для обеспечения 60fps скоростью? Что если кто-то купил один из тех крутых новых 144-герцовых мониторов? Что если он отключил в настройках драйвера vsync?

Вы можете подумать: мне нужно где-то измерять время и обеспечить обновление с правильной частотой. Это сделать довольно просто — достаточно накапливать время в каждом цикле и выполнять обновление каждый раз, когда он превосходит порог в 1/60 секунды.

while(running) {
    deltaTime = CurrentTime()-OldTime;
    oldTime = CurrentTime();
    accumulator += deltaTime;
    while(accumulator > 1.0/60.0){
        update();
        accumulator -= 1.0/60.0;
    }
    render();
    display();
}

Готово, проще некуда. На самом деле есть куча игр, в которых код по сути выглядит именно так. Но это неправильно. Это подходит для регулировки таймингов, но приводит к проблемам с дёрганьем (stuttering) и прочим рассогласованиям. Очень часто встречается такая проблема: кадры отображаются не ровно 1/60 секунды; даже когда vsync включен, всегда присутствуют небольшой шум во времени их отображения (и в точности таймера ОС). Поэтому будут возникать ситуации, когда вы рендерите кадр, а игра считает, что время повторного обновления ещё не настало (потому что аккумулятор на крошечную долю запаздывает), поэтому она просто снова повторяет тот же кадр, но теперь игра запаздывает на кадр, поэтому выполняет двойное обновление. Вот и дёрганье!

Погуглив, можно найти несколько готовых решений для устранения этого дёрганья. Например, игра может использовать переменный, а не постоянный шаг времени, и просто полностью отказаться от аккумуляторов в коде таймингов. Или можно реализовать постоянный шаг времени с интерполирующим рендерером, описанный в довольно известной статье "Fix Your Timestep" Гленна Филдера. Или можно переделать код таймера так, чтобы он был немного более гибким, как описано в посте "Frame Timing Issues" Slick Entertainment (к сожалению, этого блога уже нет).



Нечёткие тайминги


Метод Slick Entertainment с «нечёткими таймингами» в моём движке было реализовать проще всего, потому что он не требовал изменений в логике игры и рендеринге. Поэтому в The End is Nigh я использовал его. Достаточно было просто вставить его в движок. По сути, он просто позволяет игре обновляться «немного раньше», чтобы избежать проблем с рассогласованием таймингов. Если в игре включен vsync, то он просто позволяет использовать в качестве основного таймера игры vsync, и обеспечивает плавную картинку.

Вот как код обновления выглядит сейчас (игра «может работать» при 62 fps, но всё равно обрабатывает каждый шаг времени так, как будто работает при 60fps. Не совсем понимаю, зачем ограничивать его так, чтобы значения аккумулятора не опускались ниже 0, но без этого код не работает). Можно интерпретировать это так: «игра обновляется с фиксированным шагом, если рендерится в интервале от 60fps до 62fps»:

while(accumulator > 1.0/62.0){
    update();
    accumulator -= 1.0/60.0;
    if(accumulator < 0) accumulator = 0;
}

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

Спидраннеры. Спидраннеры заметят. Вскоре после выхода игры они заметили, что некоторые люди в списках рекордов спидрана имели более плохое время прохождения, но по подсчёту оказавшееся более хорошим, чем у других. И непосредственной причиной этого была нечёткость таймингов и отключение vsync в игре (или 144-герцовые мониторы). Поэтому стало очевидно, что нужно выключать эту нечёткость при отключении vsync.

Ой, но ведь мы никак не может проверить, отключен ли vsync. В ОС для этого нет вызовов, и хотя мы можем запрашивать из приложения включение или отключение vsync, на самом деле это полностью зависит от ОС и графического драйвера. Единственное, что можно сделать — отрендерить кучу кадров, попробовать измерить время выполнения этой задачи, а затем сравнить, занимают ли они примерно одинаковое время. Именно так я и сделал для The End is Nigh. Если в игре не включен vsync с частотой 60 Гц, то она откатывается к исходному таймеру кадров со «строгими 60 fps». Кроме того, я добавил в файл конфигурации параметр, принуждающий игру не использовать нечёткость (с основном для спидраннеров, которым нужно точное время) и добавил для них точный обработчик внутриигрового таймера, позволяющий использовать autosplitter (это скрипт, работающий с таймером атомного времени).

Некоторые пользователи по-прежнему жаловались на возникающие иногда дёрганья отдельных кадров, но они казались настолько редкими, что их можно было объяснить событиями ОС или другими внешними причинами. Ничего особо страшного. Верно?

Просматривая недавно свой код таймера, я заметил нечто странное. Аккумулятор смещался, каждый кадр занимал чуть больше времени, чем 1/60 секунды, поэтому периодически игра думала, что запаздывает на кадр, и выполняла двойное обновление. Оказалось, что мой монитор работает с частотой 59,94 Гц, а не 60 Гц. Это означало, что каждые 1000 кадров ему приходится выполнять двойное обновление, чтобы «догнать». Однако это очень просто исправить — достаточно изменить интервал допустимых частот кадров (не с 60 до 62, а с 59 до 61).

while(accumulator > 1.0/61.0){
    update();
    accumulator -= 1.0/59.0;
    if(accumulator < 0) accumulator = 0;
}

Описанная выше проблема с отключенным vsync и мониторами высокой частоты по-прежнему сохраняется, и к нему применимо то же решение (откат к строгому таймеру, если монитор не синхронизирован vsync на 60).

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

Симулятор монитора


Пытаясь придумать надёжное решение «проблемы 59,94-герцового монитора», я осознал, что не могу просто выполнять проверки методом проб и ошибок, надеясь найти надёжное решение. Мне нужен был удобный способ тестирования разных попыток написания качественного таймера и простой способ проверки, вызывает ли он дёрганье или сдвиг времени в разных конфигурациях мониторов.

На сцене появляется Monitor Simulator. Это написанный мной «грязный и быстрый» код, симулирующий «работу монитора», и по сути выводящий мне кучу чисел, дающих представление о стабильности каждого тестируемого таймера.

Например, для простейшего таймера из начала статьи выводятся такие значения:

20211012021011202111020211102012012102012[...]
TOTAL UPDATES: 10001
TOTAL VSYNCS: 10002
TOTAL DOUBLE UPDATES: 2535
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.683
SYSTEM TIME: 166.7


Сначала код выводит для каждого эмулируемого vsync число количества «обновлений» игрового цикла после предыдущего vsync. Любые значения, отличающиеся от сплошных 1, приводят к дёрганой картинке. В конце код выводит накопившуюся статистику.

При использовании «нечёткого таймера» (с интервалом 60–62fps) на 59,94-герцовом мониторе код выводит следующее:

111111111111111111111111111111111111111111111[...]
TOTAL UPDATES: 10000
TOTAL VSYNCS: 9991
TOTAL DOUBLE UPDATES: 10
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.667
SYSTEM TIME: 166.683


Дёрганье кадра возникает очень редко, поэтому его может быть сложно заметить при таком количестве 1. Но выводимая статистика чётко показывает, что игра выполнила здесь несколько двойных обновлений, что приводит к дёрганью. В исправленной версии (с интервалом 59–61 fps) присутствует 0 пропущенных или двойных обновлений.

Также можно отключить vsync. Остальные данные статистики становятся неважными, но это чётко показывает мне величину «сдвига времени» (сдвига системного времени относительно того, где должно находиться игровое время).

GAME TIME: 166.667
SYSTEM TIME: 169.102


Именно поэтому при отключенном vsync нужно переключаться на строгий таймер, иначе эти расхождения со временем накапливаются.

Если я присвою времени рендеринга значение .02 (то есть для рендеринга нужно «больше, чем кадр»), то снова получу дёрганье. В идеале паттерн игры должен выглядеть как 202020202020, но он немного неравномерен.

В этой ситуации этот таймер ведёт себя немного лучше предыдущего, но становится всё запутаннее и всё сложнее разобраться, как и почему он работает. Но я ведь просто могу засунуть тесты в этот симулятор и проверить, как они себя ведут, а разбираться в причинах можно и позже. Пробы и ошибки, детка!

while(accumulator >= 1.0/61.0){
    simulate_update();
    accumulator -= 1.0/60.0;
    if(accumulator < 1.0/59.0–1.0/60.0) accumulator = 0;
}

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

Я доволен своим решением не на 100% (в нём всё равно требуется хак с «распознаванием vsync» и могут возникать единичные дёрганья при рассинхронизации), но я считаю, что он почти так же хорош, как и попытка реализации игрового цикла с фиксированным шагом. Частично эта проблема возникает потому, что очень сложно определить параметры того, что считается здесь «приемлемым». Главная сложность заключается в компромиссе между сдвигом времени и двойными/пропущенными кадрами. Если запустить 60-герцовую игру на 50-герцовом PAL-мониторе… то каким будет верное решение? Хотите ли вы дикого дёрганья, или заметно более медленной работы игры? Оба варианта кажутся плохими.

Отделённый рендеринг


В предыдущих методах я описывал то, что называю «рендерингом с фиксированным шагом» (lockstep rendering). Игра обновляет своё состояние, затем выполняет рендеринг, а при рендеринге она всегда отображает наиболее свежее состояние игры. Рендеринг и обновление соединены вместе.

Но можно их разделить. Именно это делает метод, описанный в посте "Fix Your Timestep". Я не буду повторяться, вам определённо стоит перечитать прочитать этот пост. Это (насколько я понимаю) «отраслевой стандарт», используемый в AAA-играх и таких движках, как Unity и Unreal (однако в напряжённых активных 2D-играх обычно предпочитают использовать фиксированный шаг (lockstep), потому что иногда просто необходима точность, которую даёт этот метод).

Но если описать вкратце пост Гленна, то в нём просто описывается метод обновления с фиксированной частотой кадров, но при рендеринге выполняется интерполяция между «текущим» и «предыдущим» состоянием игры, а текущее значение аккумулятора используется как величина интерполяции. При таком способе можно выполнять рендеринг с любой частотой кадров и обновлять игру с любой частотой, а картинка всегда будет плавной. Никакого дёрганья, работает универсально.

while(running){
    computeDeltaTimeSomehow();
    accumulator += deltaTime;
    while(accumulator >= 1.0/60.0){
        previous_state = current_state;
        current_state = update();
        accumulator -= 1.0/60.0;
    }
    render_interpolated_somehow(previous_state, current_state, accumulator/(1.0/60.0));
    display();
}

Вот так, элементарно. Проблема решена.

Теперь нужно просто сделать так, чтобы игра могла рендерить интерполированные состояния… но постойте, на самом деле это совсем непросто. В посте Гленна просто допускается, что это можно сделать. Достаточно легко кэшировать предыдущее положение игрового объекта и интерполировать его перемещения, но в состояние игры входит гораздо больше всего. Нужно учитывать в нём состояния анимации, создание и уничтожение объектов, и ещё кучу всякого.

Плюс в логике игры нужно учитывать, телепортируется ли объект или его нужно плавно двигать, чтобы интерполятор не делал ложных предположений о пути, проделанным игровым объектом в его текущее положение. Настоящий хаос может возникнуть с поворотами, особенно если за один кадр поворот объекта может смениться больше, чем на 180 градусов. А как правильно обрабатывать создаваемые и уничтожаемые объекты?

В данный момент я как раз работаю над этой задачей в своём движке. По сути, я просто интерполирую перемещения, а всё прочее оставляю, как есть. Вы не заметите дёрганья, если объект не движется плавно, поэтому пропуск кадров анимации и рассинхронизация создания/уничтожения объекта вплоть до одного кадра не станут проблемой, если всё остальное выполняется плавно.

Однако странно то, что по сути этот метод рендерит игру в состоянии, запаздывающем на 1 состояние игры от того, где сейчас находится симуляция. Это незаметно, но может соединяться с другими источниками задержек, например, задержкой ввода и частотой обновления монитора, поэтому те, кому нужен максимально отзывчивый игровой процесс (я говорю про вас, спидраннеры), скорее всего предпочтут, чтобы в игре использовался lockstep.

В своём движке я просто даю возможность выбора. Если у вас 60-герцовый монитор и быстрый компьютер, то лучше всего использовать lockstep со включенным vsync. Если у монитора нестандартная частота обновления, или ваш слабый компьютер не может постоянно рендерить 60 кадров в секунду, то включайте интерполяцию кадров. Я хочу назвать эту опцию «unlock framerate» («разблокировать частоту кадров»), но люди могут подумать, что это просто означает «включите эту опцию, если у вас хороший компьютер». Однако эту проблему можно решить позже.

Вообще-то есть метод, позволяющий обойти эту проблему.

Обновления с переменным шагом времени


Меня многие спрашивали, почему бы просто не обновлять игру с переменным шагом времени, а программисты-теоретики часто говорят: «если игра написана ПРАВИЛЬНО, то можно просто обновлять её с произвольным шагом времени».

while(running) {
    deltaTime = CurrentTime()-OldTime;
    oldTime = CurrentTime();
    update(deltaTime);
    render();
    display();
}

Никаких странностей с таймингами. Никакого странного интерполяционного рендеринга. Всё просто, всё работает.

Вот так, элементарно. Проблема решена. И теперь навсегда! Лучшего результата добиться невозможно!

Теперь достаточно просто сделать так, чтобы игровая логика работала с произвольным шагом времени. Это ведь просто, достаточно заменить весь такой код:

position += speed;

на такой:

position += speed * deltaTime;

и заменить вот такой код:

speed += acceleration;
position += speed;

на такой:

speed += acceleration * deltaTime;
position += speed * deltaTime;

и заменить вот такой код:

speed += acceleration;
speed *= friction;
position += speed;

на такой:

Vec3D p0 = position;
Vec3D v0 = velocity;
Vec3D a = acceleration*(1.0/60.0);
double f = friction;
double n = dt*60;
double fN = pow(friction, n);
position = p0 + ((f*(a*(f*fN-f*(n+1)+n)+(f-1)*v0*(fN-1)))/((f-1)*(f-1)))*(1.0/60.0);
velocity = v0*fN+a*(f*(fN-1)/(f-1));

… так, постойте-ка

Откуда это всё взялось?

Последняя часть в буквальном смысле скопирована из вспомогательного кода моего движка, выполняющего «действительно корректное, не зависящее от частоты кадров движение с ограничивающим скорость трением». В нём есть немного мусора (эти умножения и деления на 60). Но это «правильная» версия кода с переменным шагом времени для предыдущего фрагмента. Я вычислял её больше часа при помощи Wolfram Alpha.

Теперь меня могут спросить, почему бы не сделать вот так:

speed += acceleration * deltaTime;
speed *= pow(friction, deltaTime);
position += speed * deltaTime;

И хотя это как будто сработает, на самом деле так делать неправильно. Можете проверить сами. Выполните два обновления со значением deltaTime = 1, а затем выполните одно обновление с deltaTime = 2, и результаты будут отличаться. Обычно мы стремимся, чтобы игра работала согласованно, поэтому такие расхождения не приветствуются. Вероятно, это достаточно хорошее решение, если точно знать, что deltaTime всегда примерно равно одному значению, но тогда нужно написать код, обеспечивающий выполнение обновлений с какой-то постоянной частотой и… да. Верно, теперь мы пытаемся сделать всё «ПРАВИЛЬНО».

Если такой крошечный фрагмент кода разворачивается в чудовищные математические вычисления, то представьте более сложные паттерны движения, в которых участвует множество взаимодействующих объектов, и тому подобное. Теперь можно чётко увидеть, что «правильное» решение нереализуемо. Максимум, чего мы можем добиться — это «грубое приближение». Давайте пока об этом забудем, и допустим, что у нас и в самом деле есть «действительно корретная» версия функций движения. Здорово, правда?

Вообще-то нет. Вот реальный пример проблемы, которая у меня возникла с этим в Bombernauts. Игрок может подпрыгивать примерно на 1 тайл, а игра разворачивается в сетке из блоков в 1 тайл. Чтобы приземлиться на блок, ноги персонажа должны подняться над верхней поверхностью блока.


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


Очевидно, что эта проблема решаема. Но она иллюстрирует виды проблем, с которыми мы сталкиваемся при попытке правильной реализации работы игрового цикла с переменным шагом времени. Мы теряем согласованность и детерминированность, поэтому придётся избавиться от функций реплея игры по записи ввода игрока, детерминированного мультиплеера и того подобного. Для основанной на рефлексах быстрой 2D-игры согласованность чрезвычайно важна (и снова привет спидраннерам).

Если вы попытаетесь отрегулировать шаги времени так, чтобы они не были ни слишком большими, ни слишком маленькими, то лишитесь основного преимущества, получаемого от переменного шага времени, и спокойно можете использовать два других описанных здесь метода. Овчинка не будет стоить выделки. Слишком много лишних усилий будет вложено в игровую логику (реализация правильной математики движения), и потребуется слишком много жертв в области детерминированности и согласованности. Я бы использовал этот метод только для музыкальной ритм-игры (в которой уравнения движения просты и требуется максимальная отзывчивость и плавность). Во всех других случаях я выберу фиксированное обновление.



Заключение


Теперь вы знаете, как заставить игру работать с постоянной частотой 60fps. Это тривиально просто, и больше ни у кого с этим не должно возникать проблем. Больше нет никаких других проблем, усложняющих эту задачу.




К сожалению, не доступен сервер mySQL