7

I am new to HTML5 canvas and looking to make a few circles move in random directions for a fancy effect on my website.

I have noticed that when these circles move, the CPU usage is very high. When there is just a couple of circles moving it is often ok, but when there is around 5 or more it starts to be a problem.

Here is a screenshot of profiling this in Safari for a few seconds with 5 circles.

Profile Results

Here is the code I have so far for my Circle component:

export default function Circle({ color = null }) {
  useEffect(() => {
    if (!color) return

    let requestId = null
    let canvas = ref.current
    let context = canvas.getContext("2d")

    let ratio = getPixelRatio(context)
    let canvasWidth = getComputedStyle(canvas).getPropertyValue("width").slice(0, -2)
    let canvasHeight = getComputedStyle(canvas).getPropertyValue("height").slice(0, -2)

    canvas.width = canvasWidth * ratio
    canvas.height = canvasHeight * ratio
    canvas.style.width = "100%"
    canvas.style.height = "100%"

    let y = random(0, canvas.height)
    let x = random(0, canvas.width)
    const height = random(100, canvas.height * 0.6)

    let directionX = random(0, 1) === 0 ? "left" : "right"
    let directionY = random(0, 1) === 0 ? "up" : "down"

    const speedX = 0.1
    const speedY = 0.1

    context.fillStyle = color

    const render = () => {
      //draw circle
      context.clearRect(0, 0, canvas.width, canvas.height)
      context.beginPath()
      context.arc(x, y, height, 0, 2 * Math.PI)

      //prevent circle from going outside of boundary
      if (x < 0) directionX = "right"
      if (x > canvas.width) directionX = "left"
      if (y < 0) directionY = "down"
      if (y > canvas.height) directionY = "up"

      //move circle
      if (directionX === "left") x -= speedX
      else x += speedX
      if (directionY === "up") y -= speedY
      else y += speedY

      //apply color
      context.fill()

      //animate
      requestId = requestAnimationFrame(render)
    }

    render()

    return () => {
      cancelAnimationFrame(requestId)
    }
  }, [color])

  let ref = useRef()
  return <canvas ref={ref} />
}

Is there a more performant way to draw and move circles using canvas?

When they do not move, the CPU usage starts off around ~3% then drops to less than 1%, and when I remove the circles from the DOM, the CPU usage is always less than 1%.

I understand it's often better to do these types of animations with CSS (as I believe it uses the GPU rather than the CPU), but I couldn't work out how to get it to work using the transition CSS property. I could only get the scale transformation to work.

My fancy effect only looks "cool" when there are many circles moving on the screen, hence looking for a more performant way to draw and move the circles.

Here is a sandbox for a demo: https://codesandbox.io/s/async-meadow-vx822 (view in chrome or safari for best results)

Charklewis
  • 4,427
  • 4
  • 31
  • 69
  • If you dont expect the canvas to change between calls to your function, there's quite a bit that you're doing in there that will **produce the same result each time its run**. Things like that are best calculated once. Most of the code up until `let y = canvas.height;` seem likely to return the same results. But in any case, much of this discussion is moot if you've not profiled your code yet. The devtools in the browser can help you here and tell you exactly how much time is consumed by any piece of your code. No need to guess when you can measure! – enhzflep May 25 '20 at 09:48
  • 1
    add a snippet / fiddle or something runnable – Mechanic May 25 '20 at 09:57
  • I have updated the question with a sandbox. – Charklewis May 25 '20 at 10:14
  • @Charklewis why you don't consider using div's for circles and animating using CSS animations(via `animate` method)? For me it is way more performant than canvas for your particular task. – Alex Jun 13 '20 at 20:05
  • @Aleksey that's a great idea, and likely to be more performant. When I get a chance I will try this out. – Charklewis Jun 13 '20 at 20:11
  • @Charklewis Just replace Circle.js file with my solution in your originally posted code to try. I am curious what kind of performance you will get on your machine. Please let me know results if you will find chance to test this. – Alex Jun 13 '20 at 20:17

5 Answers5

6

Here is a slightly different approach to combine circles and background to have only one canvas element to improve rendered dom.

This component uses the same colours and sizes with your randomization logic but stores all initial values in a circles array before rendering anything. render functions renders background colour and all circles together and calculates their move in each cycle.

export default function Circles() {
  useEffect(() => {
    const colorList = {
      1: ["#247ba0", "#70c1b3", "#b2dbbf", "#f3ffbd", "#ff1654"],
      2: ["#05668d", "#028090", "#00a896", "#02c39a", "#f0f3bd"]
    };
    const colors = colorList[random(1, Object.keys(colorList).length)];
    const primary = colors[random(0, colors.length - 1)];
    const circles = [];

    let requestId = null;
    let canvas = ref.current;
    let context = canvas.getContext("2d");

    let ratio = getPixelRatio(context);
    let canvasWidth = getComputedStyle(canvas)
      .getPropertyValue("width")
      .slice(0, -2);
    let canvasHeight = getComputedStyle(canvas)
      .getPropertyValue("height")
      .slice(0, -2);

    canvas.width = canvasWidth * ratio;
    canvas.height = canvasHeight * ratio;
    canvas.style.width = "100%";
    canvas.style.height = "100%";

    [...colors, ...colors].forEach(color => {
      let y = random(0, canvas.height);
      let x = random(0, canvas.width);
      const height = random(100, canvas.height * 0.6);

      let directionX = random(0, 1) === 0 ? "left" : "right";
      let directionY = random(0, 1) === 0 ? "up" : "down";

      circles.push({
        color: color,
        y: y,
        x: x,
        height: height,
        directionX: directionX,
        directionY: directionY
      });
    });

    const render = () => {
      context.fillStyle = primary;
      context.fillRect(0, 0, canvas.width, canvas.height);

      circles.forEach(c => {
        const speedX = 0.1;
        const speedY = 0.1;

        context.fillStyle = c.color;
        context.beginPath();
        context.arc(c.x, c.y, c.height, 0, 2 * Math.PI);
        if (c.x < 0) c.directionX = "right";
        if (c.x > canvas.width) c.directionX = "left";
        if (c.y < 0) c.directionY = "down";
        if (c.y > canvas.height) c.directionY = "up";
        if (c.directionX === "left") c.x -= speedX;
        else c.x += speedX;
        if (c.directionY === "up") c.y -= speedY;
        else c.y += speedY;
        context.fill();
        context.closePath();
      });

      requestId = requestAnimationFrame(render);
    };

    render();

    return () => {
      cancelAnimationFrame(requestId);
    };
  });

  let ref = useRef();
  return <canvas ref={ref} />;
}

You can simply replace all bunch of circle elements and background style with this one component in your app component.

export default function App() {
  return (
    <>
      <div className="absolute inset-0 overflow-hidden">
          <Circles />
      </div>
      <div className="backdrop-filter-blur-90 absolute inset-0 bg-gray-900-opacity-20" />
    </>
  );
}
cerkiner
  • 1,906
  • 10
  • 19
  • This technique worked great. I made an early mistake when trying this solution which was to put the requestAnimationFrame inside the circles.forEach loop, causing major performance issues. After fixing this bug, I was able to bring down the CPU usage down to a consistent ~3%. A major saving of about 27%! Thank you. – Charklewis Jun 13 '20 at 16:41
  • To add, I also reduced the FPS for further performance gains using the technique in Luke's answer to this question: https://stackoverflow.com/questions/19764018/controlling-fps-with-requestanimationframe. – Charklewis Jun 13 '20 at 16:45
1

I tried to assemble your code as possible, it seems you have buffer overflow (blue js heap), you need to investigate here, these are the root cause.

The initial approach is to create circle just once, then animate the child from parent, by this way you avoid intensive memory and CPU computing.

Add how many circles by clicking on the canvas, canvas credit goes to Martin

Update

Following for alexander discussion it is possible to use setTimeout, or Timeinterval (Solution 2)

Soltion #1

App.js

import React from 'react';
import { useCircle } from './useCircle';
import './App.css';

