Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Optimise canvas drawing of a circle

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)

like image 942
Charklewis Avatar asked May 25 '20 09:05

Charklewis


People also ask

Is canvas more performant than Dom?

In short, the canvas and WebGL are more performant than the DOM, and with third-party libraries, its ease-of-use is comparable; furthermore, growing browser support for additional web standards have the potential to further boost canvas performance.

How to draw a circle on the canvas?

Well, that's all there is to drawing circles on the canvas. As you've seen by now, there is no simple circle method that draws a circle for you. Instead, you have the more general arc method that provides you with a lot of little buttons to push and to customize what your circle looks like.

Why can't I draw a circle in JavaScript?

The first is that the angles increase clockwise for a circle when drawn in the canvas: The second detail is that JavaScript doesn't work with degrees. In JavaScript land, you deal with everything in terms of radians:

How do you draw a circle with anti clockwise?

Draw a line on the circumference either clockwise or anticlockwise depending on whether your value for isAntiClockwise is true or false. If you are filling in your circle, fill in the region enclosed by the circumference and the straight line between the points referenced by startAngle and stopAngle. Let's look through an example of this.

How can I reduce the size of an image on canvas?

Cache various sizes of your images on an offscreen canvas when loading as opposed to constantly scaling them in drawImage (). In your application, you may find that some objects need to move or change frequently, while others remain relatively static. A possible optimization in this situation is to layer your items using multiple <canvas> elements.


1 Answers

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" />
    </>
  );
}
like image 58
cerkiner Avatar answered Sep 17 '22 14:09

cerkiner