1

I built a fractal clock using canvas 2dContext, but found I was getting performance issues when the layers increased above 6. In an attempt to improve performance and increase the layer limit I rebuilt the same clock using PIXI.js.

The problem I have is that the performance improvement was minimal - on my desktop it went from a maximum of 6 layers with 2dContext to 7 layers with PIXI.

Is there anything obviously wrong with how I'm approaching the PIXI solution that could be limiting its performance?

I suspect the best solution is to move all of the calculations into shaders, and start from boiler plate webgl.

Code to save clicking links:

const config = {
  numLevels: 7, // bottleneck here, higher means poor performance
  levelAngleOffset: 0,
  levelScale: .7,
  levelOpacityMultiplier: .7,
  levelHueRotation: 40,

  initialRadius: .4,
  initialOpacity: 1,
  initialHue: 0,

  dialOpacity: .1,
  handOpacity: 1,

  secondHandLength: .9,
  secondHandWidth: .015,
  minuteHandLength: .8,
  minuteHandWidth: .03,
  hourHandLength: .6,
  hourHandWidth: .05,

  // set by code
  width: 0,
  height: 0,
};

// https://stackoverflow.com/a/44134328/3282374
function hslToHex(h, s, l) {
  l /= 100;
  const a = s * Math.min(l, 1 - l) / 100;
  const f = n => {
    const k = (n + h / 30) % 12;
    const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
    return Math.round(255 * color).toString(16).padStart(2, '0');   // convert to Hex and prefix "0" if needed
  };
  return `0x${f(0)}${f(8)}${f(4)}`;
}

class Hand extends PIXI.Graphics {
  constructor(x,y,length, rotation, width, opacity) {
    super();
    
    this.length = length;
    
    this.lineStyle({
      width: width, 
      color: 0xFFFFFF,
      alpha: config.handOpacity * opacity,
      cap: PIXI.LINE_CAP.ROUND
    });
    this.moveTo(0, 0);
    this.lineTo(0, -length);
    this.rotation = rotation;
    this.x = x;
    this.y = y;
    this.endFill();
  }
  
  setClock(clock) {
    this.hasClock = true;
    this.clock = clock;
    
    clock.x = 0;
    clock.y = this.length;

    this.addChild(clock);
  }

  update(rotation, angles) {
    this.rotation = rotation;
    
    if (this.hasClock) {
      this.clock.update(angles, 0, -this.length, rotation);
    }
  }
}

class Clock extends PIXI.Graphics {
  constructor(cx, cy, radius, rotation, hue, opacity) {
    super();

    this.lineStyle(0);     
    this.beginFill(hslToHex(hue, 100, 50), opacity * config.dialOpacity);
    this.drawCircle(0, 0, radius);
    this.endFill();
    this.x = cx;
    this.y = cy;
    this.rotation = rotation;

    this.hourHand = new Hand(0, 0, config.hourHandLength * radius, 0, config.hourHandWidth * radius, opacity);
    this.minuteHand = new Hand(0, 0, config.minuteHandLength * radius, 0, config.minuteHandWidth * radius, opacity);
    this.secondHand = new Hand(0, 0, config.secondHandLength * radius, 0, config.secondHandWidth * radius, opacity);

    this.addChild(this.hourHand, this.minuteHand, this.secondHand);
  }
  
  setChildClocks(hour, minute, second) {
    this.hourHand.setClock(hour);
    this.minuteHand.setClock(minute);
    this.secondHand.setClock(second);
  }

  update(angles, cx, cy, rotation) {
    this.x = cx;
    this.y = cy;
    this.rotation = rotation;

    this.hourHand.update(angles.hour, angles);
    this.minuteHand.update(angles.minute, angles);
    this.secondHand.update(angles.second, angles);
  }
}

