6
\$\begingroup\$

I am writing first-person shooter game with fast motion. I have a fixed high-frequency physics loop thread, which is separated from variable-frequency rendering thread. I need to choose the physics frequency, but I'm unsure which value to choose. I have 3 options with the following properties:

  • 120 Hz:

    • Monitor support: 1:1 or 1:2 mapping to rendering thread refresh rate
    • Is divided by mains frequency in US, may be good for older CRTs there
  • 125 Hz:

    • 8ms integer frame delta (if time is expressed in integer ms, in a games like Quake3)
    • Default USB poll rate for most consumer mices (why the hell is it so?!..)
  • 128 Hz:


Which rate should i choose?

PS: Feel free to run my test code. If you are lucky Linux user, please share your port and your results, it may be interesting. On Windows: see how worse is 125hz jitter, compared to 128hz:

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>

#include <cstdint>
#include <vector>
#include <algorithm>
#include <numeric>
#include <iostream>

uint64_t qpf() {
    uint64_t hpF = 0;
    ::QueryPerformanceFrequency(reinterpret_cast<LARGE_INTEGER*>(&hpF));
    return hpF;
}

uint64_t qpc() {
    uint64_t hpC = 0;
    ::QueryPerformanceCounter(reinterpret_cast<LARGE_INTEGER*>(&hpC));
    return hpC;
}

// very precise (~1000x times), but would consume 100% cpu in absense of active threads.
void yield_active() {
    SwitchToThread();
}

// default precision is 15.625ms, but 1ms +-0.3ms if NtSetTimerResolution() was called
void yield_passive() {
    SleepEx(1, false);
}

// import ntdll stuff, this is the heart of Sleep()/SleepEx():
static int(__stdcall *NtDelayExecution)(BOOL Alertable, PLARGE_INTEGER DelayInterval) = (int(__stdcall*)(BOOL, PLARGE_INTEGER)) GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtDelayExecution");

// 0.5ms +-0.3ms, 5000 us value is hardcoded into ntoskrnl, which is hard to change; i tried, attempt failed:
// https://superuser.com/questions/1326252/changing-windows-thread-sheduler-timeslice
// https://reverseengineering.stackexchange.com/questions/18384/ntoskrnl-checksum-mismatch/18387
void yield_passive_hp(const int64_t& delayInterval) {
    //delay.QuadPart = (milliseconds > 1) ? -10000LL * (milliseconds - 1) : -1LL;
    ::NtDelayExecution(false, (PLARGE_INTEGER)(&delayInterval));
}

// ensure that some moment defined by target timespamp had occured
uint64_t wait_stamp(uint64_t targetStamp) {
    uint64_t currentStamp;
    while ((currentStamp = qpc()) < targetStamp)
        //yield_active();
        yield_passive_hp(-1000); // yield_passive_hp(-1000); asking to wait for 100us or 0.1ms, implementation would wait for at least 0.5ms
        //yield_passive();
    return currentStamp;
}

template< typename T >
T series_mean(const std::vector< T >& vSeries) {
    auto lagSum = std::accumulate(vSeries.begin(), vSeries.end(), T(0));
    return lagSum / vSeries.size();
}

template< typename T >
T std_deviation(const std::vector< T >& vSeries, const T& lagMean = 0) {
    std::vector<T> vSqrDiff(vSeries.size());
    std::transform(vSeries.begin(), vSeries.end(), vSqrDiff.begin(), [lagMean](const T& x) { return x - lagMean; });
    double varianceSum = std::inner_product(vSqrDiff.begin(), vSqrDiff.end(), vSqrDiff.begin(), T(0));
    return std::sqrt(varianceSum / vSeries.size());
}

