Andre Bandarra's Blog

Building Doom Fire using Modern JavaScript

Doom Fire is an animated fire used by some ports of Doom, and documented in Fabien Sanglard's blogpost. Despite the animation looking cool, the code is simple and ideal for learning graphic APIs.

The Offscreen Canvas API allows for moving the animation code to a Worker, allowing the main thread to worry about more important things - like handling user input! I've been looking into trying out the API for a while and the Doom Fire animation sounded simple enough to allow focusing on how Offscreen Canvas works.

This post will focus on the Offscreen Canvas and modern JavaScript aspects for the code. I do recommend Fabien's blogpost if you just want to learn more about the animation or go straight to the source code.

Browser support

As most modern APIs, it's a good idea to start the work by checking browser support. The Offscreen Canvas API has good support and includes Chrome, Samsung Internet, Edge, and a couple other browsers. But it's still missing in Safari and Firefox.

Thankfully, the API is easy to feature test:

const canvas = document.querySelector('#canvas');
if ("OffscreenCanvas" in window) {
// Offscreen Canvas code goes here!
}

Architecture considerations

Since Offscreen Canvas is not supported by all browsers, we'll want to use progressive enhancement and use the API when it is available - this means that the animation will run on a worker thread when Offscreen Canvas is available and on the main thread when it isn't.

With this information, we now know we have to decouple the code that runs the animation from the code that sets up the Canvas and add it to a module that can be used from the main thread or from the worker:

  export default class DoomFireAnimation {
constructor(parent, canvas) {
this.parent = parent;
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
... // Finish setting up the animation.
}

start() {
this.parent.requestAnimationFrame(this._update.bind(this));
}

_update() {
...
// Run the Doom Fire animation then render the next frame.
this.parent.requestAnimationFrame(this._update.bind(this));
}
}

In the snippet above, the constructor gets two variables:

  1. A reference to the context where the code is running, so we can call requestAnimationFrame() to render each frame. This will either be the Window object when the animation is running on the main thread or the Worker object when running off the main thread.
  2. A reference to the Canvas object that we will used to draw the animation.

The Worker implementation is straightforward:

import DoomFireAnimation from './doom-fire-animation.mjs';

let doomFireAnimation;

self.onmessage = function(ev) {
if(ev.data.msg === 'init') {
doomFireAnimation = new DoomFireAnimation(self, ev.data.canvas);
}

if (ev.data.msg === 'start') {
if (doomFireAnimation) {
doomFireAnimation.toggle().start();
}
}
}

The Worker can handle two types of messages: one to prepare the animation and another message that starts it. Those messages could be merged into a single one, but having separate events comes handy when wrapping the animation into a web component.

Finally, we can put everything together in the application:

const canvas = document.querySelector('#canvas');
if ("OffscreenCanvas" in window) {
const offscreenCanvas = this.canvas.transferControlToOffscreen();
this.worker = new Worker('doom-fire-worker.js');
this.worker.postMessage(
{msg: 'init', canvas: offscreenCanvas}, [offscreenCanvas]
);
this.worker.postMessage({msg: 'start'});
} else {
this.animation = new DoomFireAnimation(window, this.canvas);
this.animation.start();
}

When Offscreen Canvas is available, transferControlToOffscreen() transfers control of the canvas and then is passed to a Worker. We then send an init message with a reference to the Offscreen Canvas and start the animation.

When not available, the DoomFireAnimation is created in the main thread, with a reference to the Window object and the canvas.

Wrapping everything in a Web Component

export default class DoomFire extends HTMLElement {
constructor() {
super();

// Create our own Canvas!
this.canvas = document.createElement('canvas');
this.offscreen = "OffscreenCanvas" in window;

// Make the canvas use the whole element.
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';

if (this.offscreen) {
console.log('Rendering with Offscreen Canvas.');
const offscreenCanvas = this.canvas.transferControlToOffscreen();
this.worker = new Worker('doom-fire-worker.js');
this.worker.postMessage(
{msg: 'init', canvas: offscreenCanvas}, [offscreenCanvas]
);
} else {
console.log('Rendering with regular Canvas.');
this.animation = new DoomFireAnimation(window, this.canvas);
}

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(this.canvas);
}

connectedCallback() {
if (this.offscreen) {
this.worker.postMessage({msg: 'start'});
} else {
this.animation.start();
}
}
}

if (!customElements.get('doom-fire')) {
customElements.define('doom-fire', DoomFire);
}

And this is how the module can be added to the HTML:

<!doctype html>
<head>
...
<script type="module" src="doom-fire.mjs">
...
</head>
<body>
<doom-fire></doom-fire>
</body>

Performance

The Doom Fire animation is lightweight enough to run without effort in most devices, as it was originally created to run on devices like the PSX and the Nintendo 64.

Still, checking out the fire charts on DevTools (pun intended) shows us how free the main thread gets with Offscreen Canvas:

Main Thread Offscreen Canvas
Main Thread fire chart Worker fire chart

Where to go next

If you are a fan of the Canvas 2D API, you may be interested to know that it is getting updates and improvements! Check out the recent Chrome Dev Summit by Aaron Krajeski talk to learn more!

← Home