Sorry, say again? Can’t hear you over the sound of my computer’s fans 🌬.

I ducked into playing with some JS+Canvas on and off over the past few weeks. I wrote a script one evening about a curious submarine captain (who should not be a submarine captain) and wanted to build an interactive narrative for the script. You can check out the story here, but it maaaaaaay not be suuuper performant. I’m going to dig into why in this post.

So take a gander if thou daresth melt thy cpu. When you return I’ll outline what’s happening.

Cool and all, but it turns out that your computer might melt while enjoying the story. Your fans will likely boot up and sometimes things lag, unfortunately.

I think most of the problem is that I’m rendering a GIANT canvas. The canvas itself is stretched to 16x the height of the users’ browser’s innerHeight, and as you scroll the ocean gets darker and darker via a canvas gradient.

// from ocean.js

function draw() {
  let w = this.oceanWidth;
  let h = this.oceanHeight;
  // Build gradient
  let gradient = this.ctx.createLinearGradient(0, 0, 0, h);
  gradient.addColorStop(0.0, this.oceanTopGradient);
  gradient.addColorStop(0.8, this.oceanBtmGradient);
  this.ctx.fillStyle = gradient;
  this.ctx.fillRect(0, this.skyHeight, w, h);
  if (!this.dev) {
    this.bubble.draw();
  }
  this.jellys.forEach(j => {
    j.draw(this.ctx);
  });
}

I think my first implementation was naivé, but I’m not super sure of other solutions yet. The first possible fix would be to not make the canvas 12,000~ px tall (ha) and just have it fill the innerHeight of the screen, all the while slowly darkening the ocean’s colour as the user scrolls.

I took a crack at doing that, but found that there was some obvious banding on changing the colour of the canvas background on scroll (even without throttling the scroll event).

Moving on, other performance issues were caused by rendering sea creatures and bubbles when they weren’t immediately visible. That was a fairly easy fix though:

/**
 * Render a bubble, animating it's pos X and Y.
 * Only renders a bubble if it's in viewport / in the ocean.
 * @param {number} linkX - links Xposition to external input.
 * @param {number} linkY - links Yposition to external input.
 * @todo enable delay to render (esp for sub bubbles)
 */
function draw(linkX, linkY) {
  for (var i = 0; i < this.bubbles.length; i++) {
    let b = this.bubbles[i];
    b.pos.y = b.pos.y - b.pos.ySpeed;
    b.pos.x = b.pos.x + b.pos.xSpeed;

    if (
      b.pos.y > this.g.scrollPos + b.radius * 2 &&
      b.pos.y > this.g.skyHeight + b.radius
    ) {
      this.render(b);
    } else {
      b.pos.y = linkY || this._setBubbleStart();
      b.pos.x = linkX || b.pos.x;
    }
  }
}

Those are some pretty ugly / long if statements, but it worked for that case. I took a crack at using the chrome dev tools to assess where the bulk of the rendering was. Here’s a screen shot of the self/total time in the Chrome profiler.

Twirling down those frame and following the call stack didn’t really point to anything other than requestAnimationFrame it seems. I’m still not sure exactly sure what Composite Layers is referring to, although according to the Chrome profiler docs:

Compositing is where the painted parts of the page are put together for displaying on screen. For the most part, if you stick to compositor-only properties and avoid paint altogether, you should see a major improvement in performance, but you need to watch out for excessive layer counts (see also Stick to compositor-only properties and manage layer count).

Flipping over to the call tree reveals which functions are being called the most. Super helpful. Looks like those jellyfish are the culprit:

Oh no. I know what’s happening here. Let’s take a look at the function for rendering a jellyfish:

  draw() {
    let ctx = this.ctx;
    let xPos = this.xPos;
    let yPos = this.yPos;
    ctx.save();
    ctx.translate(xPos, yPos);
    ctx.rotate(Math.PI);
    ctx.beginPath();
    ctx.arc(0, 0, this.radius, 0, (2 * Math.PI) / 2);
    ctx.strokeStyle = jellyColour;
    ctx.stroke();
    ctx.restore();
    this.legs.forEach((leg, i) => {
      let firstLegMin = xPos - this.radius * 0.5;
      let lastLegMax = xPos + this.radius * 0.6;
      let legAttach = util.map(i, 0, this.legs.length, firstLegMin, lastLegMax);
      leg.draw(ctx, legAttach, yPos);
    });
    let rndYSeed = Math.sin((Date.now() * this.seed2) / 1000);
    let advanceJellyYpos = util.map(rndYSeed, 0, 1, 2, 5);
    this.yPos = this.yPos - advanceJellyYpos * 0.3;
  }

Oh, right, I’m rendering 8 legs for each jellyfish. And what’s this? The jellyfish seem to be rendering even when the user is offscreen. Looks like I forgot to block rendering if the jellyfish isn’t actually on (or close to) the viewport yet.

Thanks dev tools, I’m off to fix some code. Oh, and then get this thing working outside of Chrome (that’ll warrant another post, I imagine).

Links / Docs