Realtime games are a different breed of software than data-driven websites. A harsh constraint for game developers is that, in order to get 60 frames per second, your game loop needs to execute in less than 16.66ms. If you know your target CPU and your execution time budget, you can work within this constraint. But when you’re writing a game for the browser, you have one more thing to worry about: the Javascript VM needs to pause periodically to collect garbage and manage the memory heap.
The good news is that modern browsers have vastly improved the efficiency and common-case runtime of their garbage collection phases. But are they good enough to make games with smooth animation today? How much garbage can your game safely create? Once your game loop fits into the CPU-time budget, can you know that it will be scheduled fast enough? Are you going to meet some target frame rate for the 99th percentile rendering time? The answers depend strongly on the client (hardware and browser), but we can make some measurements on typical computers and make some useful generalizations.
Let’s assume a typical game will have an animation loop which is gated by window.requestAnimationFrame()
, with the hope that the browser will schedule the loop at a stable 60fps. Also, assume that there is a single function which computes the next game state and “draws” it (to the DOM and Canvas). If we monitor the inter-arrival times of these requestAnimationFrame()
calls while watching the JS heap size we can see if garbage collection pauses are responsible for dropped frames.
The following example runs in Chrome, which supplies window.performance.now()
and window.performance.memory.usedJSHeapSize
(when invoked with --enable-memory-info
). Similar affordances exist in Firefox and Safari.
function() {
var lastHeapSize = null;
var lastFrameTime = null;
var runGame = function() {
requestAnimationFrame(runGame);
var frameTime = window.performance.now();
var heapSize = window.performance.memory.usedJSHeapSize;
if (lastHeapSize == null) { lastHeapSize = heapSize; }
if (lastFrameTime == null) { lastFrameTime = frameTime; }
var dt = frameTime - lastFrameTime;
var dh = heapSize - lastHeapSize
frameDataList.push([dt, dh]);
lastHeapSize = heapSize;
lastFrameTime = frameTime;
computeNextGameStateAndPaint();
};
}();
We can also make some adjustments to the implementation of computeNextGameStateAndPaint()
to see the effect of different amounts of garbage generation.
var makeSimulatedGameLoop = function(n) {
/* Pre-initialize an array with distinct simple objects */
var g = new Array(1000*n);
var count = 0;
for (i=0; i< g.length; i++) {
g[i] = {count: count++};
}
var _work = function() {
var i;
for (i=0; i < g.length; i++) {
/* For every game variable, double it's count property. */
/* Implicitly create a new object, garbaging the old one. */
g[i] = { count: g[i].count * 2 };
};
};
return _work;
};
computeNextGameStateAndPaint = makeSimulatedGameLoop(20); // Try 2, 20, 200
After this runs for a while, frameDataList
will contain a list of samples which are easy to analyze:
> window.frameDataList
[16.672734, 133128],
[16.183574, 128228],
[39.847293, -12158528],
[16.974714, 119222],
[16.672734, 140248]
Garbage collection events aren’t explicitly announced by the VM, but we can infer that one has just happened whenever the JS heap size decreases between two samples. We might undercount the number of garbage collection events by sampling too slowly, but we can’t overcount it this way.
Here are some plots showing the inter-arrival times of a series of animation frames, along with some decoration indicating changes to the size of the JS Heap. The bubble radius is proportional to the magnitude of the heap size change in the last frame, and color indicates the sign. This data was collected using Chrome 22 running on a high end laptop.
Here’s an example of an absurdly high amount of garbage generation with 200,000 anonymous objects becoming unreachable every frame. While this is an artificial and unrealistic memory-use profile, it still produces thought-provoking results. Notice how bi-modal the frame times are when there’s a lot of garbage being generated inside computeNextGameStateAndPaint()
. It’s clear that the slow frames which do occur almost always occur in conjunction with a garbage collection pass.
As we dial down the rate of garbage generation, the situation improves materially. GC pauses start to fit inside the gaps between frames, and predictable 30fps is within reach.
At 2,000 objects/tick, 60fps is looking rock-solid.
Too much garbage will certainly cause stalls that impact frame rate, but the critical amount of garbage is discoverable early in the development cycle of your application. It is possible to get stable fast frame rates provided you stay inside your per-frame CPU budget and keep the rate of garbage generation under control.
Share: Y
blog comments powered by Disqus