游戏时间步长:std::chrono 实施

Gaffer on games timestep: std::chrono implementation

如果您不熟悉 Gaffer on Games 文章 "Fix your Timestep",您可以在这里找到它: https://gafferongames.com/post/fix_your_timestep/

我正在构建一个游戏引擎,为了更加熟悉 std::chrono 我一直在尝试使用 std::chrono 实现一个固定的时间步长......现在几天,我似乎无法理解它。这是我正在努力的伪代码:

double t = 0.0;
double dt = 0.01;

double currentTime = hires_time_in_seconds();
double accumulator = 0.0;

State previous;
State current;

while ( !quit )
{
    double newTime = time();
    double frameTime = newTime - currentTime;
    if ( frameTime > 0.25 )
        frameTime = 0.25;
    currentTime = newTime;

    accumulator += frameTime;

    while ( accumulator >= dt )
    {
        previousState = currentState;
        integrate( currentState, t, dt );
        t += dt;
        accumulator -= dt;
    }

    const double alpha = accumulator / dt;

    State state = currentState * alpha + 
        previousState * ( 1.0 - alpha );

    render( state );
}

目标:

我目前的尝试(半固定):

#include <algorithm>
#include <chrono>
#include <SDL.h>

namespace {
    using frame_period = std::chrono::duration<long long, std::ratio<1, 60>>;
    const float s_desiredFrameRate = 60.0f;
    const float s_msPerSecond = 1000;
    const float s_desiredFrameTime = s_msPerSecond / s_desiredFrameRate;
    const int s_maxUpdateSteps = 6;
    const float s_maxDeltaTime = 1.0f;
}


auto framePrev = std::chrono::high_resolution_clock::now();
auto frameCurrent = framePrev;

auto frameDiff = frameCurrent - framePrev;
float previousTicks = SDL_GetTicks();
while (m_mainWindow->IsOpen())
{

    float newTicks = SDL_GetTicks();
    float frameTime = newTicks - previousTicks;
    previousTicks = newTicks;

    // 32 ms in a frame would cause this to be .5, 16ms would be 1.0
    float totalDeltaTime = frameTime / s_desiredFrameTime;

    // Don't execute anything below
    while (frameDiff < frame_period{ 1 })
    {
        frameCurrent = std::chrono::high_resolution_clock::now();
        frameDiff = frameCurrent - framePrev;
    }

    using hr_duration = std::chrono::high_resolution_clock::duration;
    framePrev = std::chrono::time_point_cast<hr_duration>(framePrev + frame_period{ 1 });
    frameDiff = frameCurrent - framePrev;

    // Time step
    int i = 0;
    while (totalDeltaTime > 0.0f && i < s_maxUpdateSteps)
    {
        float deltaTime = std::min(totalDeltaTime, s_maxDeltaTime);
        m_gameController->Update(deltaTime);
        totalDeltaTime -= deltaTime;
        i++;
    }

    // ProcessCallbackQueue();
    // ProcessSDLEvents();
    // m_renderEngine->Render();
}

此实现有问题

我的实际问题

If you use count(), and/or you have conversion factors in your chrono code, then you're trying too hard. So I thought maybe there was a more intuitive way.

下面我使用 <chrono>Fix your Timestep 实现了几个不同版本的“final touch”。我希望这个例子能转化为您想要的代码。

主要的挑战是弄清楚每个 doubleFix your Timestep 中代表什么单位。一旦完成,到 <chrono> 的转换就相当机械了。

前题

为了方便我们更换时钟,从Clock类型开始,例如:

using Clock = std::chrono::steady_clock;

稍后我将展示,如果需要,甚至可以根据 SDL_GetTicks() 实现 Clock

如果您可以控制 integrate 函数的签名,我建议时间参数使用基于双精度的秒单位:

void
integrate(State& state,
          std::chrono::time_point<Clock, std::chrono::duration<double>>,
          std::chrono::duration<double> dt);

这将允许您传递任何您想要的东西(只要 time_point 基于 Clock),而不必担心显式转换为正确的单位。加上物理计算通常以浮点数完成,因此这也适用于此。例如,如果 State 只包含一个加速度和一个速度:

struct State
{
    double acceleration = 1;  // m/s^2
    double velocity = 0;  // m/s
};

integrate应该计算新的速度:

void
integrate(State& state,
          std::chrono::time_point<Clock, std::chrono::duration<double>>,
          std::chrono::duration<double> dt)
{
    using namespace std::literals;
    state.velocity += state.acceleration * dt/1s;
};

表达式dt/1s简单地将基于double的计时码表seconds转换为double,因此它可以参与物理计算。

std::literals1s 是 C++14。如果你卡在 C++11 上,你可以用 seconds{1}.

替换它们

版本 1

using namespace std::literals;
auto constexpr dt = 1.0s/60.;
using duration = std::chrono::duration<double>;
using time_point = std::chrono::time_point<Clock, duration>;

time_point t{};

time_point currentTime = Clock::now();
duration accumulator = 0s;

State previousState;
State currentState;

while (!quit)
{
    time_point newTime = Clock::now();
    auto frameTime = newTime - currentTime;
    if (frameTime > 0.25s)
        frameTime = 0.25s;
    currentTime = newTime;

    accumulator += frameTime;

    while (accumulator >= dt)
    {
        previousState = currentState;
        integrate(currentState, t, dt);
        t += dt;
        accumulator -= dt;
    }

    const double alpha = accumulator / dt;

    State state = currentState * alpha + previousState * (1 - alpha);
    render(state);
}