const useAnimationFrame = callback => {
  // Use useRef for mutable variables that we want to persist
  // without triggering a re-render on their change
  const requestRef = React.useRef();
  const previousTimeRef = React.useRef();

  const animate = time => {
    if (previousTimeRef.current != undefined) {
      const deltaTime = time - previousTimeRef.current;
      callback(deltaTime)
    }
    previousTimeRef.current = time;
    requestRef.current = requestAnimationFrame(animate);
  }

  React.useEffect(() => {
    requestRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(requestRef.current);
  }, []); // Make sure the effect runs only once
}
function App() {

  const [count, setCount] = React.useState(0)
  const [coordinates, setCoordinates, canvasRef, canvasWidth, canvasHeight, counts] = useCircle();
  const speedX = 1 // tunne performance by changing this
  const speedY = 1 // tunne performance by changing this
  const requestRef = React.useRef();
  const previousTimeRef = React.useRef();



  const handleCanvasClick = (event) => {
    // on each click get current mouse location 
    const currentCoord = { x: event.clientX, y: event.clientY ,directionX:"right",directionY:"down"};
    // add the newest mouse location to an array in state 
    setCoordinates([...coordinates, currentCoord]);
    // query.push(currentCoord)
    //query.push(currentCoord)
  };

  const move = () => {
    let q = [...coordinates]
    q.map(coordinate => { return { x: coordinate.x + 10, y: coordinate.y + 10 } })
    setCoordinates(q)
  }

  const handleClearCanvas = (event) => {
    setCoordinates([]);
  };

  const animate = time => {

//if (time % 2===0){

    setCount(time)
    if (previousTimeRef.current != undefined) {
      const deltaTime = time - previousTimeRef.current;

setCoordinates(coordinates => coordinates.map((coordinate)=> {

let x=coordinate.x;
let y=coordinate.y;

let directionX=coordinate.directionX

let directionY=coordinate.directionY


  if (x < 0) directionX = "right"
  if (x > canvasWidth) directionX = "left"
  if (y < 0) directionY = "down"
  if (y > canvasHeight) directionY = "up"


  if (directionX === "left") x -= speedX
  else x += speedX
  if (directionY === "up") y -= speedY
  else y += speedY

  return { x:x,y:y,directionX:directionX,directionY:directionX} 


}))

   // }
  }
    previousTimeRef.current = time;
    requestRef.current = requestAnimationFrame(animate);
  }


  React.useEffect(() => {
    requestRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(requestRef.current);
  }, []); // Make sure the effect runs only once

  return (
    <main className="App-main" >
      <div>{Math.round(count)}</div>

      <canvas
        className="App-canvas"
        ref={canvasRef}
        width={canvasWidth}
        height={canvasHeight}
        onClick={handleCanvasClick}

      />

      <div className="button" >
        <button onClick={handleClearCanvas} > CLEAR </button>
      </div>
    </main>
  );

};

export default App;

userCircle.js

import React, { useState, useEffect, useRef } from 'react';

var circle = new Path2D();
circle.arc(100, 100, 50, 0, 2 * Math.PI);
const SCALE = 1;
const OFFSET = 80;
export const canvasWidth = window.innerWidth * .5;
export const canvasHeight = window.innerHeight * .5;

export const counts=0;

export function draw(ctx, location) {
  console.log("attempting to draw")
  ctx.fillStyle = 'red';
  ctx.shadowColor = 'blue';
  ctx.shadowBlur = 15;
  ctx.save();
  ctx.scale(SCALE, SCALE);
  ctx.translate(location.x / SCALE - OFFSET, location.y / SCALE - OFFSET);
  ctx.rotate(225 * Math.PI / 180);
  ctx.fill(circle);
  ctx.restore();

};

export function useCircle() {
  const canvasRef = useRef(null);
  const [coordinates, setCoordinates] = useState([]);

  useEffect(() => {
    const canvasObj = canvasRef.current;
    const ctx = canvasObj.getContext('2d');
    ctx.clearRect(0, 0, canvasWidth, canvasHeight);
    coordinates.forEach((coordinate) => {
      draw(ctx, coordinate)
    }
    );
  });

  return [coordinates, setCoordinates, canvasRef, canvasWidth, canvasHeight,counts];
}

Soltion #2 Using Interval

IntervalExample.js (app) 9 sample circle

import React, { useState, useEffect } from 'react';

import Circlo from './Circlo'


const IntervalExample = () => {
  const [seconds, setSeconds] = useState(0);

  const [circules, setCircules] = useState([]);


  let arr =[
    {x:19,y:15, r:3,directionX:'left',directionY:'down'},
    {x:30,y:10,r:4,directionX:'left',directionY:'down'},
    {x:35,y:20,r:5,directionX:'left',directionY:'down'},
    {x:0,y:15, r:3,directionX:'left',directionY:'down'},
    {x:10,y:30,r:4,directionX:'left',directionY:'down'},
    {x:20,y:50,r:5,directionX:'left',directionY:'down'},
    {x:70,y:70, r:3,directionX:'left',directionY:'down'},
    {x:80,y:80,r:4,directionX:'left',directionY:'down'},
    {x:10,y:20,r:5,directionX:'left',directionY:'down'},
  ]



const reno =(arr)=>{
  const table = Array.isArray(arr) && arr.map(item => <Circlo x={item.x} y={item.y} r={item.r} />);
return(table)
}
  const speedX = 0.1 // tunne performance by changing this
  const speedY = o.1 // tunne performance by changing this

  const move = (canvasHeight,canvasWidth) => {


 let xarr=   arr.map(((coordinate)=> {

      let x=coordinate.x;
      let y=coordinate.y;

      let directionX=coordinate.directionX
      let directionY=coordinate.directionY
      let r=coordinate.r
        if (x < 0) directionX = "right"
        if (x > canvasWidth) directionX = "left"
        if (y < 0) directionY = "down"
        if (y > canvasHeight) directionY = "up"
        if (directionX === "left") x -= speedX
        else x += speedX
        if (directionY === "up") y -= speedY
        else y += speedY

        return { x:x,y:y,directionX:directionX,directionY:directionY,r:r} 

      }))
      return xarr;

  }

  useEffect(() => {
    const interval = setInterval(() => {

     arr =move(100,100)

      setCircules( arr)
      setSeconds(seconds => seconds + 1);


    }, 10);
    return () => clearInterval(interval);
  }, []);

  return (
    <div className="App">
      <p>
        {seconds} seconds have elapsed since mounting.
      </p>


<svg viewBox="0 0 100 100">
{ reno(circules)}
  </svg>     

    </div>
  );
};

export default IntervalExample;

Circlo.js

import React from 'react';

export default function Circlo(props) {

    return (

        <circle cx={props.x} cy={props.y} r={props.r} fill="red" />
    )

}

enter image description here

enter image description here

enter image description here

0xFK
  • 2,433
  • 32
  • 24
  • I downvoted before you added any code. If you take a look at the profiler results posted by author it shows that main cause is re-painting rather than javascript performance/memory problem. While your optimization is valid it will not provide any noticeable/significant enough performance boost. – Alex Jun 13 '20 at 10:45
0

First of all, nice effect!

Once said that, I read carefully your code and it seems fine. I'm afraid that the high CPU load is unavoidable with many canvas and transparencies...

To optimize your effect you could try two ways:

  1. try to use only one canvas
  2. try use only CSS, at the end you are using canvas only to draw a filled circle with color from a fixed set: you could use images with pre-drawn same circles and use more or less the same code to simply chage style properties of the images

Probably with a shader you'll be able to obtain the same effect with high CPU save, but unfortunately I'm not proficient on shaders so I can't give you any relevant hint.

Hope I given you some ideas.

Daniele Ricci
  • 15,422
  • 1
  • 27
  • 55
0

I highly recommend reading the article Optimizing the Canvas on the Mozilla Developer's Network website. Specifically, without getting into actual coding, it is not advisable to perform expensive rendering operations repeatedly in the canvas. Alternatively, you can create a virtual canvas inside your circle class and perform the drawing on there when you initially create the circle, then scale your Circle canvas and blit it the main canvas, or blit it and then scale it on the canvas you are blitting to. You can use CanvasRenderingContext2d.getImageData and .putImageData to copy from one canvas to another. How you implement it is up to you, but the idea is not to draw primitives repeatedly when drawing it once and copying the pixel data is pretty fast by comparison.

Update

