游戏中的时间步长:std::chrono 实现

7
如果您对“Gaffer on Games”文章“修复时间步长(Fix your Timestep)”不熟悉,可以在此处找到它:https://gafferongames.com/post/fix_your_timestep/ 我正在构建一个游戏引擎,并尝试使用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 );
}

目标:

  • 我不希望渲染受到帧率的限制,应该在繁忙循环中进行渲染。
  • 我想要一个完全固定的时间步长,在其中使用 float 类型的时间增量调用我的更新函数。
  • 不能休眠。

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

#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();
}

这种实现的问题

  • 渲染、处理输入等都与帧率相关
  • 我使用的是SDL_GetTicks()而不是std::chrono

我的实际问题

  • 我如何将SDL_GetTicks()替换为std::chrono::high_resolution_clock::now()?似乎无论如何我都需要使用count(),但我从Howard Hinnant本人那里读到了这句话:

如果你使用count(),和/或者在你的chrono代码中有转换因子,那么你就是在过度尝试。 所以我想可能有一种更直观的方式。

  • 我如何用实际的std::chrono_literal时间值取代所有的float,除了最后我得到float deltaTime作为模拟更新函数的修饰符之外?
1个回答

21

下面我使用<chrono>实现了来自固定你的时间步长的“最终触摸”的几个不同版本。 我希望这个例子能够转化为您所需的代码。

主要的挑战是确定Fix your Timestep中每个double表示的单位。 一旦完成,转换到<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将基于doublechrono seconds转换为double,以便参与物理计算。 std::literals1s是C++14的语法。如果你被困在C++11中,可以用seconds{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);
}

这个版本与修复时间步长几乎完全相同,只有一些double被更改为类型duration<double>(如果它们表示时间持续时间),其他一些则被更改为time_point<Clock, duration<double>>(如果它们表示时间点)。

dt的单位是duration<double>(基于double的秒),我认为修复时间步长中的0.01是一个打字错误,期望值是1./60。在C++11中,可以将1.0s/60.更改为seconds{1}/60.

本地类型别名用于durationtime_point,以使用Clock和基于double的秒。

从现在开始,代码与修复时间步长几乎完全相同,只是使用durationtime_point来替换类型double

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

如上所述,需要使用std::chrono_literal时间值来替换所有的浮点数,除了最后获取浮点数deltaTime以作为模拟器修饰符传递给更新函数之外。如果函数签名不受您控制,则无需将float类型delaTime传递给更新函数。
m_gameController->Update(deltaTime/1s);

版本2

现在让我们再进一步:我们是否真的需要使用浮点数来表示时间间隔和时间点单位?

不需要。以下是使用基于整数的时间单位实现相同效果的方法:

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映射到物理日历时间,和/或者不关心t是否慢慢偏离精确物理时间,那么使用steady_clock。没有完美的时钟,steady_clock从不会校准为正确的时间(例如通过NTP服务)。
如果您需要将t映射到日历时间或者希望t与协调世界时保持同步,则使用system_clock。这将需要对Clock进行一些小的(可能是毫秒级别或更小的)调整,以使其在游戏运行时保持同步。
如果您不关心使用steady_clock还是system_clock,并想每次将代码移植到新平台或编译器时都获得惊喜,请使用high_resolution_clock
最后,如果您希望可以使用SDL_GetTicks(),则可以编写自己的Clock,像这样:

E.g.:

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

不需要对事件循环、物理引擎或渲染引擎进行任何更改,只需重新编译即可。转换常数将自动更新。因此,您可以轻松尝试哪种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";
}

哦我的天啊,非常感谢您抽出时间回复!这太棒了!我从来不知道std::chrono可以看起来如此简洁!现在所有的std::chrono都变得很清晰了,再次感谢!我对整合过程有完全的控制,并且我正在使用最新版本的MSVC。关于 State 对象,我有一个问题。你认为 State state = currentState * alpha + previousState * (1 - alpha) 会扩展到在 state.accelerationstate.velocity 上执行该操作吗?我不确定是否暗示了一种可以执行该操作的 operator= 重载。 - Josh Sanders
很高兴能够帮助。关于“State”,是的,插值可以在两个字段上操作。我在我的原型中这样做了。我已经将所有的“State”代码添加到我的答案中作为示例。 - Howard Hinnant
你知道250ms代表什么吗? - Lukas__
250毫秒是另一种表示milliseconds{250}的方式。这种语法要求使用C++14或更高版本,并且需要添加using namespace std::chrono_literals;语句。 - Howard Hinnant
@Lukas__: 250ms 是为了应对“死循环”而设置的帧时间限制。它不一定要恰好是 250ms,但应该足够高以处理(希望是暂时的)负载峰值。 - Fake Code Monkey Rashid
@HowardHinnant:干得好!这是我见过的最全面、完整和“真实”的Gaffer Fix Your Timestep C++实现。你对chrono库的大量使用尤其令人启发。如果我必须挑剔什么,那就是integrate函数不完整(传递了t但未命名或使用),并且在此基础上应该让人们知道积分基础确实是必读的。 - Fake Code Monkey Rashid

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接