Дoжили. Недавно была обнаружена проблема синхронизации игрового процесса с реальным временем не где-нибудь, а в игре "Quake Champions". Название игры "Quake" раньше было синонимом чего-то крутого, высокотехнологичного и идеального. И в голову не могло придти, что через какую-то пару десятков лет и камня на камне не останется от былого превосходства, а в новой игре с именем "Quake" появятся грубые ошибки, приводящие к тому, что один из игроков может получить преимущество только потому, что у него лучше "железо". Дело в том, что скорость стрельбы в новом шутере зависит от fps, то есть, количество пуль, выпущенных игроками с разным значением fps за один и тот же промежуток времени будет разным, а значит один из них может получить преимущество.


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


Вступление


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


При решении данной проблемы возникает задача (и не очень тривиальная, как может показаться на первый взгляд), которую обычно называют «синхронизация по таймеру», «привязка к таймеру», «привязка к реальному времени». Суть этой задачи – сделать так, чтобы анимация и другие события в программе были привязаны к реальному времени и не зависели от производительности компьютера.


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


Отличие "природного" времени от времени "компьютерного".


Даже физики еще не пришли к единому пониманию того, что же такое время. Но для простых людей, в наблюдаемой реальности, где пространство-время не слишком искажено, кажется, что время течет непрерывно, и события происходят параллельно. Кажется, что объекты на самом деле находятся там, где мы их видим, и наш повседневный опыт постоянно это подтверждает. Время в программах выглядит совсем иначе, чем в наблюдаемой реальности. Забывая об этом, сложно правильно моделировать поведение объектов во времени. Давайте разберемся в свойствах «компьютерного» времени.


Дискретность (Квантование)


Эффекты течения времени создаются за счет анимации – создания последовательности статичных картинок (кадров), которые при быстрой смене создают иллюзию движения. Ввиду инертности зрения и восприятия, мозг вынужден достраивать недостающие элементы движения, поэтому при воспроизведении последовательности малоотличающихся кадров нам кажется, что движение происходит плавно. Разбиение временного процесса на кадры придает компьютерному времени свойство дискретности – то есть, объекты на пути своего движения могут занимать только определенное конечное число позиций, в случае же "природного" времени, кажется, что объекты на своем пути занимают неисчислимое количество позиций.


Неоднородность


В один момент «природного» времени ядро процессора выполняет только одну операцию. Это и придает «компьютерному» времени свойство, отличающее его от «природного» времени – это свойство неоднородности его течения. То есть, для всех наших игровых объектов время течет не одновременно.


Допустим, у нас есть два объекта, состояние каждого объекта зависит от состояния другого. Расчет состояния для объектов будет осуществляться последовательно – это значит, что первый объект будет вычислять свое состояние, исходя из предыдущего состояния другого объекта (неактуального), а второй объект будет вычислять свое состояние на основании состояния первого объекта (актуального, но не правильного, т.к. оно было вычислено по неактуальному состоянию второго объекта). Образуется замкнутый круг ошибок из-за того, что объекты обрабатываются последовательно, из-за того, что течение «компьютерного» времени не однородно.


Высокопроизводительный таймер


Для измерения временных отрезков в программах целесообразно использовать функции, дающие сравнительно высокую точность. В ОС Windows используется QueryPerfomanceCounter, в Linux gettimeofday. Точность может отличаться на различных процессорах, но они почти всегда дают точность лучше, чем 1 миллисекунда.


Типичный main loop


float dt = 0.0f;
while (is_run) {
    if (active == false) WaitMessage();
    timer.start();
    doUpdate(dt);
    doRender();
    dt = timer.elapsed();
}

Виды синхронизации


Я различаю два способа синхронизации времени, у каждого есть свои преимущества и недостатки.


Интегрирование


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


void onUpdate(float dt) {
    value += dt;
}

Это пример простейшего интегрирования.


