A very popular way to implement a game loop looks like this:
while (playing) {
advance state by one frame
render the new frame
sleep until it’s time to do the next frame
}
There are a few problems with this, the most fundamental being the idea that the game can define what a "frame" is. Different displays will refresh at different rates, and that rate may vary over time. If you generate frames faster than the display can show them, you will have to drop one occasionally. If you generate them too slowly, SurfaceFlinger will periodically fail to find a new buffer to acquire and will re-show the previous frame. Both of these situations can cause visible glitches.
What you need to do is match the display's frame rate, and advance game state according to how much time has elapsed since the previous frame. There are several ways to go about this:
- Use the Android Frame Pacing library (recommended)
- Stuff the BufferQueue full and rely on the "swap buffers" back-pressure
- Use Choreographer (API 16+)
Android Frame Pacing library
See Achieve proper frame pacing for information on using this library.
Queue stuffing
This is very easy to implement: just swap buffers as fast as you can. In early
versions of Android this could actually result in a penalty where
SurfaceView#lockCanvas()
would put you to sleep for 100ms. Now
it's paced by the BufferQueue, and the BufferQueue is emptied as quickly as
SurfaceFlinger is able.
One example of this approach can be seen in Android Breakout. It
uses GLSurfaceView, which runs in a loop that calls the application's
onDrawFrame() callback and then swaps the buffer. If the BufferQueue is full,
the eglSwapBuffers()
call will wait until a buffer is available.
Buffers become available when SurfaceFlinger releases them, which it does after
acquiring a new one for display. Because this happens on VSYNC, your draw loop
timing will match the refresh rate. Mostly.
There are a couple of problems with this approach. First, the app is tied to SurfaceFlinger activity, which is going to take different amounts of time depending on how much work there is to do and whether it's fighting for CPU time with other processes. Since your game state advances according to the time between buffer swaps, your animation won't update at a consistent rate. When running at 60fps with the inconsistencies averaged out over time, though, you probably won't notice the bumps.
Second, the first couple of buffer swaps are going to happen very quickly because the BufferQueue isn't full yet. The computed time between frames will be near zero, so the game will generate a few frames in which nothing happens. In a game like Breakout, which updates the screen on every refresh, the queue is always full except when a game is first starting (or un-paused), so the effect isn't noticeable. A game that pauses animation occasionally and then returns to as-fast-as-possible mode might see odd hiccups.
Choreographer
Choreographer allows you to set a callback that fires on the next VSYNC. The actual VSYNC time is passed in as an argument. So even if your app doesn't wake up right away, you still have an accurate picture of when the display refresh period began. Using this value, rather than the current time, yields a consistent time source for your game state update logic.
Unfortunately, the fact that you get a callback after every VSYNC does not guarantee that your callback will be executed in a timely fashion or that you will be able to act upon it sufficiently swiftly. Your app will need to detect situations where it's falling behind and drop frames manually.
The "Record GL app" activity in Grafika provides an example of this. On some devices (e.g. Nexus 4 and Nexus 5), the activity will start dropping frames if you just sit and watch. The GL rendering is trivial, but occasionally the View elements get redrawn, and the measure/layout pass can take a very long time if the device has dropped into a reduced-power mode. (According to systrace, it takes 28ms instead of 6ms after the clocks slow on Android 4.4. If you drag your finger around the screen, it thinks you're interacting with the activity, so the clock speeds stay high and you'll never drop a frame.)
The simple fix was to drop a frame in the Choreographer callback if the current time is more than N milliseconds after the VSYNC time. Ideally the value of N is determined based on previously observed VSYNC intervals. For example, if the refresh period is 16.7ms (60fps), you might drop a frame if you're running more than 15ms late.
If you watch "Record GL app" run, you will see the dropped-frame counter increase, and even see a flash of red in the border when frames drop. Unless your eyes are very good, though, you won't see the animation stutter. At 60fps, the app can drop the occasional frame without anyone noticing so long as the animation continues to advance at a constant rate. How much you can get away with depends to some extent on what you're drawing, the characteristics of the display, and how good the person using the app is at detecting jank.
Thread management
Generally speaking, if you're rendering onto a SurfaceView, GLSurfaceView, or TextureView, you want to do that rendering in a dedicated thread. Never do any "heavy lifting" or anything that takes an indeterminate amount of time on the UI thread. Instead, create two threads for the game: a game thread and a render thread. See Improve your game's performance for more information.
Breakout and "Record GL app" use dedicated renderer threads, and they also update animation state on that thread. This is a reasonable approach so long as game state can be updated quickly.
Other games separate the game logic and rendering completely. If you had a simple game that did nothing but move a block every 100ms, you could have a dedicated thread that just did this:
run() {
Thread.sleep(100);
synchronized (mLock) {
moveBlock();
}
}
(You may want to base the sleep time off of a fixed clock to prevent drift -- sleep() isn't perfectly consistent, and moveBlock() takes a nonzero amount of time -- but you get the idea.)
When the draw code wakes up, it just grabs the lock, gets the current position of the block, releases the lock, and draws. Instead of doing fractional movement based on inter-frame delta times, you just have one thread that moves things along and another thread that draws things wherever they happen to be when the drawing starts.
For a scene with any complexity you'd want to create a list of upcoming events sorted by wake time, and sleep until the next event is due, but it's the same idea.