int main() {
    double benchmarkTime = 1.0; // run game loop for 1.0 second
    double fixedFPS = 128.0; // try to stick as close as possible to 1/128th timeslices
    auto hpF = qpf();
    auto fixedDelta = 1.0 / fixedFPS;
    auto numTicks = static_cast< uint64_t >( benchmarkTime * fixedFPS );
    std::vector< double > vLags;
    vLags.reserve(numTicks);
    auto startStamp = qpc();
    uint64_t frameNumber = 0;
    do {
        auto stampDelta = static_cast<uint64_t>(static_cast<double>(hpF * frameNumber++) * fixedDelta);
        auto targetStamp = startStamp + stampDelta;
        auto actualStamp = wait_stamp(targetStamp);
        auto lastDelta = static_cast<double>(actualStamp - targetStamp) / hpF;
        vLags.emplace_back(lastDelta);
    } while (frameNumber < numTicks);

    auto lagMean = series_mean(vLags);
    auto lagDeviation = std_deviation(vLags, lagMean);
    std::cout << "lagMean=" << lagMean * 1000 << " ms; lagDeviation=" << lagDeviation * 1000 << " ms;\n";
    return 0;
}
\$\endgroup\$
  • 1
    \$\begingroup\$ Why are you hard coding frame rate? Just let the user decide like most games do, give them the option of no FPS cap or a set cap (either user enter-able, or a selection of fixed presets). \$\endgroup\$ – Trotski94 Aug 22 '18 at 10:43
  • 5
    \$\begingroup\$ @JamesTrotter for rendering you are right, framerate is defined by either vsync or user and is of minor importance. But for game physics where i update user coordinate using euler integration scheme x = x + v * dt you'd normally want to have same dt and fixed dt for all users to put them in equal conditions and normalize error. example: Quake3, where /seta com_maxfps 125 fps gives user an ability to perform higher jumps, than standart 85fps. AFAIK, interframe delays there are made with means of select(..., timeval) function \$\endgroup\$ – xakepp35 Aug 22 '18 at 10:46
  • \$\begingroup\$ Let us continue this discussion in chat. \$\endgroup\$ – xakepp35 Aug 22 '18 at 10:59
  • 2
    \$\begingroup\$ Two other ingredients to consider: what's the tick rate of your server for multiplayer (if you have one)? If your physics is ticking faster than your server sends updates, or your server ticks faster than typical variances in network round-trip-times, you might not see much benefit in responsiveness to player input from high frequencies. The other aspect to consider is that you can get a kind of "beat frequency" between the physics & rendering if their rates are closely matched / small integer multiples of one another. If you're using interpolation anyway though this shouldn't be very visible. \$\endgroup\$ – DMGregory Aug 22 '18 at 11:28
  • \$\begingroup\$ @DMGregory This value is tweakable on server, and it would dictate all clients' physics rates within game session. I am asking about default value to choose. Second, most users would normally have 55-60fps rendering, so i may care of it not much, since physics would tick more than 2x faster. \$\endgroup\$ – xakepp35 Aug 22 '18 at 11:56
1
\$\begingroup\$

I recommend 240Hz or 480Hz for physics.

It seems a bit much, but modern processors, even on mobile, are fast enough to run simple physics at this rate.

Using a timestep this small has the advantage of not having to interpolate frames to match your display rate. You can just just the nearest integer number of steps, and ignore the remainder.

Alternatively, you can follow Gaffer-on-Games approach and do that interpolation, but it requires a lot of book keeping, as you need to keep the state from old frames around, leading to overly complex code.

\$\endgroup\$
  • \$\begingroup\$ Why not use good numbers like 256 or 512? If you are about numeric precision then dividing by power of two would just modify exponent and leave mantissa untouched.And physics ODE solvers usually does many many fma operations, like x+=v*dt; where dt=1/FPS \$\endgroup\$ – xakepp35 Aug 30 '18 at 18:52
  • \$\begingroup\$ @xakepp35 Because 240Hz is exactly 4 timesteps on 60fps displays, and exactly 2 timesteps on 120fps displays, which are both important hardware targets. \$\endgroup\$ – Bram Aug 30 '18 at 19:15
  • \$\begingroup\$ Logic (input+processing) thread is very different from render(output) thread, which may run anywhere between 30 to 120 fps. Even 60 fps is actually 58 fps one frame and 62 fps in another... I think it's pointless, and even bad to stick to multiplies of 15. Two thing that matters: \$\endgroup\$ – xakepp35 Aug 31 '18 at 12:59
  • \$\begingroup\$ 1) To have faster producer(login) thread than consumer(render), due to triple buffer sync scheme, that i am using. Running at 512 fps would not affect if you have 45 or 75 fps renderer - logic just spits out freshest frame at the end and renderer just sees freshest frame in the beginning. \$\endgroup\$ – xakepp35 Aug 31 '18 at 13:00
  • \$\begingroup\$ 2) fps/60: It involves terrible rounding errors every frame, that ruins ODE solver with time. That is because multiplies of 15 leads to division by 3 and by 5 at same time, and this is second order of magnitude error per single division on a binary FPU... (fps/15 == fps/(3*5)) \$\endgroup\$ – xakepp35 Aug 31 '18 at 13:00
0
\$\begingroup\$

After playing a bit, i have figured out that best platform for implementing unified low-latency event loop for games is Linux. That is because it have nanosleep or timerfd mechanisms to do precise blocking wait, while still be able to perfectly produce up to 10000 fps loops. Windows OS is usually limited to 500-1000 fps loops with worse precision. Using at least one nanosleep() call the precision starts to getting worse after 8-10k fps mark, but still remains good(by heavily increasing cpu usage) with timerfd mechanism. So, its 8-10 times more precise than Windows version.

Using timerfd with epoll_wait() is great, because it also could multiplex network and evdev descriptor events in one event loop, unifying it and taking it much closer to soft realtime, than any other general-purpose OS.

Answering framerate question, I think that 128 or 256 fps would be best guess for almost any game, allowing for fast lockless inter-thread producer-consumer syncronization between event&physics loop and render loop, using triple buffering scheme. Producer have to run faster than consumer for best effects.

PS: Game loop lag&jitter benchmarking code, that i come to:

// LINUX
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <unistd.h>

#include <cstdint>
#include <cstring>
#include <cmath>
#include <vector>
#include <algorithm>
#include <numeric>
#include <iostream>
#include <stdexcept>