Существует множество численных алгоритмов интегрирования, самые известные — метод Эйлера и методы Рунге-Кутты. Метод Эйлера является самым простым методом решения дифференциальных уравнений, но для решения сложных уравнений не подходит, так как дает неудовлетворительную точность. Метод Эйлера — это самый быстрый метод, и он хорошо подходит для использования при разработке игр. Методы Рунге-Кутты применяются для решения сложных систем дифференциальных уравнений, и используются там, где нужна высокая точность.


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


vec3 pos;
vec3 velocity;
vec3 force;

void onUpdate(float dt) {
    pos += velocity * dt + (force * dt * dt) / 2;
    velocity += force * dt;
}

Здесь мы видим известную школьную формулу:


S = v0*t + (a * t^2) / 2

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


Теперь поговорим о недостатках этого метода. Дело в том, что формула для интегрирования не всегда такая простая, как это могло бы казаться. Могу привести в пример игру S.T.A.L.K.E.R, в которую я так и не смог нормально поиграть на своем слабеньком компьютере – но не из-за того, что fps был совсем уж неприемлемым, а как раз по причине того, что разработчики использовали для сглаживания вращения камеры нечто вот такое:


vec3 camera_angles;
void onUpdate(float dt) {
    vec3 new_camera_angles = input.getMouseDelta();
    float k = 0.5f; // k = 0.0f..1.0f
    camera_angles = new_camera_angles * k + camera_angles * (1.0f - k);
}

Из-за этого камера была слишком инертной при низких значениях fps. Казалось бы, простое и очевидное сглаживание, но неправильно реализованное, оно создает дискомфорт для игрока при низких значениях fps, не позволяя наслаждаться всеми прелестями зоны отчуждения. Как результат — в S.T.A.L.K.E.R я так и не играл.


Fixed time step


Этот метод основан на том, что мы производим обновление состояния объектов заданное постоянное количество раз в секунду, тем самым фиксируем шаг по времени. Неоспоримым преимуществом данного метода служит то, что нам больше не нужно интегрировать — формулы становятся простыми и предсказуемыми. Мы просто делаем так, чтобы функция onUpdate() вызывалась, скажем, 60 раз в секунду, и забываем про постоянную необходимость интегрировать все процессы изменения состояния. Несомненно, этот метод заметно упрощает жизнь, особенно когда игра содержит сетевые взаимодействия.


Я бы посоветовал использовать этот метод тем, кто не слишком желает вникать в проблемы правильного контроля времени и интегрирования, но все же этот метод не решит полностью проблемы неоднородности времени. Для сетевых игр — это наверное единственный вариант, когда все процессы на разных компьютерах будут происходить более-менее синхронно.


Естественно, метод тоже содержит подводные камни:


  • Обновление логики больше не связано с кадрами, поэтому движения могут выглядеть не такими плавными, как при интегрировании. При этом не имеет смысла показывать игроку больше кадров в секунду, чем частота обновления логики. Поэтому частоту обновления логики лучше сделать равной частоте кадровой развертки – скажем, 60, 85, или 120. Если игра слишком динамичная, лучше сделать 120, многие современные игровые мониторы умеют показывать столько кадров.
  • Существует проблема, которую я называю «временной коллапс», ну или можно называть это «черной дырой времени». Проблема возникает, когда время выполнения функции onUpdate() довольно велико (допустим, там рассчитывается вся физика, и было добавлено огромное количество объектов). При этом за игровой цикл onUpdate() начинает вызываться все большее количество раз, и программа просто зависает. Мы тратим много времени на onUpdate(), а значит нам нужно компенсировать прошедшее время уже двумя onUpdate(), два onUpdate() – это уже четыре onUpdate() в следующем цикле – и так далее. Поэтому необходимо контролировать время просчета логики, и если оно слишком велико, нужно его ограничивать. Естественно, после этого уже никакой синхронности ожидать не приходится, но это спасет от зависания. При этом пользователю можно сообщить о том, что его компьютер не справляется с расчетами, и предложить приобрести более быстрый компьютер :)

Периодические события


