优化绘制圆形的画布

7

我刚接触HTML5画布,希望制作几个圆圈以随机方向移动,为我的网站增加一些华丽效果。

我注意到当这些圆圈移动时,CPU使用率非常高。当只有几个圆圈在移动时,通常还可以,但大约有5个或更多时就会出现问题。

这是在Safari中对具有5个圆圈的情况进行了数秒钟的性能分析的截图。

Profile Results

这是我目前为我的圆形组件编写的代码:

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} />
}

有没有更有效率的方法来使用 Canvas 绘制和移动圆形?
当圆形不移动时,CPU 利用率开始在 ~3% 左右,然后降至小于 1%。当我从 DOM 中删除这些圆形时,CPU 利用率始终小于 1%。
我知道使用 CSS 来完成这些类型的动画通常更好(因为我相信它使用 GPU 而不是 CPU),但我无法弄清楚如何使用过渡 CSS 属性使其工作。我只能让缩放变换起作用。
只有在屏幕上有许多运动的圆形时,我的花哨效果才看起来很“酷”,因此寻找一种更有效率的方法来绘制和移动这些圆形。
这里有一个演示的沙盒:https://codesandbox.io/s/async-meadow-vx822 (在 Chrome 或 Safari 中查看效果最佳)。

如果您不希望画布在调用函数之间发生变化,那么您在其中执行的许多操作将会每次运行都产生相同的结果。像这样的事情最好只计算一次。直到 let y = canvas.height; 的大部分代码似乎都可能返回相同的结果。但无论如何,如果您还没有对代码进行过性能分析,那么这些讨论大多是无意义的。浏览器中的开发工具可以帮助您,在任何代码片段消耗的时间方面告诉您准确的数据。既然可以测量,就没有必要猜测! - enhzflep
1
添加一个片段/小例子或可运行的代码 - Mechanic
我已经在问题中更新了沙盒。 - Charklewis
@Charklewis,为什么不考虑使用div来制作圆形,并使用CSS动画(通过animate方法)进行动画处理呢?对于你的特定任务而言,这种方式比canvas更具性能优势。 - Alex
@Aleksey这是个好主意,而且可能会更高效。我有机会的话会尝试一下。 - Charklewis
@Charklewis 请将Circle.js文件替换为我的解决方案,以尝试您最初发布的代码。我很好奇您在自己的机器上会得到什么样的性能。如果您有机会测试,请告诉我结果。 - Alex
5个回答

6

这是一种不同的方法,将圆形和背景结合在一起,只使用一个画布元素来改善呈现的dom。

该组件使用与您的随机逻辑相同的颜色和大小,但在渲染任何内容之前将所有初始值存储在circles数组中。render函数一起呈现背景颜色和所有圆形,并计算它们在每个周期内的移动。

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} />;
}

你可以在你的应用程序组件中,仅使用此一个组件来替换所有的圆形元素和背景样式。
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" />
    </>
  );
}

这项技术非常有效。我在尝试这种解决方案时犯了一个错误,就是将requestAnimationFrame放在circles.forEach循环内部,导致了严重的性能问题。修复这个错误后,我成功将CPU使用率降低到了稳定的约3%。节约了大约27%!谢谢。 - Charklewis
另外,我还使用Luke在这个问题的答案中提到的技术来降低FPS以进一步提高性能。链接:https://dev59.com/e2Ij5IYBdhLWcg3w6JHU。 - Charklewis

1

我尽可能地组装了您的代码,看起来您有缓冲区溢出(蓝色js堆),您需要在这里进行调查,这些是根本原因。

最初的方法是只创建一个圆圈,然后从父级动画子级,通过这种方式,您可以避免密集的内存和CPU计算。

通过单击画布添加多少个圆圈,画布信用归Martin所有。

根据Alexander的讨论,可以使用setTimeout或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];
}

方案#2 使用间隔

IntervalExample.js (应用程序) 9个样本圆形

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


在你添加任何代码之前,我已经点了“踩”。如果你看一下作者发布的分析器结果,就会发现主要原因是重新绘制,而不是JavaScript性能/内存问题。虽然你的优化是有效的,但是它不会提供足够明显/显著的性能提升。 - Alex

