0

I'm trying to implement high resolution timing for a Game Boy emulator. A 16-17 millisecond timer is sufficient to get the emulation at roughly the right speed, but it eventually loses sync with precision emulations like BGB.

I originally used QElapsedTimer in a while loop. This gave the expected results and kept sync with BGB, but it feels really sloppy and eats up as much CPU time as it possibly can because of the constantly-running while loop. It also keeps the program resident in Task Manager after closing. I tried implementing it using a one millisecond QTimer that tests the QElapsedTimer before executing the next frame. Despite the reduced resolution, I figured that the timing would average out to the correct speed due to checking the QElapsedTimer. This is what I currently have:

void Platform::start() {
    nanoSecondsPerFrame = 1000000000 / system->getRefreshRate();
    speedRegulationTimer->start();
    emulationUpdateTimer->start(1);
}


void Platform::executionLoop() {
    qint64 timeDelay;

    if (frameLocked == true)
        timeDelay = nanoSecondsPerFrame;
    else
        timeDelay = 0;

    if (speedRegulationTimer->nsecsElapsed() >= timeDelay) {
        speedRegulationTimer->restart();
        // Execute the cycles of the emulated system for one frame.
        system->setControllerInputs(buttonInputs);
        system->executeCycles();
        if (system->getIsRunning() == false) {
            this->stop();
            errorMessage = QString::fromStdString(system->getSystemError());
        }
        //timeDelay = speedRegulationTimer->nsecsElapsed();
        FPS++;
    }
}

nanoSecondsPerFrame calculates to 16742005 for the 59.73 Hz refresh rate. speedRegulationTimer is the QElapsedTimer. emulationUpdateTimer is a QTimer set to Qt:PreciseTimer and is connected to executionLoop. The emulation does run, but at about 50-51 FPS instead of the expected 59-60 FPS. This is definitely due to the timing because running it without timing restraints results in an exponentially higher frame rate. Either there's an obvious oversight in my code or the timers aren't working like I expect. If anyone sees an obvious problem or could offer some advice on this, I'd appreciate it.

3 Answers3

1

I'd suggest using QElapsedTimer to keep track of when your next frame should be executed (ideally) and then dynamically computing a QTimer::singleShot() call's msec argument based on that, so that your timing loop automatically compensates for the time it takes for the GameBoy code to run; that way you can avoid the "drifts away from sync" problem that you mentioned. Something like this:

// Warning:  uncompiled/untested code, may contain errors
class Platform : public QObject
{
Q_OBJECT;

public:
   Platform() {/* empty */}

   void Start()
   {
      _nanosecondsPerFrame = 1000000000 / system->getRefreshRate();
      _clock.start(); 
      _nextSignalTime = _clock.elapsed();
      ScheduleNextSignal();
   }

private slots:
   void ExecuteFrame()
   {
      // called 59.73 times per second, on average
      [... do GameBoy calls here...]

      ScheduleNextSignal();
   }

private:
   void ScheduleNextSignal()
   {
      _nextSignalTime += _nanosecondsPerFrame;
      QTimer::singleShot(NanosToMillis(_nextSignalTime-_clock.elapsed()), Qt::PreciseTimer, this, SLOT(ExecuteFrame()));
   }

   int NanosToMillis(qint64 nanos) const
   {
      const quint64 _halfAMillisecondInNanos = 500 * 1000;  // so that we'll round to the nearest millisecond rather than always rounding down
      return (int) ((nanos+_halfAMillisecondInNanos)/(1000*1000));
   }

