Using javascript's async/await and requestAnimationFrame to render the Buddhabrot

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.

Your browser does not support canvas.

A simple description of the algorithm:

  1. Sample random points for a while (scan).
  2. Repaint the image (render).
  3. 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:

  1. A return statement, or the end of the function, is reached in scan.
  2. An await expression is reached in scan.

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:

Async/await Buddhabrot 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.