Friday, 15 May 2009

The trouble with timers

On the iPhone it seems to have become commonplace to use a NSTimer to trigger redraws on OpenGL ES applications when displaying a changing or animated scene. To run smoothly, a frame rate of 30fps or higher is generally aimed for (60fps being the maximum, limited by the iPhone hardware refresh).

Relying on a timer to fire the code that renders the frame has its pros and its cons. The pros are simplicity and having everything in the same thread of execution (or runloop) means not having to worry about concurrency issues. The cons are what I'm more interested in..

The NSTimer class reference states: A timer is not a real-time mechanism; it fires only when one of the run loop modes to which the timer has been added is running and able to check if the timer’s firing time has passed. Because of the various input sources a typical run loop manages, the effective resolution of the time interval for a timer is limited to on the order of 50-100 milliseconds. If a timer’s firing time occurs while the run loop is in a mode that is not monitoring the timer or during a long callout, the timer does not fire until the next time the run loop checks the timer." Therefore, the actual time at which the timer fires potentially can be a significant period of time after the scheduled firing time.

The low resolution and the fact that a timer can misfire (skip frames) are the most worrying for me.

A frame rate of 60fps means a timer period of 1/60 = 0.016667 seconds (or 16.667ms). What this means is that the frame needs to be rendered well within 16.667ms or the timer will misfire. Flipping buffers when rendering an OpenGL frame can block, depending on the hardware refresh sync, making the likelihood of an overrun (and subsequent misfire) even higher.

What typically happens is the timer fires every 16.667 ms or so (depending on what else the run loop is up to), but for frames whose drawing code has not completed by the next time the timer is meant to fire, the subsequent frame is effectively skipped.

A misfire results in the time between two ticks suddenly being twice the expected period (or possibly a higher multiple of the period if the overrun in the rendering is even higher). Depending on the way in which the animation is implemented in the application, this will usually cause a visible blip or stutter. Regular misfires would make the animation appear jittery and unpleasant.

As most developers simply add the timer to the main runloop, it means that the events need to fire in between whatever other code the main runloop is executing (in particular, user events). This can add inconsistency to the timer period.

Things that can be done to improve this situation (many of which can be done together) are:

  1. Optimise the drawing code so that it comfortably completes within the timer period (16.667ms for 60fps). If it's far under this threshold, then the other things happening in the runloop shouldn't regularly push it over the threshold.
  2. Change the animation code so that it does not expect a regular period and animates the scene based on how much time has passed since the last animation. This is not always feasible - see this time-based animation tutorial.
  3. Set the timer on a seperate thread (not the main thread).
  4. Use a thread with its own custom loop rather than a timer. This in itself is not a solution if the rendering regularly exceeds the desired frame rate period, however can improve the smoothness of the animation when combined with some of the other things in this list; it also gives you greater control over when to draw the next frame when the previous frame has overrun, instead of being forced into multiples of the timer.
  5. Increase the timer period and only render the scene when an appropriate amount of time has passed (oversampling).
  6. Decrease the frame rate; however it can only be decreased so far before it starts to look bad.
  7. Decouple the drawing from code that is not directly related to rendering (such as physics simulation or animation logic) - see this blog on fixing the animation timestep for a good overview of this approach.
  8. Synch to the hardware refresh. This is typically what console games do and would be the best approach in many situations. Unfortunately this not currently possible on the iPhone as no hook or callback is provided by the underlying APIs.

My preferred approach is a combination of 1, 2 and 4. If option 8 was available I would go for a combination of 1, 2 and 8.

No comments: