Jade Dungeon

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为单位,因此两边的像素就会被自动补足发散。

理论结果

images

实际结果

images

解决方案

面对这种情况,我们可以做个特殊处理,如果线宽为「奇数」的情况下, 我们就将绘制点偏移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)
}

images

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即可。

images

两个独立的图形重叠

当图形2与图形3发生了重叠,那我们在重绘图形2的同时势必要重绘图形3。

images

如果只针对这种情况的话,简单点的做法是, 直接擦除掉图形2和图形3组成的整个包围盒区域,然后全部重绘即可。

images

多个图形连续重叠

这种情况其实是最头疼的,

images

如果按照上一种情况的做法,大致思路会是这样:

  • 图形2需要重绘,判断有无图形重叠
  • 图形3和图形2重叠,因此要重绘图形3,并判断有无图形和图形3重叠
  • 图形1和图形3重叠,因此要重绘图形1,并判断有无图形和图形1重叠
  • 擦除图形1、2、3形成的包围盒区域,同时重绘图形1、2、3

可以看到,这样的话当图形数量巨大,且大多重叠的时候,很可能经过了大量的计算以后, 最终的结果还是要重绘所有的图形。

因此,我们需要采用另一种方式,借助我们刚刚提到的一个Canvas相关的接口,clip来实现。

如何实现局部擦除重绘

我们可以通过clip接口裁剪出需要重绘的图形区域, 将重绘的影响限制到图形自身的区域范围内。

images

比如这种情况下,我们需要重绘图形2,那我们要做的其实就这么几步:

  • 利用clip裁剪出图像2的绘制区域
  • 调用clearRect擦除这块区域
  • 重绘和这块区域重叠的所有图形

images

比起之前的做法的优势在于,如果和图形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');  // 结束时间

images

批量绘制

首先想到的是批量绘制,前面的代码中,每次变量都会调用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();
	}

可以看出 每次遍历都调用了一次beginPathstroke方法。为了提高绘制效率, 我们可以只调用它们方法一次,把所有的子路径组织成为一个大的路径, 这就是所谓的批量绘制思路,代码如下:

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所定义的路径会自动和路径集合中的最后一个路径连接起来, 如下图所示:

images

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填充区域。