Перейдем к более конкретным примерам. Периодическим событием я называю событие, которое происходит через фиксированный временной промежуток. Примером такого события является реализация вызова onUpdate() с заданной частотой при реализации fixed time step.


void onUpdate() { }

float freq = 10.0f;
float time_to_event = 0.0f;

void doUpdate(float dt) {

    float ifreq = 1 / freq;
    time_to_event -= dt;

    while (time_to_event <= 0.0f) {
        event();
        time_to_event += ifreq;
    }
}

В данном примере событие onUpdate() будет вызываться с частотой freq раз в секунду. Мы видим, что если ifreq будет меньше dt (то есть заданная частота вызова будет больше fps — частоты вызова doUpdate()), то onUpdate() вызовется несколько раз в пределах одного doUpdate(). Вроде бы все правильно, но что если представить, что onUpdate() создает объект, который тоже имеет переменное во времени состояние?


Давайте представим, себе, что у нас есть событие выстрела из автомата, которое должно обрабатываться внутри onUpdate, как и всякая игровая логика. Если в пределах одного onUpdate() произойдет два выстрела, пули просто создадутся в одной точке и будут лететь параллельно рядом, хотя на самом деле их разделяет временной промежуток ifreq, за который одна пуля улетела дальше другой.


Давайте посмотрим, как этого избежать:


class Bullet {
    void update(float dt) { }
};

float fire_rate = 10.0f;
float time_to_shoot = 0.0f;
vector <Bullet *> bullets;

void onUpdate(float dt) {
    for (int i=0; i<bullets.size(); i++) {
        bullets[i]->update(dt);
    }
    float ifire = 1 / freq;
    time_to_shoot -= dt;
    while (time_to_shoot <= 0.0f) {
        Bullet *bullet = new Bullet();
        bullet->update(-time_to_shoot);
        bullets.push_back(bullet);
        time_to_shoot += ifreq;
    }
}

В данном примере мы компенсируем пуле время, которое прошло с момента ее запуска до следующего вызова onUpdate(), где Bullet::update() будет вызван уже в штатном порядке.


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


class Bullet {
    void update(float dt) { … }
    void setTransform(const Transform &tf) { … }
};

float fire_rate = 10.0f;
float time_to_shoot = 0.0f;
vector <Bullet *> bullets;
Transform old_tf;
bool first_update = true;

void onUpdate(float dt) {
    for (int i=0; i<bullets.size(); i++) {
        bullets[i]->update(dt);
    }

    Transform tf = getBarrelTransform();
    if (first_update) {
        old_tf = tf;
        first_update = false;
    }

    float ifire = 1 / freq;
    float k = time_to_shoot / dt;
    float delta_k = ifire / dt;
    time_to_shoot -= dt;

    while (time_to_shoot <= 0.0f) {
        float k = 1.0f - (-time_to_shoot / dt);
        Bullet *bullet = new Bullet();
        bullet->setTransform(lerp(old_tf, tf, k));
        bullet->update(-time_to_shoot);
        bullets.push_back(bullet);
        time_to_shoot += ifreq;
    }
    old_tf = tf;
}

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


На самом деле, если глубже погружаться в эту тему, станет ясно, что чем точнее мы хотим обрабатывать временные события, тем сложнее будет программа. Казалось бы, простой и очевидный код периодического события сильно усложнился, когда мы стали учитывать свойства «программного» времени. Поэтому в некоторых случаях нужно мириться с тем, что не все так уж правильно, как было бы в идеальном случае. Но не учитывать вовсе свойства «компьютерного» времени тоже нельзя, особенно в очень динамичных сетевых играх, где хотелось бы получить максимальный отклик от управления, и добиться максимальной синхронности работы клиентов и сервера.