此版本几乎与 Fix your Timestep 完全相同,除了一些 double 类型更改为 duration<double> 类型(如果它们表示持续时间),而其他的则得到更改为 time_point<Clock, duration<double>>(如果它们代表时间点)。

dt 的单位是 duration<double>(基于双精度的秒数),我假设 Fix your Timestep 中的 0.01 是类型 o,所需的值为 1。 /60。在 C++11 中 1.0s/60. 可以更改为 seconds{1}/60..

durationtime_point 的本地类型别名设置为使用基于 Clockdouble 的秒数。

从现在开始,代码几乎与 Fix your Timestep 相同,除了使用 durationtime_point 代替 double 类型。

注意alpha不是时间单位,而是无量纲的double系数

  • How can I replace SDL_GetTicks() with std::chrono::high_resolution_clock::now()? It seems like no matter what I need to use count()

同上。没有使用 SDL_GetTicks().count().

  • How can I replace all the floats with actual std::chrono_literal time values except for the end where I get the float deltaTime to pass into the update function as a modifier for the simulation?

同上,您不需要将浮点数 delaTime 传递给更新函数,除非该函数签名不在您的控制范围内。如果是这样的话,那么:

m_gameController->Update(deltaTime/1s);

版本 2

现在让我们更进一步:我们真的需要在持续时间和 time_point 单位中使用浮点数吗?

没有。以下是如何使用基于积分的时间单位做同样的事情:

using namespace std::literals;
auto constexpr dt = std::chrono::duration<long long, std::ratio<1, 60>>{1};
using duration = decltype(Clock::duration{} + dt);
using time_point = std::chrono::time_point<Clock, duration>;

time_point t{};

time_point currentTime = Clock::now();
duration accumulator = 0s;

State previousState;
State currentState;

while (!quit)
{
    time_point newTime = Clock::now();
    auto frameTime = newTime - currentTime;
    if (frameTime > 250ms)
        frameTime = 250ms;
    currentTime = newTime;

    accumulator += frameTime;

    while (accumulator >= dt)
    {
        previousState = currentState;
        integrate(currentState, t, dt);
        t += dt;
        accumulator -= dt;
    }

    const double alpha = std::chrono::duration<double>{accumulator} / dt;

    State state = currentState * alpha + previousState * (1 - alpha);
    render(state);
}

与版本 1 相比变化真的很小:

  • dt 现在的值为 1,用 long long 表示,单位为 1/60秒。

  • duration 现在有一个我们甚至不需要知道细节的奇怪类型。它与 Clock::durationdt 的总和类型相同。这将是可以准确表示 Clock::duration1/60 秒的最粗精度。谁在乎它是什么。重要的是基于时间的算法不会有截断错误,如果 Clock::duration 是基于整数的,甚至不会有任何舍入错误。 (谁说在电脑上不能准确表示1/3?!)

  • 0.25s 限制被转换为 250ms(C++11 中的milliseconds{250})。

  • alpha 的计算应积极转换为基于双精度的单位,以避免与基于整数的除法相关的截断。

更多关于Clock

  • 如果您不需要将 t 映射到物理中的日历时间,请使用 steady_clock,and/or 您不关心 t慢慢地偏离了准确的物理时间。没有完美的时钟,steady_clock 永远不会调整到正确的时间(例如通过 NTP 服务)。

  • 如果您需要将 t 映射到日历时间,或者如果您希望 t 与 UTC 保持同步,请使用 system_clock。这将需要在游戏进行时对 Clock 进行一些小的(可能是毫秒级或更小的)调整。

  • 如果您不关心得到的是steady_clock还是system_clock并且想对每次移植代码时得到的结果感到惊讶,请使用high_resolution_clock到新的平台或编译器。 :-)

  • 最后,如果你愿意,你甚至可以坚持使用 SDL_GetTicks(),像这样写你自己的 Clock

例如:

struct Clock
{
    using duration = std::chrono::milliseconds;
    using rep = duration::rep;
    using period = duration::period;
    using time_point = std::chrono::time_point<Clock>;
    static constexpr bool is_steady = true;

    static
    time_point
    now() noexcept
    {
        return time_point{duration{SDL_GetTicks()}};
    }
};

正在切换:

  • using Clock = std::chrono::steady_clock;
  • using Clock = std::chrono::system_clock;
  • using Clock = std::chrono::high_resolution_clock;
  • struct Clock {...}; // SDL_GetTicks based

需要对事件循环、物理引擎或渲染引擎进行 更改。只需重新编译。转换常量会自动更新。因此,您可以轻松地试验哪种 Clock 最适合您的应用程序。

附录

我完整的 State 完整性代码:

struct State
{
    double acceleration = 1;  // m/s^2
    double velocity = 0;  // m/s
};

void
integrate(State& state,
          std::chrono::time_point<Clock, std::chrono::duration<double>>,
          std::chrono::duration<double> dt)
{
    using namespace std::literals;
    state.velocity += state.acceleration * dt/1s;
};

State operator+(State x, State y)
{
    return {x.acceleration + y.acceleration, x.velocity + y.velocity};
}

State operator*(State x, double y)
{
    return {x.acceleration * y, x.velocity * y};
}

void render(State state)
{
    using namespace std::chrono;
    static auto t = time_point_cast<seconds>(steady_clock::now());
    static int frame_count = 0;
    static int frame_rate = 0;
    auto pt = t;
    t = time_point_cast<seconds>(steady_clock::now());
    ++frame_count;
    if (t != pt)
    {
        frame_rate = frame_count;
        frame_count = 0;
    }
    std::cout << "Frame rate is " << frame_rate << " frames per second.  Velocity = "
              << state.velocity << " m/s\n";
}