I tried messing around with your example but I don't have any experience with react so I'm not exactly sure what's going on. Anyway, I cooked up a pure Javascript example without using virtual canvasses, but rather drawing to a canvas, adding it to the document, and animating the canvas itself inside the constraints of the original canvas. This seems to work the fastest and smoothest (Press c to add circles and d to remove circles):

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Buffer Canvas</title>
        <style>
            body, html {
                background-color: aquamarine;
                padding: 0;
                margin: 0;
            }
            canvas {
                border: 1px solid black;
                padding: 0;
                margin: 0;
                box-sizing: border-box;
            }
        </style>
        <script>
            function randInt(min, max) {
                return min + Math.floor(Math.random() * max);
            }
            class Circle {
                constructor(x, y, r) {
                    this._canvas = document.createElement('canvas');
                    this.x = x;
                    this.y = y;
                    this.r = r;
                    this._canvas.width = 2*this.r;
                    this._canvas.height = 2*this.r;
                    this._canvas.style.width = this._canvas.width+'px';
                    this._canvas.style.height = this._canvas.height+'px';
                    this._canvas.style.border = '0px';
                    this._ctx = this._canvas.getContext('2d');
                    this._ctx.beginPath();
                    this._ctx.ellipse(this.r, this.r, this.r, this.r, 0, 0, Math.PI*2);
                    this._ctx.fill();
                    document.querySelector('body').appendChild(this._canvas);
                    const direction = [-1, 1];
                    this.vx = 2*direction[randInt(0, 2)];
                    this.vy = 2*direction[randInt(0, 2)];
                    this._canvas.style.position = "absolute";
                    this._canvas.style.left = this.x + 'px';
                    this._canvas.style.top = this.y + 'px';
                    this._relativeElem = document.querySelector('body').getBoundingClientRect();
                }
                relativeTo(elem) {
                    this._relativeElem = elem;
                }
                getImageData() {
                    return this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);
                }
                right() {
                    return this._relativeElem.left + this.x + this.r;
                }
                left() {
                    return this._relativeElem.left + this.x - this.r;
                }
                top() {
                    return this._relativeElem.top + this.y - this.r
                }
                bottom() {
                    return this._relativeElem.top + this.y + this.r;
                }
                moveX() {
                    this.x += this.vx;
                    this._canvas.style.left = this.x - this.r  + 'px';
                }
                moveY() {
                    this.y += this.vy;
                    this._canvas.style.top = this.y - this.r + 'px';
                }
                move() {
                    this.moveX();
                    this.moveY();
                }
                reverseX() {
                    this.vx = -this.vx;
                }
                reverseY() {
                    this.vy = -this.vy;
                }
            }

            let canvas, ctx, width, height, c, canvasRect;

            window.onload = preload;
            let circles = [];

            function preload() {
                canvas = document.createElement('canvas');
                canvas.style.backgroundColor = "antiquewhite";
                ctx = canvas.getContext('2d');
                width = canvas.width = 800;
                height = canvas.height = 600;
                document.querySelector('body').appendChild(canvas);
                canvasRect = canvas.getBoundingClientRect();
                document.addEventListener('keypress', function(e) {
                   if (e.key === 'c') {
                       let radius = randInt(10, 50);
                       let c = new Circle(canvasRect.left + canvasRect.width / 2 - radius, canvasRect.top + canvasRect.height / 2 - radius, radius);
                       c.relativeTo(canvasRect);
                       circles.push(c);
                   } else if (e.key === 'd') {
                       let c = circles.pop();
                       c._canvas.parentNode.removeChild(c._canvas);
                   }
                });
                render();
            }

            function render() {
                // Draw
                ctx.clearRect(0, 0, canvas.width, canvas.height);
                circles.forEach((c) => {
                    // Check position and change direction if we hit the edge
                    if (c.left() <= canvasRect.left || c.right() >= canvasRect.right) {
                        c.reverseX();
                    }
                    if (c.top() <= canvasRect.top || c.bottom() >= canvasRect.bottom) {
                        c.reverseY();
                    }

                    // Update position for next render
                    c.move();
                });

                requestAnimationFrame(render);
            }
        </script>
    </head>
    <body>

    </body>
