Make an animated Penrose triangle in Javascript + HTML

I created this project to learn a bit about the HTML canvas. Check it out on Github Github!

You may also want to read more about the Penrose triangle.

Let's do the layout first. We'll place the triangle in a square area inside the canvas and figure out some of the parameters.

layout

For this example, the canvas size will be 400x350. Initialization code for the HTML page:


  <canvas id='penrose-canvas' width='400' height='350'></canvas>
          

The PenroseTriangle class

Let's create a class PenroseTriangle to handle everything.


  'use strict';

  class PenroseTrinagle {}
          

Its constructor will take the canvas and an options object for custom configuration. We'll provide the following configuration parameters:

  • triangleEdge: length of triangle's edge in pixels.
  • cubeEdge: length of cube's edge in pixels.
  • cubesPerTriangleEdge: # of cubes per triangle edge.
  • padding: top and left padding in pixels.
  • loopFrames: # of frames per loop.
  • lineWidth: cube line's width in pixels.
  • lineColor: cube line's color.
  • cubeColors: colors for the cube's visible faces.

  constructor(canvas, {
      triangleEdge = 300,
      cubeEdge = 30,
      cubesPerTriangleEdge = 6,
      padding = [ 50.5, 0.5 ],
      loopFrames = 100,
      lineWidth = 3,
      lineColor = '#0041a3',
      cubeColors = [ '#4f9bf7', '#c0d8fc', '#87b7ff' ]
    } = {}) {
      
    // set options
    this.triangleEdge = triangleEdge;
    this.cubeEdge = cubeEdge;
    this.cubesPerTriangleEdge = cubesPerTriangleEdge;
    this.padding = [ padding[0], padding[1] ];
    this.loopFrames = loopFrames;
    this.lineWidth = lineWidth;
    this.lineColor = lineColor;
    this.cubeColors = [ cubeColors[0], cubeColors[1], cubeColors[2] ];
    
    // prepare graphics context
    this.canvas = canvas;
    this.context = this.canvas.getContext('2d');
    this.context.lineJoin = 'round';
    this.context.lineWidth = this.lineWidth;
    this.context.strokeStyle = this.lineColor;
    
    // precalculate lengths and cube coordinates
    this.triangleHeight = this.triangleEdge * Math.sqrt(3) / 2;
    this.ch = this.cubeEdge * Math.sqrt(3) / 2;
    this.chb = this.cubeEdge / 2;
    this.calculateCubesCoords();
    
    // start at frame 0
    this.frame = 0;
  }
          

What the constructor does is set instance variables, prepare the graphics context, and do some precalculations to make the render faster.


The illusion

To create a convincing optical illusion, we want the cubes to overlap. So, we need to draw them in order. However, if we do that, the last cube will overlap the first one in a way that breaks the effect.

Bad cubes

To fix this, we can just draw the overlapping side of the last cube first.

Good cubes


  // calcultes coordinates for the cubes with the established parameters
  calculateCubesCoords() {
    // triangle vertices
    let va = [                                           // bottom-left
        this.padding[0], 
        this.triangleEdge + this.padding[1] 
      ];                      
    let vb = [                                           // bottom-right
        this.triangleEdge + this.padding[0], 
        this.triangleEdge + this.padding[1] 
      ];  
    let vc = [                                           // top
        this.triangleEdge / 2.0 + this.padding[0], 
        this.triangleEdge - this.triangleHeight + this.padding[1] 
      ];  
  
    let minc = this.cubesPerTriangleEdge * this.loopFrames;
    this.finc = this.triangleEdge / minc;   // length increment for a frame
    this.vinc1 = [                          // increment vector along the right edge
        (vc[0] - vb[0]) / minc, 
        (vc[1] - vb[1]) / minc 
      ];
    this.vinc2 = [                          // vector increment along the left edge
        (va[0] - vc[0]) / minc, 
        (va[1] - vc[1]) / minc 
      ];
    
    // cubes' coordinates
    this.cubeMid = ((this.cubesPerTriangleEdge - 1) / 2) | 0;         // the 1st cube to draw
    let inc = this.triangleEdge / this.cubesPerTriangleEdge;          // separation between cubes
    this.v = new Float64Array(6 * this.cubesPerTriangleEdge);         // coordinates array
    this.vt = new Float64Array(6 * this.cubesPerTriangleEdge);        // coords array for render
    let j = 0;
    for (let i = this.cubeMid; i < this.cubesPerTriangleEdge; ++i) {  // bottom-right
      this.v[j++] = va[0] + inc * i;
      this.v[j++] = va[1];
    }
    let vdir = [                                      // right edge Euclidean vector
        (vc[0] - vb[0]) / this.cubesPerTriangleEdge, 
        (vc[1] - vb[1]) / this.cubesPerTriangleEdge 
      ];
    for (let i = 0; i < this.cubesPerTriangleEdge; ++i) {             // right edge
      this.v[j++] = vb[0] + vdir[0] * i;
      this.v[j++] = vb[1] + vdir[1] * i;  
    }
    vdir = [                                          // left edge vector
        (va[0] - vc[0]) / this.cubesPerTriangleEdge, 
        (va[1] - vc[1]) / this.cubesPerTriangleEdge 
      ];
    for (let i = 0; i < this.cubesPerTriangleEdge; ++i) {             // left edge
      this.v[j++] = vc[0] + vdir[0] * i;
      this.v[j++] = vc[1] + vdir[1] * i;
    }
    for (let i = 0; i < this.cubeMid; ++i) {                          // bottom-left
      this.v[j++] = va[0] + inc * i;
      this.v[j++] = va[1];
    }
  }
          

