Using javascript's async/await
and requestAnimationFrame
to render the Buddhabrot
Updated October, 2019
Rendering the Buddhabrot is a computationally intensive process.
Therefore, we need to be very careful if we want to paint the Buddhabrot without blocking the page.
We can use Javascript's Promises
to asynchronously handle
the scan (where we iterate the Mandelbrot formula) and the render (where we paint whatever pixel data we have).
The async/await
keywords
and the requestAnimationFrame
function make it especially easy to code this.
A simple description of the algorithm:
- Sample random points for a while (scan).
- Repaint the image (render).
- Finish if we sampled enough points, otherwise go back to step 1.
The scan function
This function repeats the following block of instructions a large number of times:
let c = random, z = 0, n = 0 while n < maxN and abs(z) <= 2: z = z^2 + c ++n if n < maxN: plot z iterates if scan has gone on for long enough: sleep until next repaint is done
This is the original Budddhabrot technique, where points are selected at random.
Only this time we will take a break every so often, to yield control back to the browser.
This is important, as we want the browser to stay responsive -for instance to button clicks, screen scroll, etc.-,
as well as to repaint the new pixel data scan
has produced.
The render function
This function paints the pixel data on the HTML canvas, and schedules a repaint for the next frame.
Using async/await
to implement both functions
The async
declaration allows us to define an asynchronous function.
async
functions always return a Promise
object,
and the actual return value will be passed on to the Promise
's resolve
method.
The await
operator takes a Promise
,
and pauses execution of the async
function from which it was called until the Promise
settles.
We can use this to quickly create our render loop:
async render() {
do {
this.renderBuddhabrot(); // paint Buddhabrot on HTML canvas
await this.sleep(); // wait until next repaint
} while (this.scanning); // keep going until scan is finished
}
Before implementing the scan
function, which is more verbose, let's take a look at how sleep
works.
Using requestAnimationFrame
to synchronize both functions
requestAnimationFrame
is a function provided by the browser (Web API).
It is used to schedule tasks for execution right before the next repaint, so this is exacly what we need to implement sleep
:
sleep() {
return new Promise(requestAnimationFrame);
}
Putting it all together
Let's create a Buddhabrot
class. We have already seen its render
and sleep
methods.
We will need a function start
to initiate the scan and render loops.
'use strict';
class Buddhabrot {
constructor(canvas) {
// TODO
}
start() {
this.scan();
this.render();
}
async scan() {
// TODO
}
async render() {
do {
this.renderBuddhabrot(); // paint Buddhabrot on HTML canvas
await this.sleep(); // wait until next repaint
} while (this.scanning); // keep going until scan is finished
}
renderBuddhabrot() {
// TODO
}
sleep() {
return new Promise(requestAnimationFrame);
}
}
It is important to know how async/await
works to understand what happens when start
is invoked.
First, scan
is called, and its body immediately starts running, no different than a regular function.
However, since it is async
, there are 2 ways execution can return to the calling start
function:
- A
return
statement, or the end of the function, is reached inscan
. - An
await
expression is reached inscan
.
Either way, the render
function will run next, and it will already have some new pixel data from the scan that just happened.
It will paint that, and upon finding the await
expression, yield control back to the start
function, which will in turn terminate.
From there, both the scan
and the render
functions will resume and pause execution at every browser repaint,
when requestAnimationFrame
resolves the Promises
we are waiting for and the next await
expressions are found, respectively.
We can easily see this using Google Chrome's DevTools timeline:
Now we are ready to implement the remaining functions for the Buddhabrot
class.
constructor(canvas) {
// Use this.image to paint on canvas
this.canvas = canvas;
this.context = this.canvas[0].getContext('2d');
this.image = this.context.createImageData(this.canvas.width(), this.canvas.height());
// Fit a circle centered at (-.25, 0i) and diameter 3.5 in the canvas
this.imgWidth = this.canvas.width();
this.imgHeight = this.canvas.height();
this.imgSize = this.imgWidth * this.imgHeight;
const ratio = this.imgWidth / this.imgHeight;
const side = 3.5;
if (ratio >= 1) {
this.height = side;
this.width = side * ratio;
} else {
this.width = side;
this.height = side / ratio;
}
this.center = [ -0.25, 0.0 ];
// render variables
this.maxN = 5000;
this.maxColor = 1;
this.itersR = new Float64Array(this.maxN); // z iterates
this.itersI = new Float64Array(this.maxN);
this.color = new Float64Array(this.imgSize); // pixel data
for (let i = 0; i < this.imgSize; ++i) {
this.color[i] = 0;
}
this.imageUpdated = false; // flags
this.scanning = false;
}
async scan() {
const halfWidth = this.width / 2.0; // precalculate vars
const halfHeight = this.height / 2.0;
const top = this.center[0] - halfHeight;
const left = this.center[1] - halfWidth;
const factor = this.imgWidth / this.width;
this.scanning = true; // scanning flag and current time
let t = performance.now();
let pointsLeft = this.imgSize * 100; // iterate a large number of times
while (pointsLeft-- > 0) {
const cr = top + Math.random() * this.height; // C = random
const ci = left + Math.random() * this.width;
let zr = 0.0; // Z = 0
let zi = 0.0;
let tr = 0.0; // tr and ti = zr and zi squared
let ti = 0.0;
let n = 0; // iterations counter
while (n < this.maxN && tr + ti <= 4.0) {
this.itersI[n] = zi = 2.0 * zr * zi + ci; // Z = Z^2 + C
this.itersR[n] = zr = tr - ti + cr;
tr = zr * zr;
ti = zi * zi;
++n;
}
if (n < this.maxN) { // if C is in M, plot all Z iterates
for (let i = 0; i < n; ++i) {
const x = Math.floor((this.itersR[i] - top) * factor);
const y = Math.floor((this.itersI[i] - left) * factor);
if (x >= 0 && x < this.imgHeight && y >= 0 && y < this.imgWidth) {
const z = x * this.imgWidth + y;
++this.color[z];
this.maxColor = Math.max(this.maxColor, this.color[z]);
}
}
this.imageUpdated = true;
}
if (performance.now() - t > 10) { // every 10 millis
t = await this.sleep(); // pause and wait for next repaint
}
}
this.scanning = false; // set flag once the scan is done
}
renderBuddhabrot() {
if (this.imageUpdated) {
this.imageUpdated = false;
const brightness = 2.5;
let offset = 0;
for (let i = 0; i < this.imgSize; ++i) {
const gray = Math.min(255, Math.round(brightness * 255 * this.color[i] / this.maxColor));
this.image.data[offset++] = gray;
this.image.data[offset++] = gray;
this.image.data[offset++] = gray;
this.image.data[offset++] = 255;
}
this.context.clearRect(0, 0, this.imgWidth, this.imgHeight);
this.context.putImageData(this.image, 0, 0);
}
}
Wrapping it up
Let's create a simple HTML page for the Buddhabrot.
<html>
<head>
<script>
'use strict';
class Buddhabrot {
// ...
}
// start animation on window load
window.addEventListener('load', event => {
const canvas = document.getElementById('buddhabrot-canvas');
const buddhabrot = new Buddhabrot(canvas);
buddhabrot.start();
});
</script>
</head>
<body>
<div style='width: 1000px; margin: 0 auto;'>
<canvas id='buddhabrot-canvas' width='1000' height='562'></canvas>
</div>
</body>
</html>
That's it! Hopefully this demonstrates how easy it is to get things like this done in javascript. On a personal note, I also found this javascript code to be remarkably clean, as opposed to the nasty callback chains that developers had no choice but to use just a few years ago.