Web components

High Performance HTML Rendering

How to properly use a game loop with HTML canvas drawings

by Joe Honton

Game programming uses a software pattern called a game loop. It is an important technique for any software developer to study, having applicability to serious endeavors beyond graphic animations, for example in: time-series data visualization and scientific modeling.

A simple game loop can be created with HTML using only one DOM function: requestAnimationFrame. It is set up as a callback function, with the browser invoking it approximately 60 times per second. The browser optimizes this frequency to match the user's screen refresh rate.

The browser also optimizes this function to reduce power consumption so that when the browser is minimized or when the host document is not the active tab, the callback is not invoked.


Game loop within a DOM component

To demonstrate how to put this to use, consider a DOM component that displays an analog clock. We set up the component to have two canvas elements: one for the clock face and another for the clock hands. They should be sized identically, and positioned using CSS to be on top of each other.

class AnalogClock extends HTMLElement {
constructor() {
super();
this.canvas1 = document.createElement('canvas');
this.canvas2 = document.createElement('canvas');
}
}

The component is added to the host document's HTML using the custom element tagname <analog-clock>, defined with this snippet:

window.customElements.define('analog-clock', AnalogClock);

When the component is instantiated by the browser, the normal connectedCallback life-cycle function is called. This is where the canvas elements are added to the component's shadow DOM and the game loop is kicked-off.

connectedCallback() {
const shadow = this.attachShadow({mode: 'open'});
shadow.appendChild(this.canvas1);
shadow.appendChild(this.canvas2);
window.requestAnimationFrame(this.continuous.bind(this));
}

continuous(msSinceStart) {
this.renderClockFace();
this.renderClockHands();
window.requestAnimationFrame(this.continuous.bind(this));
}

Improving the game loop

There are four simple things we can do to improve the continuous game loop.

First, we can take advantage of the function's only argument msSinceStart. It is a highres timestamp, expressed as the number of milliseconds since the UNIX time epoch, with microsecond (μs) accuracy. For any app that needs complete real-time fidelity we can use this value to determine the elapsed time between invocations. Normally the elapsed time will be quite similar from one invocation to the next, but it can vary for several reasons:

  • The browser went into background mode or the tab lost focus.
  • The browser's garbage collector stole too many CPU cycles.
  • The app's drawing function was too intensive.
  • The user's device was busy handling other apps.

By saving the msSinceStart we can calculate the true delta from one invocation to the next, and use that to keep animations in sync with real time.

var elapsedMs = msSinceStart - this.mostRecentPaint;

Second, we can't know for sure what refresh rate the user's device is capable of. In order to throttle things to a fixed rate, we can use the just calculated elapsedMs as a gate, ignoring any invocation that comes too quickly.

// do not exceed refresh rate of 60Hz (16.67 frames per second)
if (elapsedMs > 16.67) {
this.renderClockFace();
this.renderClockHands();
}

Third, we can place our canvas drawing functions behind promises, making them asynchronous. Then, in order to prevent adjacent rendering cycles from overlapping we can use a flag to block attempts to repaint while a prior painting operation is still in progress.

And fourth, we can intelligently add isDirty flags to each render function, to completely skip unnecessary drawing.

All together, our optimized game loop becomes:

async continuous(msSinceStart) {
var elapsedMs = msSinceStart - this.mostRecentPaint;
if (elapsedMs > 16.67) {
if (!this.currentlyPainting) {
await this.renderClockFace(elapsedMs);
await this.renderClockHands(elapsedMs);
this.mostRecentPaint = msSinceStart;
}
}
window.requestAnimationFrame(this.continuous.bind(this));
}

async renderClockFace(elapsedMs) {
return new Promise((resolve) => {
if (this.isClockFaceDirty) {
this.currentlyPainting = true;
// lengthy drawing operations ...
this.isClockFaceDirty = false;
this.currentlyPainting = false;
}
resolve();
});
}

async renderClockHands(elapsedMs) {
return new Promise((resolve) => {
if (this.isClockHandsDirty) {
this.currentlyPainting = true;
// lengthy drawing operations ...
this.isClockHandsDirty = false;
this.currentlyPainting = false;
}
resolve();
});
}

Looking forward

For canvas drawings that need to perform lots of geometry calculations, like the full earth mapping component shown in the title block, the requestAnimationFrame callback will arrive too fast, and a single CPU won't be able to keep up. The currentlyPainting gate will prevent incoming callbacks from clobbering partially rendered scenes.

Of course most devices have more than one CPU, and we'd like to be able to make use of them. This is where Web Workers and the OffscreenCanvas function will come to the rescue. The idea is to draw the next scene to the offscreen canvas running on its own thread, then directly transfer the rendered scene to the main DOM thread.

Unfortunately, browser support for this is incomplete. As of this writing, Chrome, Edge, Opera, and their Android ports fully support OffscreenCanvas. Firefox only partially supports it behind an experimental flag. Safari and its iOS port have no support and no public timeline for its availability, despite community requests dating back to 2017.

When caniuse lights up green across the board, we'll have a powerful tool to push HTML rendering towards game-quality standards.

Then we can really start having fun!

High Performance HTML Rendering — How to properly use a game loop with HTML canvas drawings

🔎