Introduction

Chip-8 runs at 60 fps (frames per second) and it’s very important to maintain this frame rate. Unlike modern systems, the sound and delay timers are tied to the frame rate. If the frame rate is too fast or too slow, the timing within apps will be off. Also, some apps will time off of the VSync (vertical synchronization). Making the frame rate even more important.

I tried multiple methods to maintain a consistent frame rate in Chipped-8 and in the end I was able to achieve a solid 60 fps. I used the third method below with QTimer if you want to skip ahead to exactly what I did.

Maintaining Frame Rates

Running at 60 fps means each frame is 1/60th of a second or 16.666666 ms. This number turned out to be problematic. I tried multiple methods for frame timing and each one has some kind of drawback. Or just plain didn’t work.

I did look at how game engines handle it and I’m happy I came up with all the same methods. The handful I looked at do one of or a combination of the following:

  1. Use a system timer / sleep and not care if the framerate is off. These typically only work with whole milliseconds. A problem when you need fractions of milliseconds. In this case, close enough is good enough and for most games this is fine. Especially if they’re able to render at a different rate than engine processing. Most modern games work like this because subsystems are clearly separated and not tied directly to the framerate.
  2. Use a busy loop for more fine grained timing than system timers / sleep can provide. Funny enough, I can get nano second timing measurements with Python but not with Python timers or sleep. I can get exactly 16.6666 frame timing with no deviation using this method but it maxes the CPU. Fine for a intensive 3D game but not acceptable in my mind for a basic emulator.
  3. Use timers / sleep like method 1 but use time deltas adjustments every cycle. Frame time still varies by about half a ms because only whole ms granularity is provided by system timers / sleep. But slight variations can mask the frame timing being slightly different per frame.

Timers / Sleep

Using timers / sleep for frame timing can be done by using time.sleep() to sleep the thread. Python’s threading.Timer object works in a similar way. Basically, take the time using time.perf_counter_ns() at the start of the frame. Then after all the cycles to process finish, take the time again. Finally, subtract the time from the frame time and sleep.

In theory this works well. There will be some times when the sleep will wake late due to thread scheduling, but that should be minimal and not cause a perceivable impact. If it does, we can use a time delta to further adjust the next sleep.

There was a small problem using this method. Python’s sleep method takes a fractional time in seconds but it’s limited to millisecond resolution. It can’t go as low as fractional milliseconds. That’s a problem but one we can work around using delta adjustments.

However, there is a much bigger problem. time.sleep() on my macOS system has a minimum sleep time of 20 milliseconds. That’s a maximum 50 fps we can get with this method. It’s simply not feasible to use sleep if there is a 20 ms floor.

Busy Loop

This one is the only method that can achieve perfect results but at the expense of CPU usage. Not a little, 100% usage. It can create near perfect 1/60 second frames and maintain a perfect 60 fps.

Basically, doing something like this:

While True:
    ns = time.perf_counter_ns()
    process_frame()
    while time.perf_counter_ns() - ns < 1 / 60 * 1000000000:
        pass

One advantage is, there is no worry about thread scheduling introducing delay. Getting true nano second accuracy is also very nice. It worked great in this regard out of all of the methods. However, the maxed CPU usage made me not want to use this method. Plus, the tight loop has it’s own problems. Like when trying to capture events such as keyboard input.

QTimer

Since, I’m using Qt for the GUI and audio, I’m already within the Qt event loop and I can use QTimer. It has a minimum 15 millisecond wait on many systems, and even as low as 1 millisecond on some. The biggest problem with python.sleep() and threading.Event solved. It even has a performance mode giving me farily solid accuracy. Also, it doesn’t have intensive CPU usage, since it’s an event timer, which solves the busy loop problems.

However, it still has the problem of taking whole milliseconds only. I can either get 62.5 fps with a 16 millisecond wait, or I get 58.8 fps with a 17 millisecond wait. Both are close but not right. I really want to use QTimer because Qt’s event system handles all thread synchronization and data protections for me. I handle events, like keyboard, with Qt’s signals and they’re thread safe.