function getTimeAngles() {
  const time = new Date();

  const millisecond = time.getMilliseconds();
  const second = time.getSeconds() + millisecond / 1000;
  const minute = time.getMinutes() + second / 60;
  const hour = time.getHours() % 12 + minute / 60;

  const hourAngle = Math.PI * 2 * hour / 12;
  const minuteAngle = Math.PI * 2 * minute / 60;
  const secondAngle = Math.PI * 2 * second / 60;

  return {
    hour: hourAngle,
    minute: minuteAngle,
    second: secondAngle
  };
}

let clock;

function initClock() {
  const center = Math.min(config.width, config.height) / 2;
  clock = new Clock(
    center, 
    center, 
    center * config.initialRadius, 
    Math.PI / 2,     
    config.initialHue, 
    config.initialOpacity);

  let level = 0;
  let clocks = [clock];
  while (level < config.numLevels) {
    level++;
    const nextClocks = [];
    for (const parent of clocks) {
      const children = [];
      for (var i  = 0; i < 3; i++) {
        const child = new Clock(
          center, 
          center, 
          center * config.initialRadius * config.levelScale**level, 
          0, 
          config.initialHue + config.levelHueRotation * level, 
          config.initialOpacity * config.levelOpacityMultiplier ** level);
        
        children.push(child);
      }
      parent.setChildClocks(...children);
      nextClocks.push(...children);
    }
    clocks = nextClocks;
  }
}

function step() {
  const angles = getTimeAngles();
  clock.update(angles, config.width/2, config.height/2, 0);
}

function init() {
  PIXI.utils.skipHello();
  
  const app = new PIXI.Application({ antialias: true, transparent: true });
  document.body.appendChild(app.view);

  const canvas = document.querySelector('canvas');

  const resize = () => {
    const {width, height } = canvas.getBoundingClientRect();
    config.width = width;
    config.height = height;
    app.renderer.resize(width, height);
  }

  window.addEventListener('resize', resize);
  resize();

  initClock();

  app.stage.addChild(clock);
  app.ticker.add(step);
}

init();
html, body {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
}

body {
  display: flex;
  align-items: center;
  justify-content: center;
  background: #222;
}

canvas {
  width: 99vw;
  height: 99vh;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/5.3.9/pixi.min.js"></script>

1 Answers1

0

Is there anything obviously wrong with how I'm approaching the PIXI solution that could be limiting its performance?

Your performance issue comes from CPU, not the GPU. For depth 8, drawing a single frame takes 60ms on my PC (Intel Core i7-10700F) because of sheer amount of work the CPU has to do (traverse the ever-expanding tree in depth).

enter image description here

Switching to Pixi won't help, because you do the same amount of work on the CPU. To make your fractal clock work nicely for bigger depths, you'll have to do the programming on the GPU. WebGL allows you to do this by writing shaders.

You'll need to re-think your procedural sequential algorithm and transform it into a fragment shader, whose instructions are executed in parallel for many pixels (fragments) at a time. This is not a trivial task and requires some practice.

However, depending on the way you want your fractal to look, you might get away with still using canvas. Instead of re-drawing each branch from scratch, you could draw one level only, and then copy this image data to other positions, along with translating/rotating/scaling. See getImageData for details.

However, if your deeper nodes of the fractal require manipulation which cannot be achieved with affine transformations or simple pixel-based operations, you'll have to do with the custom fragment shader solution that I have described.

For inspiration about what you can do with shaders in WebGL, see ShaderToy. There's a section for fractals as well. Here's a clock similar to yours (pictured below).

enter image description here

Lazar Ljubenović
  • 18,976
  • 10
  • 56
  • 91
  • Yeah I'm semi-familiar with writing shaders, as mentioned in the question I figured that'd be the best approach. I had thought about trying duplicated pixel clipping, but rejected it as I wanted the colours to change at each level and looping through the pixelData seemed a little heavy. Might be worth investigating though. –  May 14 '21 at 09:47
  • The other problem with the pixel clipping approach is the placement and rotation for each clock at each level would still need to be recalculated each step. So maybe not a performance improvement after all. –  May 14 '21 at 09:51