Canvas技巧
使用双缓存
其实双缓存是使用Canvas使用到的比较高频的技巧, 主要是用于解决Canvas绘制时画面闪烁或者说白屏的问题。
通过另外创建一个和主画布等大的缓冲Canvas画布,需要渲染的图片先绘制到缓冲画布上, 然后将缓冲画布的数据直接绘制到主画布上,就解决了闪烁的问题。
function drawImage(url, mainCanvas) { //1. 第一步加载图片 const img = new Image(); img.src = url; img.onload = () => { //2.第二步,将图片绘制到缓存画布上,缓存画布临时创建和存储起来都可以 const cacheCanvas = document.createElement("canvas"); //画布设置为等大 cacheCanvas.width = mainCanvas.width; cacheCanvas.height = mainCanvas.height; //这里是绘制图片的逻辑,自适应或者拉伸取决于自己需要,为了方便这里就拉伸了 cacheCanvas.getContext("2d").drawImage(img, 0, 0, cacheCanvas.width, cacheCanvas.height); //3.第三步,把缓存画布的内容绘制到主画布上 const mainCtx = mainCanvas.getContext("2d"); //先清空上一帧 mainCtx.clearRect(0, 0, mainCanvas.width, mainCanvas.height); //绘制画面 mainCtx.drawImage(cacheCanvas, 0, 0); } }
线条发散处理
在绘制一些规则的几何图形和线条的时候,可能会发现边缘或者线条明明宽度应该是1px, 但结果却显示成了2px,而且颜色也更淡。
这其实跟Canvas绘制线条的规则有关,把屏幕像素点看成一个个网格, 那么Canvas绘制线条时会以网格的交错点作为起点,因此奇数线宽的线条, 往往宽度会被拆分到两边的网格之中。 又因为屏幕显示像素最小以1px为单位,因此两边的像素就会被自动补足发散。
理论结果
实际结果
解决方案
面对这种情况,我们可以做个特殊处理,如果线宽为「奇数」的情况下, 我们就将绘制点偏移0.5px,最终的结果就不会发散了。
不规则遮罩
有些时候我们可能需要利用Canvas实现一个遮罩的效果,比如裁剪框、刮刮乐。
对于规则图形,我们可以通过先绘制底部图案,然后调用clearRect的方法来擦除遮罩区域 ,但对于不规则的区域的话就没办法实现了。
因此对于不规则图案,我们需要用到Canvas的一个功能来实现遮罩,那就是clip。
对应的接口文档:CanvasRenderingContext2D.clip()
实现思路
实现的思路大致分为四步:
- 绘制作为遮罩的背景(比如纯黑、或者一张图片)
- 绘制需要露出来的形状(比如裁出一个五角星)
- 调用clip接口裁出这块区域
- 最后,如果需要镂空的话直接调用clearRect擦除画布即可; 如果需要在镂空处绘制别的图案,可以在基础上调用绘制方法。
具体代码
function drawMask() { const cvs = document.createElement("canvas"); const ctx = cvs.getContext("2d"); cvs.width = 500; cvs.height = 500; //1. 绘制遮罩背景,这里使用纯黑 ctx.fillStyle = "#000"; ctx.fillRect(0, 0, 500, 500); //2. 绘制自定义的遮罩形状,这里用三角形比较简单 ctx.beginPath(); ctx.moveTo(250, 100); ctx.lineTo(375, 300); ctx.lineTo(125, 300); ctx.closePath(); //3. 调用clip接口裁出图形 ctx.clip(); //4. 擦除裁出的图形区域,这里只要保证擦除的区域包括裁剪区域即可 ctx.clearRect(0, 0, 500, 500) }
Blob和ImageData数据的处理
有些场景下,你可能需要根据图片获取对应的ImageData数据或者Blob数据, 传到后端做一些图像处理。这里就介绍下如何通过Canvas获得这两种数据, 并且互相转换
获取ImageData
这里我们可以借助Canvas自带的getImageData接口获取ImageData数据。
(Api的功能介绍直接看MDN就行了CanvasRenderingContext2D.getImageData()
)。
这里我们直接上代码:
function getImageData(url) { return new Promise((res, rej) => { //根据url创建出img对象来 const img = new Image(url); img.src = url; img.onload = () => { //创建临时的canvas元素,用于获取imageData数据 const tempCanvas = document.createElement("canvas"); //将canvas设为和图片等大 tempCanvas.width = img.naturalWidth; tempCanvas.height = img.naturalHeight; //绘制图片,并获取imageData数据 const ctx = tempCanvas.getContext("2d"); ctx.drawImage(img, 0, 0); res(ctx.getImageData(0, 0, tempCanvas.width, tempCanvas.height)) }; img.onerror=(err)=>{ rej(err); }; }); }
通过将图片绘制到Canvas上,再调用ctx.getImageData
接口,
我们就得到想要的ImageData数据了。
获取Blob
获取Blob数据可以借助Canvas自带的toBlob接口获取,
(同样放上Api的链接HTMLCanvasElement.toBlob()
)。同样直接上代码:
function getBlob(url, type, quality) { return new Promise((res, rej) => { //根据url创建出img对象来 const img = new Image(url); img.src = url; img.onload = () => { //创建临时的canvas元素,用于获取imageData数据 const tempCanvas = document.createElement("canvas"); //将canvas设为和图片等大 tempCanvas.width = img.naturalWidth; tempCanvas.height = img.naturalHeight; //绘制图片,并获取imageData数据 const ctx = tempCanvas.getContext("2d"); ctx.drawImage(img, 0, 0); tempCanvas.toBlob(res, type, quality); }; img.onerror = (err) => { rej(err); }; }); }
用法基本和获取ImageDta类似,但最后是通过toBlob接口获取, 同时可以设置数据类型和质量,相当于自带压缩了。
Blob转ImageData
其实本质上还是借助Canvas的几个接口,直接上代码:
function blobToImgData(blob) { const url = URL.createObjectURL(blob); return getImageData(url); }
你没看错!借助前面封装好的接口,其实只要两行代码就搞定了。
ImageData转Blob
这个过程稍微多几步,原理也是一样的:
function imgDataToBlob(imgData, type, quality) { return new Promise((res) => { //创建临时的canvas元素 const tempCanvas = document.createElement("canvas");
//将canvas元素设为图片等大 tempCanvas.width = imgData.width; tempCanvas.height = imgData.height;
//绘制数据 tempCanvas.getContext("2d").putImageData(imgData, 0, 0);
//转成blob数据 tempCanvas.toBlob(res, type, quality); }); }
局部擦除重绘
当使用Canvas用于一些绘制图形较多的场景式,比如游戏、或者图形编辑器等等, 需要频繁重绘,而每一次重绘都需要对整个画布所有的内容重新绘制, 难免会造成很大的浪费。
很多时候其实需要重绘的图形比较独立,只需要重绘本身即可, 因此如果能采用局部擦除重绘的方式的话,自然能节省渲染的成本。
不同场景
分析一下可能遇到的不同的渲染情况对应彩不同的策略。
完全独立
这种是最简单的情况,我们只需要擦除掉图形2自身的区域,然后重绘图形2即可。
两个独立的图形重叠
当图形2与图形3发生了重叠,那我们在重绘图形2的同时势必要重绘图形3。
如果只针对这种情况的话,简单点的做法是, 直接擦除掉图形2和图形3组成的整个包围盒区域,然后全部重绘即可。
多个图形连续重叠
这种情况其实是最头疼的,
如果按照上一种情况的做法,大致思路会是这样:
- 图形2需要重绘,判断有无图形重叠
- 图形3和图形2重叠,因此要重绘图形3,并判断有无图形和图形3重叠
- 图形1和图形3重叠,因此要重绘图形1,并判断有无图形和图形1重叠
- 擦除图形1、2、3形成的包围盒区域,同时重绘图形1、2、3
可以看到,这样的话当图形数量巨大,且大多重叠的时候,很可能经过了大量的计算以后, 最终的结果还是要重绘所有的图形。
因此,我们需要采用另一种方式,借助我们刚刚提到的一个Canvas相关的接口,clip来实现。
如何实现局部擦除重绘
我们可以通过clip接口裁剪出需要重绘的图形区域, 将重绘的影响限制到图形自身的区域范围内。
比如这种情况下,我们需要重绘图形2,那我们要做的其实就这么几步:
- 利用clip裁剪出图像2的绘制区域
- 调用clearRect擦除这块区域
- 重绘和这块区域重叠的所有图形
比起之前的做法的优势在于,如果和图形2重叠的图形上还有重叠的图形, 我们就不需要考虑了,只考虑和图形2重叠的图形即可。
高效绘制10万图形技巧
绘制10万个圆,耗时359.8430175ms:
for (var i = 0; i < column; i++) { for (var j = 0; j < row; j++) { var circle = new Circle({x: 8 * i + 3,y: 8 * j + 3, radius: 3}); box.push(circle); } } console.time('time'); // 开始计时 for (var c = 0; c < box.length; c++) { var circle = box[c]; circle.draw(ctx); } console.timeEnd('time'); // 结束时间
批量绘制
首先想到的是批量绘制,前面的代码中,每次变量都会调用circle.draw(ctx)
方法,
circle.draw
方法代码如下:
function draw(ctx) { ctx.save(); ctx.lineWidth = this.lineWidth; ctx.strokeStyle = this.strokeStyle; ctx.fillStyle = this.fillStyle; ctx.beginPath(); this.createPath(ctx); ctx.stroke(); if(this.isFill) { ctx.fill(); } ctx.restore(); }
可以看出 每次遍历都调用了一次beginPath
和stroke
方法。为了提高绘制效率,
我们可以只调用它们方法一次,把所有的子路径组织成为一个大的路径,
这就是所谓的批量绘制思路,代码如下:
console.time('time'); ctx.beginPath(); for (var c = 0; c < box.length; c++) { var circle = box[c]; ctx.moveTo(circle.x + 3, circle.y); circle.createPath(ctx); } ctx.closePath(); ctx.stroke(); console.timeEnd('time');
调试发现,确实效率有了很大的提升,时间减少到100毫秒左右, 相当于效率提高了3-4倍左右。
需要注意的是上述代码中的moveTo语句:
ctx.moveTo(circle.x + 3, circle.y);
这是因为:当使用arc方法给路径中添加子路径的时候, arc所定义的路径会自动和路径集合中的最后一个路径连接起来, 如下图所示:
arc定义的路径自动连接起来
此处的moveTo就是为了避免这种连接。
注意:arc 和arcTo都会有上述问题,但是rect定义的路径却不存在这种问题。
Pattern 方式
通过以上优化,客户已经觉得效率挺不错了。 但是技术研究没有止境, 由于这个分布很规律,总感觉有更加快速的方法。最终突发灵感想到了一种方法, 就是使用canvas 的Pattern功能:
canvas的fillStyle可以指定为一个pattern对象, 而pattern可以实现一个简单图像的平铺。基于这种思路,我们可以实现如下代码:
var tempCanvas = document.createElement('canvas'); var ctx2 = tempCanvas.getContext('2d'); var w = 5,h = 5; tempCanvas.width = w; tempCanvas.height = h; dpr(tempCanvas); ctx2.fillStyle = 'red'; ctx2.arc(w/2,h/2,w/2 - 1,0,Math.PI * 2); ctx2.stroke(); ctx.save(); ctx.beginPath(); var width = tempCanvas.width * 500,height = tempCanvas.height * 200; var pattern = ctx.createPattern(tempCanvas, 'repeat'); ctx.clearRect(100,100,width,height); ctx.rect(100,100,width,height); ctx.fillStyle = pattern; ctx.fill(); ctx.restore();
代码首先定义一个小的canvas,命名为tempCanvas
,在tempCanvas
上面绘制一个圆,
需要注意的是tempCanvas的尺寸要设置为正好绘制下这个圆圈。
然后通过通过tempCanvas创建pattern对象, 并把canvas的绘制上下文ctx的fillStyle指定为该pattern对象。
之后通过rect方法指定要fill的区域大小, 改区域大小应该是所有最终要绘制的圆圈的大小的总和:
var width = tempCanvas.width * 500,height = tempCanvas.height * 200;
最后调用画笔的fill方法,用tempCanvas填充区域。