0

我强烈推荐阅读 Mozilla Developer's Network 网站上的文章 Optimizing the Canvas。具体而言,在不涉及实际编码的情况下,不建议在画布中重复执行昂贵的渲染操作。相反,您可以在圆形类内创建一个虚拟画布,并在最初创建圆形时在其中进行绘制,然后缩放 Circle 画布并将其 blit 到主画布上,或者将其 blit 并在要 blit 的画布上缩放它。您可以使用 CanvasRenderingContext2d.getImageData 和 .putImageData 将一个画布从另一个画布中复制出来。您可以根据自己的喜好来实现它,但是这个想法是不要在绘制一次后重复绘制图元,通过复制像素数据比重绘快得多。

更新

我尝试调整您的示例,但我没有使用 react 的经验,所以我不太清楚正在发生什么。无论如何,我准备了一个纯 JavaScript 示例,而不是使用虚拟画布,而是绘制到画布上,将其添加到文档中,并在原始画布的约束条件下使画布本身动画化。这似乎是最快和最流畅的方式(按 c 添加圆形,按 d 删除圆形):

<!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>


能否分享一下您所说的虚拟画布的示例?我在文章中看到他们提到了“离屏画布”。它们是等价的概念吗? - Charklewis
1
是的,它们是相同的。你只需要使用document.createElement('canvas')创建一个画布,但从未将画布添加到DOM中。这就是所谓的离屏画布。画布存储在内存中,你可以在上面绘制、复制部分或整个画布,并将其粘贴到可见的画布上。我可以尝试编写一个示例并在今天稍后将其添加到我的答案中。 - leisheng

0
酷炫的效果!我非常惊讶,@Sam Erkiner提出的解决方案对我来说并没有比你原来的更好。我本来以为单一画布会更有效率。我决定用新的动画API和纯DOM元素尝试一下,看看效果如何。这是我的解决方案(只改变了Circle.js文件):
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} />;
}

这是我 6 年前的 Macbook 上 Safari 的性能统计数据: enter image description here 也许通过一些额外的调整可以将其推到绿色区域? 您的原始解决方案在能源影响图表的红色区域开头,而单画布解决方案位于黄色区域末尾。

这不是React的解决方案,你违反了React的基本规则,DOM可以提供更好的性能,但你牺牲了状态信息,我不会像你那样给这个无关紧要的答案投反对票。 - 0xFK
你所说的不使用React解决方案是指使用ref到div吗?是的,在某些情况下(例如焦点管理)是不可避免的,但并不推荐。在这种情况下,使用animate方法。在失去状态信息方面有什么缺点吗?主要作者关注的是性能,而我的解决方案在保留完全相同的视觉外观的同时提供了显着的性能提升。我不明白它为什么与此无关。 - Alex
你已经实现了setTimeout,请检查我的答案更新,我添加了解决方案#2用于timeinterval情况,它仍然可行,并且性能更好。 - 0xFK
我使用 animate 方法进行动画。setTimeout 仅用于更新 DOM 样式值,以避免对象在动画完成后跳回原始位置。我使用 setInterval 是因为 finished promise 和相应的事件支持有限 - 在 Safari 中未实现。不应该通过 setInterval 实现动画。requestAnimFrame 是专门为此目的设计的。 - Alex
运行第二个解决方案,将其与您的结果进行比较,这是一个纯React解决方案。 - 0xFK
React并不是为动画设计的。使用它来做动画会非常缓慢,也是错误的做法。 - Alex

0

首先,效果很不错!

说完这个,我仔细阅读了你的代码,看起来没问题。但是我担心使用多个画布和透明度会导致高CPU负载,这是无法避免的...

为了优化你的效果,你可以尝试两种方法:

  1. 尝试只使用一个画布
  2. 尝试只使用CSS,在最后你只是用画布来绘制一个填充圆形,并使用固定集合中的颜色:你可以使用预先绘制相同圆形的图像,并使用几乎相同的代码来简单地更改图像的样式属性

也许使用着色器,你将能够以高CPU节省获得相同的效果,但不幸的是我不熟悉着色器,所以无法给你任何相关提示。

希望我给你一些想法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接