说明
【跟月影学可视化】学习笔记。
方法一:优化 Canvas 指令
例子:实现一些位置随机的多边形,并且不断刷新这些图形的形状和位置
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>优化 Canvas 指令</title> <style> canvas { border: 1px dashed #fa8072; } </style> </head> <body> <canvas width="500" height="500"></canvas> <script> const canvas = document.querySelector("canvas"); const ctx = canvas.getContext("2d"); // 创建正多边形,返回顶点 function regularShape(x, y, r, edges = 3) { const points = []; const delta = (2 * Math.PI) / edges; for (let i = 0; i < edges; i++) { const theta = i * delta; points.push([ x + r * Math.sin(theta), y + r * Math.cos(theta), ]); } return points; } // 根据顶点绘制图形 function drawShape(context, points) { context.fillStyle = "red"; context.strokeStyle = "black"; context.lineWidth = 2; context.beginPath(); context.moveTo(...points[0]); for (let i = 1; i < points.length; i++) { context.lineTo(...points[i]); } context.closePath(); context.stroke(); context.fill(); } // 多边形类型,包括正三角形、正四边形、正五边形、正六边形和正100边形、正500边形 const shapeTypes = [3, 4, 5, 6, 100, 500]; const COUNT = 1000; // 执行绘制 function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < COUNT; i++) { const type = shapeTypes[ Math.floor(Math.random() * shapeTypes.length) ]; const points = regularShape( Math.random() * canvas.width, Math.random() * canvas.height, 10, type ); drawShape(ctx, points); } requestAnimationFrame(draw); } draw(); </script> </body> </html>
我们f12查看帧率,效果如下:Google Chrome浏览器怎么开启查看帧率功能?
对于一个 500 边形来说,它的顶点数量非常多,所以 Canvas 需要执行的绘图指令也会非常多,那绘制很多个 500 边形自然会造成性能问题。
下面减少绘制 500 边形的绘图指令的数量:用 -1 代替正 500 边形,如果type小于0表名多边形是正500边形,用 arc 指令来画圆
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>优化 Canvas 指令</title> <style> canvas { border: 1px dashed #fa8072; } </style> </head> <body> <canvas width="500" height="500"></canvas> <script> const canvas = document.querySelector("canvas"); const ctx = canvas.getContext("2d"); // 创建正多边形,返回顶点 function regularShape(x, y, r, edges = 3) { const points = []; const delta = (2 * Math.PI) / edges; for (let i = 0; i < edges; i++) { const theta = i * delta; points.push([ x + r * Math.sin(theta), y + r * Math.cos(theta), ]); } return points; } // 根据顶点绘制图形 function drawShape(context, points) { context.fillStyle = "red"; context.strokeStyle = "black"; context.lineWidth = 2; context.beginPath(); context.moveTo(...points[0]); for (let i = 1; i < points.length; i++) { context.lineTo(...points[i]); } context.closePath(); context.stroke(); context.fill(); } // 多边形类型,包括正三角形、正四边形、正五边形、正六边形和正100边形以及正500边形 // 用 -1 代替正 500 边形 const shapeTypes = [3, 4, 5, 6, 100, -1]; const COUNT = 1000; const TAU = Math.PI * 2; // 执行绘制 function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < COUNT; i++) { const type = shapeTypes[ Math.floor(Math.random() * shapeTypes.length) ]; const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; // 如果type小于0表名多边形是正500边形 if(type > 0) { // 画正多边形 const points = regularShape(x, y, 10, type); drawShape(ctx, points); } else { // 画圆 ctx.beginPath(); // 绘制正多边形,否则用 arc 指令来画圆 ctx.arc(x, y, 10, 0, TAU); ctx.stroke(); ctx.fill(); } } requestAnimationFrame(draw); } draw(); </script> </body> </html>
优化完之后的效果:
方法二:使用缓存
具体做法就是将图形缓存下来,保存到离屏的 Canvas(offscreen Canvas)中,然后在绘制的时候作为图像来渲染,那就可以将绘制顶点的绘图指令变成直接通过 drawImage 指令来绘制图像,而且也不需要 fill() 方法来填充图形。
https://developer.mozilla.org/zh-CN/docs/Web/API/OffscreenCanvas
OffscreenCanvas 提供了一个可以脱离屏幕渲染的 canvas 对象。它在窗口环境和web worker环境均有效。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>使用缓存</title> <style> canvas { border: 1px dashed #fa8072; } </style> </head> <body> <canvas width="500" height="500"></canvas> <script> const canvas = document.querySelector("canvas"); const ctx = canvas.getContext("2d"); // 创建缓存的函数 function createCache() { const ret = []; for (let i = 0; i < shapeTypes.length; i++) { // 创建离屏Canvas缓存图形 const cacheCanvas = new OffscreenCanvas(20, 20); // 将图形绘制到离屏Canvas对象上 const type = shapeTypes[i]; const context = cacheCanvas.getContext("2d"); context.fillStyle = "red"; context.strokeStyle = "black"; if (type > 0) { const points = regularShape(10, 10, 10, type); drawShape(context, points); } else { context.beginPath(); context.arc(10, 10, 10, 0, TAU); context.stroke(); context.fill(); } ret.push(cacheCanvas); } // 将离屏Canvas数组(缓存对象)返回 return ret; } // 创建正多边形,返回顶点 function regularShape(x, y, r, edges = 3) { const points = []; const delta = (2 * Math.PI) / edges; for (let i = 0; i < edges; i++) { const theta = i * delta; points.push([ x + r * Math.sin(theta), y + r * Math.cos(theta), ]); } return points; } // 根据顶点绘制图形 function drawShape(context, points) { context.fillStyle = "red"; context.strokeStyle = "black"; context.lineWidth = 2; context.beginPath(); context.moveTo(...points[0]); for (let i = 1; i < points.length; i++) { context.lineTo(...points[i]); } context.closePath(); context.stroke(); context.fill(); } // 多边形类型,包括正三角形、正四边形、正五边形、正六边形和正100边形以及正500边形 const shapeTypes = [3, 4, 5, 6, 100, 500]; const COUNT = 1000; const TAU = Math.PI * 2; // 一次性创建缓存,直接通过缓存来绘图 const shapes = createCache(); function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < COUNT; i++) { const shape = shapes[Math.floor(Math.random() * shapeTypes.length)]; const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; ctx.drawImage(shape, x, y); } requestAnimationFrame(draw); } draw(); </script> </body> </html>
开启缓存效果:
缓存的局限性:
如果需要创建大量的离屏 Canvas 对象,就会对内存消耗就非常大,有可能反而降低了性能。
缓存适用于图形状态本身不变的图形元素,如果是经常发生状态改变的图形元素,起不到减少绘图指令的作用。
不使用缓存直接绘制的是矢量图,而通过缓存 drawImage 绘制出的则是位图,所以缓存绘制的图形,在清晰度上可能不是很好。
方法三:分层渲染
简单点说就是用两个 Canvas 叠在一起,将不变的元素绘制在一个 Canvas 中,变化的元素绘制在另一个 Canvas 中。
满足两个条件:
- 一是有大量静态的图形元素不需要重新绘制
- 二是动态和静态图形元素绘制顺序是固定的,先绘制完静态元素再绘制动态元素
上面就是两个canvas,一个动的,一个静态的,我们把它们叠在一起
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>分层渲染</title> <style> .box { position: relative; } canvas { border: 1px dashed #fa8072; } #bg { background-color: #000; } #fg { position: absolute; top: 0; left: 0; } </style> </head> <body> <div class="box"> <canvas width="500" height="500" id="bg"></canvas> <canvas width="500" height="500" id="fg"></canvas> </div> <script> // 绘制随机三角形 function drawRandomTriangle(path, context) { const { width, height } = context.canvas; context.save(); context.translate( Math.random() * width, Math.random() * height ); context.fill(path); context.restore(); } // 绘制背景 function drawBackground(context, count = 2000) { context.fillStyle = "#ed7"; const d = "M0,0L0,10L8.66, 5z"; const p = new Path2D(d); for (let i = 0; i < count; i++) { drawRandomTriangle(p, context); } } // 加载图片 function loadImage(src) { const img = new Image(); img.crossOrigin = "anonymous"; return new Promise((resolve) => { img.onload = resolve(img); img.src = src; }); } // 绘制前置背景 async function drawForeground(context) { const img = await loadImage('./assets/img/plane.png'); const { width, height } = context.canvas; function update(t) { context.clearRect(0, 0, width, height); context.save(); context.translate(0, 0.5 * height); const p = (t % 3000) / 3000; const x = width * p; const y = 0.1 * height * Math.sin(3 * Math.PI * p); context.drawImage(img, x, y); context.restore(); requestAnimationFrame(update); } update(0); } const bgcanvas = document.querySelector("#bg"); const fgcanvas = document.querySelector("#fg"); drawBackground(bgcanvas.getContext("2d")); drawForeground(fgcanvas.getContext("2d")); </script> </body> </html>
方法四:局部重绘
如果元素都有可能运动,或者动态元素和静态元素的绘制顺序是交错的,可以使用局部重绘来处理,局部重绘就是不需要清空 Canvas 的全局区域,而是根据运动的元素的范围来清空部分区域。
canvas 提供 clip() ,能确定绘制的的裁剪区域,区域之外的图形不能绘制 CanvasRenderingContext2D.clip()
另外可以使用动态计算要重绘区域的技术,它也被称为脏区检测。它的基本原理是根据动态元素的包围盒,动态算出需要重绘的范围。
包围盒:指能包含多边形所有顶点,并且与坐标轴平行的最小矩形。
有兴趣的可以看看这篇:AntV Canvas 局部渲染总结
方法五:优化滤镜
用缓存优化版本的代码加上滤镜
可以看到直接干到 1.8 fps
了,说明滤镜对渲染性能的开销还是很大的
我们可以对 Canvas 应用一个全局的 blur 滤镜,把绘制的所有元素都变得模糊,没必要对每个元素应用滤镜,而是可以采用类似后期处理通道的做法,先将图形以不使用滤镜的方式绘制到一个离屏的 Canvas 上,然后直接将这个离屏 Canvas 以图片方式绘制到要显示的画布上,这样就能把大量滤镜绘制的过程缩减为对一张图片使用一次滤镜,下面调整一下代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>优化滤镜</title> <style> canvas { border: 1px dashed #fa8072; } </style> </head> <body> <canvas width="500" height="500"></canvas> <script> const canvas = document.querySelector("canvas"); const ctx = canvas.getContext("2d"); // 创建缓存的函数 function createCache() { const ret = []; for (let i = 0; i < shapeTypes.length; i++) { // 创建离屏Canvas缓存图形 const cacheCanvas = new OffscreenCanvas(20, 20); // 将图形绘制到离屏Canvas对象上 const type = shapeTypes[i]; const context = cacheCanvas.getContext("2d"); context.fillStyle = "red"; context.strokeStyle = "black"; if (type > 0) { const points = regularShape(10, 10, 10, type); drawShape(context, points); } else { context.beginPath(); context.arc(10, 10, 10, 0, TAU); context.stroke(); context.fill(); } ret.push(cacheCanvas); } // 将离屏Canvas数组(缓存对象)返回 return ret; } // 创建正多边形,返回顶点 function regularShape(x, y, r, edges = 3) { const points = []; const delta = (2 * Math.PI) / edges; for (let i = 0; i < edges; i++) { const theta = i * delta; points.push([ x + r * Math.sin(theta), y + r * Math.cos(theta), ]); } return points; } // 根据顶点绘制图形 function drawShape(context, points) { context.fillStyle = "red"; context.strokeStyle = "black"; context.lineWidth = 2; context.beginPath(); context.moveTo(...points[0]); for (let i = 1; i < points.length; i++) { context.lineTo(...points[i]); } context.closePath(); context.stroke(); context.fill(); } // 多边形类型,包括正三角形、正四边形、正五边形、正六边形和正100边形以及正500边形 const shapeTypes = [3, 4, 5, 6, 100, 500]; const COUNT = 1000; const TAU = Math.PI * 2; // 一次性创建缓存,直接通过缓存来绘图 const shapes = createCache(); ctx.filter = "blur(5px)"; // 创建离屏的 Canvas const ofc = new OffscreenCanvas(canvas.width, canvas.height); const octx = ofc.getContext("2d"); function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); octx.clearRect(0, 0, canvas.width, canvas.height); // 将图形不应用滤镜,绘制到离屏Canvas上 for (let i = 0; i < COUNT; i++) { const shape = shapes[Math.floor(Math.random() * shapeTypes.length)]; const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; octx.drawImage(shape, x, y); } // 再将离屏Canvas图像绘制到画布上,这一次绘制采用了滤镜 ctx.drawImage(ofc, 0, 0); requestAnimationFrame(draw); } draw(); </script> </body> </html>
可以看到效果立竿见影:
方法六:多线程渲染
多线程渲染是用来优化非渲染的计算和交互方面导致的性能问题。比如渲染过程消耗了大量的时间,它可能会阻塞其他的操作,比如对事件的响应。这个时候可以利用浏览器支持 Canvas 可以在 WebWorker 中以单独的线程来渲染,这样就可以避免对主线程的阻塞,也不会影响用户交互行为。
具体的过程:
在浏览器主线程中创建 Worker
然后将 Canvas 对象通过 transferControlToOffscreen 转成离屏 Canvas 对象发送给 Worker 线程去处理
方法 HTMLCanvasElement.transferControlToOffscreen() 将控制转移到一个在主线程或者 web worker 的 OffscreenCanvas 对象上。
postMessage用法可以参考:https://developer.mozilla.org/zh-
CN/docs/Web/API/Window/postMessage
我们新建一个 worker.js 文件,在里面写进之前优化好的代码,并且添加监听
// 创建正多边形,返回顶点 function regularShape(x, y, r, edges = 3) { const points = []; const delta = (2 * Math.PI) / edges; for (let i = 0; i < edges; i++) { const theta = i * delta; points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]); } return points; } // 根据顶点绘制图形 function drawShape(context, points) { context.lineWidth = 2; context.beginPath(); context.moveTo(...points[0]); for (let i = 1; i < points.length; i++) { context.lineTo(...points[i]); } context.closePath(); context.stroke(); context.fill(); } // 多边形类型,包括正三角形、正四边形、正五边形、正六边形和正100边形以及正500边形 // 用 -1 代替正 500 边形 const shapeTypes = [3, 4, 5, 6, 100, -1]; const COUNT = 1000; const TAU = Math.PI * 2; // 创建缓存的函数 function createCache() { const ret = []; for (let i = 0; i < shapeTypes.length; i++) { const cacheCanvas = new OffscreenCanvas(20, 20); const type = shapeTypes[i]; const context = cacheCanvas.getContext("2d"); context.fillStyle = "red"; context.strokeStyle = "black"; if (type > 0) { const points = regularShape(10, 10, 10, type); drawShape(context, points); } else { context.beginPath(); context.arc(10, 10, 10, 0, TAU); context.stroke(); context.fill(); } ret.push(cacheCanvas); } return ret; } // 执行绘制 function draw(ctx, shapes) { const canvas = ctx.canvas; ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < COUNT; i++) { const shape = shapes[Math.floor(Math.random() * shapeTypes.length)]; const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; ctx.drawImage(shape, x, y); } requestAnimationFrame(draw.bind(null, ctx, shapes)); } // 监听message console.log('self------>', self) self.addEventListener("message", (evt) => { console.log('message--->', evt) if (evt.data.type === "init") { const canvas = evt.data.canvas; if (canvas) { const ctx = canvas.getContext("2d"); const shapes = createCache(); draw(ctx, shapes); } } });
在 html 文件里面引入该 js
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>多线程渲染</title> <style> canvas { border: 1px dashed #fa8072; } </style> </head> <body> <canvas width="500" height="500"></canvas> <script> const canvas = document.querySelector('canvas'); const worker = new Worker('./assets/js/29/worker.js'); const ofc = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: ofc, type: 'init', }, [ofc]); </script> </body> </html>