calculateCubesCoords essentially calculates the coordinates of each cube's center point, in the order we want to paint them.


The animation

Every time we ask the PenroseTriangle to repaint, its cubes will also move along their path. So the render function will look like this:


  render() {
    // calculate cube positions 
    this.updateCubesPositions();
    
    // clear canvas and draw
    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.drawTriangle();
    
    // increment current frame
    if (++this.frame == this.loopFrames) this.frame = 0;
  }
          

We also need a way to start the render loop:


  // 'renderLoop(timestamp)' is invoked at every repaint
  renderLoop = timestamp => {
    this.render();
    requestAnimationFrame(this.renderLoop);
  }
  
  // call 'start()' to begin the animation
  start() {
    requestAnimationFrame(this.renderLoop);
  }
          

The rest of the functions are just auxiliary; I'd make them private if javascript provided a good way.


  // calculate cubes' positions for the current frame
  updateCubesPositions() {
    // length increments for current frame
    let inc = this.finc * this.frame;
    let inc1X = this.vinc1[0] * this.frame;
    let inc1Y = this.vinc1[1] * this.frame;
    let inc2X = this.vinc2[0] * this.frame;
    let inc2Y = this.vinc2[1] * this.frame;
    
    let j = 0;
    for (let i = this.cubeMid; i < this.cubesPerTriangleEdge; ++i) {  // bottom-right
      this.vt[j] = this.v[j++] + inc;
      this.vt[j] = this.v[j++];
    }
    for (let i = 0; i < this.cubesPerTriangleEdge; ++i) {             // right edge
      this.vt[j] = this.v[j++] + inc1X;
      this.vt[j] = this.v[j++] + inc1Y;
    }
    for (let i = 0; i < this.cubesPerTriangleEdge; ++i) {             // left edge
      this.vt[j] = this.v[j++] + inc2X;
      this.vt[j] = this.v[j++] + inc2Y;   
    }  
    for (let i = 0; i < this.cubeMid; ++i) {                          // bottom-left
      this.vt[j] = this.v[j++] + inc;
      this.vt[j] = this.v[j++];          
    }
  }
  
  // draw the triangle
  drawTriangle() {
    this.drawCubePart1(this.vt[0], this.vt[1]);
    let j = 2;
    while (j < this.vt.length) {
      this.drawCube(this.vt[j++], this.vt[j++]);
    }
    this.drawCubePart2(this.vt[0], this.vt[1]);
  }

  // draw the whole cube, centered at (x, y)
  drawCube(x, y) {
    this.drawCubePart1(x, y);
    this.drawCubePart2(x, y);
  }
  
  // draw face 0
  drawCubePart1(x, y) {
    this.drawCubeSide(
        x, y, 
        x + this.chb, y - this.ch,
        x + this.cubeEdge, y,
        x + this.chb, y + this.ch,
        this.cubeColors[0]
      );
  }
  
  // draw faces 1 and 2
  drawCubePart2(x, y) {
    this.drawCubeSide(
        x, y, 
        x - this.cubeEdge, y,
        x - this.chb, y - this.ch,
        x + this.chb, y - this.ch,
        this.cubeColors[1]
      );
    this.drawCubeSide(
        x, y, 
        x + this.chb, y + this.ch,
        x - this.chb, y + this.ch,
        x - this.cubeEdge, y,
        this.cubeColors[2]
      );
  }
  
  drawCubeSide(x0, y0, x1, y1, x2, y2, x3, y3, color) {
    this.context.beginPath();
    this.context.moveTo(x0, y0);
    this.context.lineTo(x1, y1);
    this.context.lineTo(x2, y2);
    this.context.lineTo(x3, y3);
    this.context.closePath();
    this.context.fillStyle = color;
    this.context.stroke();
    this.context.fill();
  }
          

Getting it running

To wrap things up, we need to start the animation once the window loads:


  window.onload = () => {
    const canvas = document.getElementById('penrose-canvas');
    const penroseTriangle = new PenroseTrinagle(canvas);
    penroseTriangle.start();
  };