To solve the whole millisecond only issue, I ended up varying the frame times every frame between 16 and 17 milliseconds. A 20/40 split will give 60 fps. The difference between frame times isn’t noticeable but the frame rate of 60 vs 58 or 62 is noticeable. Especially when it comes to sound since it’s tired directly to the frame rate with XO-Chip.

At the end of every frame I use QTimer start(#) to set when next frame should run. Alternatively I could have used setInterval() but it doesn’t really matter which one we use. They will work out exactly the same because setInterval() resets the timer to timeout after the new interval. Which is exactly what start(#) does.

In order to determine the new wait time, we need to account for a few things. First, the time it took to process this frame. Second, Any time longer than expected to start this frame. We’ll want to shorten the time before running the next frame to catch up and keep our 60 fps average. The event system could be slightly off when it triggers causing the extra time before processing starts. These two adjustments will give us our new interval which is when we want the timer to trigger and start processing the next frame.

The interval we need to set could be 0-17 ms. If we get a negative from our calculation we are very behind and have basically lost a frame. However, we can’t set a negative timer because we can’t go back in time, so 0 is used to immediately start processing the next frame.

interval = 17
if len(self._frame_times) % 3 == 0:
    interval = 16

interval = max(interval - ((time.perf_counter_ns() - ns_start) / 1000000) - max(((ns_start - ns_previous_frame) / 1000000) - 16, 0), 0)

I’m tracking the start time of every frame in a list (_frame_times) in order to calculate the frame rate. It’s handy that I’m doing this because I can also use it here to determine the 17 / 16 ms split for the base frame time.

This code block runs at the end of the frame processing function and just before calling start(interval). The ns_start variable is stored at the start of the function by calling time.perf_counter_ns(). The ns_previous_frame is the start time of the previous frame and it’s pulled out of the self._frame_times list. Which is used to track every ns_start. So we know the start time for the previous frame.

The equation above breaks down to

  • Base frame time we’re targeting: interval
  • Time spent processing this frame: (time.perf_counter_ns() - ns_start) / 1000000
  • Any time longer than we expected to wait. E.g. we waited for 18 ms between frames. This is a catch up.
    • Processing time of last frame (includes wait): (ns_start - ns_previous_frame) / 1000000
    • The minimum amount of time we should spend processing a frame: 16

Measuring Frame Rate

Measuring frame rate is really useful and really easy. Record the current time (use time.perf_counter_ns(), wait 60 frames and record the time again. Then subtract them to get how long 60 frames ran. Finally divide 60 by the time in seconds to get the fps.

Yet, I messed this up. I was counting 60 frames exactly but that only gives the time for 59 frames. Since I was taking the time at the start of the frame, the frame time is from it’s start to the start of the next frame. So I need two points, frame 1 and frame 2 to calculate the time for one frame. Meaning I need the time from 61 frames because the 61st frame is both the end time of the 60th frame and the start of the 61st frame.

I used this function for calculating the fps.

def _update_frame_time(self, ns):
    self._frame_times.append(ns)

    if len(self._frame_times) < 61:
        return

    time_61 = self._frame_times[-1]

    frame_time = self._frame_times[-1] - self._frame_times[0]
    sec = frame_time / 1000000000
    print('seconds: ', sec, 'fps: ', 60 / sec)

    self._frame_times = []
    self._frame_times.append(time_61)

The ns is taken at the start of the frame using time.perf_counter_ns().

Conclusion

I used the 3rd option with QTimer and delta adjustments to average 60 fps and it worked very well. Sounds syncs up great, you can’t tell each frame isn’t exactly the same length, it’s very smooth, and running the emulator won’t spin up your computer’s fans.

During this process I learned, the fractional ms timing is an issue game engines have to deal with. Also, this type of thing is pretty unique to writing an emulator because if you’re using a game engine, the engine handles this for you.

It’s interesting how important the frame rate turned out to be for such as simple system like Chip-8. I got a pretty stable 58 and 62 with method 1 and visually you can’t tell a difference. Sound on the other hand you can 100% tell is off because XO-Chip is tone based and each tone is exactly 1 frame in length. I guess the human ear is just that much more sensitive than our eyes.

Overall I’m very pleased I was able to solve this problem in an efficient and reliable way.