</html>
leisheng
  • 340
  • 1
  • 8
  • Would it be possible to share an example of what you mean by a virtual canvas? I can see in the article they are referring to an "off-screen canvas". Are they equivalent ideas? – Charklewis Jun 12 '20 at 12:38
  • 1
    Yes, they are the same. You just create a canvas with document.createElement('canvas') but never add the canvas to the DOM. That's what they mean by off-screen canvas. The canvas is stored in memory and you can draw on it, copy sections from it or the entire thing and paste it on your visible canvas. I can try to work up an example and add it to my answer later on today. – leisheng Jun 12 '20 at 18:20
0

Cool effect! I was really surprised that solution proposed by @Sam Erkiner did not perform that much better for me than your original. I would have expected single canvas to be way more efficient. I decided to try this out with new animation API and pure DOM elements and see how well that works. Here is my solution(Only changed Circle.js file):

import React, { useEffect, useRef, useMemo } from "react";
import { random } from "lodash";

const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;  

export default function Circle({ color = null }) {
  let ref = useRef();

  useEffect(() => {
    let y = random(0, HEIGHT);
    let x = random(0, WIDTH);
    let directionX = random(0, 1) === 0 ? "left" : "right";
    let directionY = random(0, 1) === 0 ? "up" : "down";

    const speed = 0.5;

    const render = () => {
      if (x <= 0) directionX = "right";
      if (x >= WIDTH) directionX = "left";
      if (y <= 0) directionY = "down";
      if (y >= HEIGHT) directionY = "up";

      let targetX = directionX === 'right' ? WIDTH : 0;
      let targetY = directionY === 'down' ? HEIGHT : 0;

      const minSideDistance = Math.min(Math.abs(targetX - x), Math.abs(targetY - y));
      const duration = minSideDistance / speed;

      targetX = directionX === 'left' ? x - minSideDistance : x + minSideDistance;
      targetY = directionY === 'up' ? y - minSideDistance : y + minSideDistance;

      ref.current.animate([
        { transform: `translate(${x}px, ${y}px)` }, 
        { transform: `translate(${targetX}px, ${targetY}px)` }
      ], {
          duration: duration,
      });

      setTimeout(() => {
        x = targetX;
        y = targetY;
        ref.current.style.transform = `translate(${targetX}px, ${targetY}px)`;
      }, duration - 10);

      setTimeout(() => {
        render();
      }, duration);
    };
    render();
  }, [color]);

  const diameter = useMemo(() => random(0, 0.6 * Math.min(WIDTH, HEIGHT)), []);
  return <div style={{
    background: color,
    position: 'absolute',
    width: `${diameter}px`,
    height: `${diameter}px`,
    top: 0,
    left: 0
  }} ref={ref} />;
}

Here are performance stats from Safari on my 6 year old Macbook: enter image description here

Maybe with some additional tweaks could be pushed into green zone? Your original solution was at the start of red zone, single canvas solution was at the end of yellow zone on Energy impact chart.

Alex
  • 1,724
  • 13
  • 25
  • This is not react solution, you are violating the basics rules of react, DOM can give better performance, but you sacrifice loosing the state information, I will not down vote this un relevant answer, as you did. – 0xFK Jun 13 '20 at 12:12
  • By not react solution you mean using ref to div? Yeah it is not recommended but unavoidable in certain cases(for example focus management) and in this case using `animate` method. Is there any drawback in losing state information in this particular case? Main author concern was performance and my solution provides significant boost to performance while retaining exactly same visual look. I don't see how it is un relevant. – Alex Jun 13 '20 at 13:26
  • You already implemented setTimeout, check my answer update please I added solution #2 for timeinterval case, it is still doable. with better performance – 0xFK Jun 13 '20 at 15:11
  • I use `animate` method for animation. `setTimeout` is used only to update DOM style value to avoid objects jumping to original location after animation is finished. I use `setInterval` because `finished` promise and corresponding event have limited support - not implemented in safari. One should not implement animations via `setInterval`. `requestAnimFrame` is designed specifically for that purpose. – Alex Jun 13 '20 at 15:15
  • Run the second solution, and compare it to your result, it is pure react solution – 0xFK Jun 13 '20 at 15:20
  • React is not designed for animations. It will be just painfully slow and wrong thing to do. – Alex Jun 13 '20 at 15:27