   QElapsedTimer _clock;
   quint64 _nextSignalTime;
   quint64 _nanosecondsPerFrame;
};
Jeremy Friesner
  • 70,199
  • 15
  • 131
  • 234
  • Thanks for the suggestion, but before I can implement anything like this, I need to understand why it runs roughly ten frames behind what it should. For instance, if I have emulationUpdateTimer start at 17 milliseconds, this would be 17000000 nanoseconds. This suggests that when executionLoop is called, the ElapsedTimer's nsecsElapsed should have exceeded the timeDelay of 16742005. At most, it should miss one or two frames if it gets called at something less than 16742005 elapsed, right? It doesn't though. It misses 10 or more frames, and I don't understand why. – Benjamin Crew Apr 07 '21 at 05:11
  • Since Qt isn't a real-time environment, there is always going to be a significant amount of error in when timing callbacks get called -- they may get called early, or late, compared to the "platonic ideal callback time" that you would presumably get if the CPU was infinitely fast. The trick is to write the code so that the timing errors do not accumulate. – Jeremy Friesner Apr 07 '21 at 05:16
  • Right, but surely the precision can't be off enough to cause 10 frames of difference per second, can it? If I remove the speedRegulation / timeDelay check in executionLoop, the emulation runs as expected close to 60 FPS when I set the emulationUpdate timer to 17 milliseconds. With the if statement check, it somehow loses 10 frames every second. What is happening with that speedRegulationTimer check to cause that much precision loss? – Benjamin Crew Apr 07 '21 at 05:27
  • Ah, I found the cause of my 10 frame delay. I had another QTimer running at a speed very close to the one controlling the emulation speed. Eliminating that timer resolved the 50 frame speed issue. I added a sub-millisecond timer compensation based on your suggestion and the speed difference with BGB is now virtually imperceptible! Thanks very much. Sorry about the unrelated timing issue. – Benjamin Crew Apr 07 '21 at 11:38
  • Timer events are not guarantied to be executed at specific time, the are executed when all other events in event loop done executing. Try adding `qApp->processEvents()` in `Platform::executionLoop` to reduce cpu usage. – mugiseyebrows Apr 07 '21 at 11:55
  • Thanks, but I stepped through with the debugger and found that the elapsed nanoseconds each time the function was called actually stayed very close to 16-17 milliseconds. The 10 frames dropping were apparently due entirely to the overlapping timer I mentioned. – Benjamin Crew Apr 07 '21 at 12:03
0

I'm adding my own answer based on Jeremy Friesner's suggestion. The 50 FPS issue was caused by another QTimer with a similar timing overlapping with the one used to regulate the emulation updates. I didn't realize that QTimers with nearly the same timeouts could throw timing off by that much, but apparently they can. This is my variation on Jeremy's suggestion if anyone is interested:

void Platform::start() {
    nanoSecondsPerFrame = 1000000000 / system->getRefreshRate();
    milliSecondsPerFrame = (double)nanoSecondsPerFrame / 1000000;
    speedRegulationTimer->start();
    executionLoop();
}


void Platform::executionLoop() {
    qint8 timeDelay;

    if (frameLocked == true)
        timeDelay = round(milliSecondsPerFrame - (speedRegulationTimer->nsecsElapsed() / nanoSecondsPerFrame));
    else
        timeDelay = 1;

    if (timeDelay <= 0)
        timeDelay = 1;

    speedRegulationTimer->restart();
    QTimer::singleShot(timeDelay, Qt::PreciseTimer, this, SLOT(executionLoop()));

    system->setControllerInputs(buttonInputs);
    system->executeCycles();
    if (system->getIsRunning() == false) {
        this->stop();
        errorMessage = QString::fromStdString(system->getSystemError());
    }
    emit screenUpdate();
    FPS++;
}

If the function takes longer than it should to be called, it reduces the number of milliseconds until the next call. Using this implementation, the difference in speed with BGB is practically imperceptible with little CPU time wasted.

0

You can use a QTimer with the type Qt::PreciseTimer

  • Thanks, but I mentioned I was doing that already. – Benjamin Crew Apr 07 '21 at 23:45
  • So I suggest to make a sub class from QThread and override run method and you make there an infinity while loop with nano-seconds sleep and add your stuff that you can to execute. – malek.khlif Apr 07 '21 at 23:52
  • I already had an infinite while loop too. That's what I was trying to avoid because it uses unnecessary CPU time. I implemented a solution based on Jeremy's answer that works well though. – Benjamin Crew Apr 07 '21 at 23:55