Комментарии (17)


  1. DrZlodberg
    29.10.2017 15:40

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


    1. FadeToBlack Автор
      29.10.2017 16:59

      Прыжки — это тоже боль, но это проблемы другого порядка сложности. В коде Quake Champions ошибка куда тривиальнее и очевиднее.


      1. domix32
        30.10.2017 10:57

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


        1. FadeToBlack Автор
          30.10.2017 11:44

          Да, суть опыта — это не повторять прежних ошибок. Я думаю, что нужно было изучить исходники Q3 и по возможности сделать лучше.


  1. ser-mk
    29.10.2017 16:37

    Теперь поговорим о недостатках этого метода. Дело в том, что формула для интегрирования не всегда такая простая, как это могло бы казаться. Могу привести в пример игру S.T.A.L.K.E.R,

    К слову сказать, в приведенном коде вообще интегрирования нет, так что это сложно назвать недостатком.


    1. FadeToBlack Автор
      29.10.2017 16:56

      В том то и дело, что его там нет, а оно должно быть. Там должен логарифм фигурировать, но это не точно. Буду признателен, если кто-нибудь приведет правильную формулу.


      1. mayorovp
        29.10.2017 20:18

        Правильная формула — вот такая: camera_angles = new_camera_angles. Зачем там вообще сглаживание?


        1. Defaultnickname
          30.10.2017 07:42

          Для эффекта контузии и иллюзии инерции персонажа.


          1. mayorovp
            30.10.2017 08:38

            Учет инерции дает формулу равноускоренного движения. Только эту формулу придется применять два раза в обе стороны (первый раз — чтобы найти правильные параметры движения исходя из желаемой конечной точки). Наверное, проще просто ограничить скорость перемещения камеры.


            1. FadeToBlack Автор
              30.10.2017 11:02

              Такое сглаживание должно давать немного другой эффект — эффект резинки, которая дает силу «стягивания», пропорциональную разности двух значений.


        1. Satim
          30.10.2017 07:42

          А так разве не будет камера рывками двигаться???


          1. mayorovp
            30.10.2017 08:38

            Только если двигать мышку рывками.


      1. mayorovp
        30.10.2017 13:34
        +1

        Да, по поводу "интегрального" вида конкретно этой формулы. Выводится он вот так. Допустим, один и тот же ввод был дан два раза подряд. В таком случае получим новое значение угла new_camera_angles * (k*k + 2*k) + camera_angles * (1 - k) ^ 2. Отсюда видим, что:


        1. под любой шаг времени можно подстроиться сменой k;
        2. коэффициент при старом значении camera_angles возводится в квадрат при удвоении интервала времени.

        Отсюда методом внимательного взгляда получаем формулу того же вида, но которую уже не надо вручную подстраивать под разные интервалы времени:


        float k = pow(k0, dt);
        camera_angles = new_camera_angles * (1-k) + camera_angles * k;

        Осталось подобрать k0 по ощущениям. Ну, или его можно вычислить как exp(1-k, 1/dt0), где dt0 — эталонный интервал времени (замерять на железе разработчиков игры).


        Как видно, никакого логарифма нет, напротив — экспонента получилась (ну, не зря же формулу еще экспоненциальным затуханием называют).


        1. FadeToBlack Автор
          30.10.2017 16:22

          да, я помню, что там должно было быть что-то экспоненциальное, а в какую сторону — забыл.


  1. Geks0n
    30.10.2017 08:33
    +1

    Автору спасибо, было познавательно.


  1. defusioner
    30.10.2017 10:59
    -1

    Спасибо


  1. T-362
    30.10.2017 12:44

    Название игры "Quake" раньше было синонимом чего-то крутого, высокотехнологичного и идеального. И в голову не могло придти, что через какую-то пару десятков лет и камня на камне не останется от былого превосходства, а в новой игре с именем "Quake" появятся грубые ошибки, приводящие к тому, что один из игроков может получить преимущество только потому, что у него лучше "железо".

    FYI эту кваку большей частью делает питерский Сейбер Интерактив и скрещивая свой движок с айдишными наработками. Сейберы, конечно, молодцы, и одна из немногих выживших российских студий что вообще способны сделать ААА фпс при нужном пиаре и вливании бабла, но все равно до хтонических навыков Айди им далеко. В принципе скажем спасибо бетхезде как издателю за такой странный подход с распределением разработки.