template< typename T >
T series_mean(const std::vector< T >& vSeries) {
    auto lagSum = std::accumulate(vSeries.begin(), vSeries.end(), T(0));
    return lagSum / vSeries.size();
}

template< typename T >
T std_deviation(const std::vector< T >& vSeries, const T& lagMean = 0) {
    std::vector<T> vSqrDiff(vSeries.size());
    std::transform(vSeries.begin(), vSeries.end(), vSqrDiff.begin(), [lagMean](const T& x) { return x - lagMean; });
    double varianceSum = std::inner_product(vSqrDiff.begin(), vSqrDiff.end(), vSqrDiff.begin(), T(0));
    return std::sqrt(varianceSum / vSeries.size());
}

uint64_t qpf() {
    return 1000000000ULL;
}

uint64_t qpc() {
    struct timespec currts;
    clock_gettime(CLOCK_MONOTONIC, &currts);
    return static_cast< uint64_t >(currts.tv_sec) * qpf() + static_cast< uint64_t >(currts.tv_nsec);
}

void s2ts(struct timespec& destTS, uint64_t srcStamp) {
    destTS.tv_sec = srcStamp / qpf();
    destTS.tv_nsec = srcStamp % qpf();
}


int main() {
    int tfd, epfd = 0;
    try {

        struct itimerspec ts;
        tfd = timerfd_create(CLOCK_MONOTONIC, 0);
        if (tfd == -1) 
            throw std::runtime_error("timerfd_create()");
        epfd = epoll_create(1);
        if (epfd == -1)
            throw std::runtime_error("epoll_create()");
        struct epoll_event ev;
        memset(&ev, 0, sizeof(ev));
        ev.events = EPOLLIN;
        if (epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev) == -1)
            throw std::runtime_error("epoll_ctl(EPOLL_CTL_ADD)");

        double benchmarkTime = 1.0; // run game loop for 1.0 second
        double fixedFPS = 256.0; // try to stick as close as possible to timeslices
        auto fixedDelta = 1.0 / fixedFPS;
        auto numTicks = static_cast< uint64_t >( benchmarkTime * fixedFPS );
        std::vector< double > vLags;
        vLags.reserve(numTicks);

        uint64_t frameNumber = 0;
        auto hpF = qpf();
        auto startStamp = qpc();
        do {
                auto stampDelta = static_cast<uint64_t>(static_cast<double>(hpF * frameNumber++) * fixedDelta);
                auto targetStamp = startStamp + stampDelta;

            struct itimerspec ts;
            ts.it_interval.tv_sec = 0;
            ts.it_interval.tv_nsec = 0;
            s2ts(ts.it_value, targetStamp);

            auto ret = timerfd_settime(tfd, TFD_TIMER_ABSTIME, &ts, NULL);
            if(ret < 0 )
                throw std::runtime_error("timerfd_settime()");
            memset(&ev, 0, sizeof(ev));
            ret = epoll_wait(epfd, &ev, 1, 500); // wait up to 500ms for timer
            if (ret < 0) 
                throw std::runtime_error("epoll_wait()");
            uint64_t res = 0;
            ret = read(tfd, &res, sizeof(res)); // reads number of expirations
                auto actualStamp = qpc(); //wait_stamp(targetStamp);
                auto lastDelta = static_cast<double>(actualStamp - targetStamp) / hpF;
                vLags.emplace_back(lastDelta);
        } while (frameNumber < numTicks);
        auto endStamp = qpc();
        auto testDelta = static_cast<double>(endStamp - startStamp) / hpF;
        std::cout << "test time is" << testDelta << "s\n";
        auto lagMean = series_mean(vLags);
        auto lagDeviation = std_deviation(vLags, lagMean);
        std::cout << "lagMean=" << lagMean * 1000 << " ms; lagDeviation=" << lagDeviation * 1000 << " ms;\n";
        return 0;


    }
    catch(const std::exception& eWhat) {
        std::cout << eWhat.what() << " exception: errno=" << errno;
        close(epfd);
        close(tfd);
        return EXIT_FAILURE;
    }
    return 0;
}

PPS. If you are interested, as for drawing thread, i've found excellent kmscube example and libevdev, that shows how to get rid of unwanted X.Org stuff, which can be useful when implementing lowest-latency and highest performance gaming engine in the world :-)

\$\endgroup\$
  • \$\begingroup\$ If that's what you went for, you might want to accept this answer :) \$\endgroup\$ – Vaillancourt Oct 5 '18 at 17:19
  • \$\begingroup\$ @AlexandreVaillancourt Yes, this seems to be good report on framerate/jitter analysis, on two platforms. Sadly, i have no OS X by hand, so this is not fully answerred - BSD and its kqueue() performance and precision is not analysed yet. But there are a lot of people owning macs, and using it as a gaming platform. Maybe, some day someone would have something else to add?.. For now I lack free time (to end up the comparison by myself), so maybe later on i would complete it.. \$\endgroup\$ – xakepp35 Oct 9 '18 at 19:14
  • \$\begingroup\$ At least a port of this code to either FreeBSD or OSX is required \$\endgroup\$ – xakepp35 Oct 9 '18 at 19:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.