最近的一个客户项目中,简化的需求是绘制按照行列绘制很多个圆圈。需求看起来不难,上手就可以做,写两个for循环。
首先定义了很多Circle对象,在遍历循环中调用该对象的draw方法。代码如下:
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);
结果绘制出了按照行列排布的很多个圆圈了,如下图所示:
恩,很简单嘛,可以回家睡觉了。
console.time(time); // 实际绘制的代码 console.timeEnd(time);
时间显示为几百毫秒(3到4百毫秒),如下图所示:
绘制时间
几百毫秒的绘制时间,必然是卡顿的。想要流畅操作,肯定还的优化。
首先想到的是批量绘制,前面的代码中,每次变数都会调用circle.draw(ctx)方法,circle.draw方法代码如下:
draw: function (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方法。为了提高绘制效率,我们可以只调用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所定义的路径会自动和路径集合中的最后一个路径连接起来,如下图所示:
此处的moveTo就是为了避免这种连接。
注意:arc 和arcTo都会有上述问题,但是rect定义的路径却不存在这种问题。
通过以上优化,客户已经觉得效率挺不错了。 但是技术研究没有止境,由于这个分布很规律,总感觉有更加快速的方法。最终突发灵感想到了一种方法,就是使用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对象。
通过上图可以看出,效率极高,可以达到零点几毫秒的级别。
如果客户需求只是这么简单,相信使用canvas pattern对象这种方式,效率是最高的。但是,客户的实际需求是,先绘制10万个的圆圈,然后可以用擦除工具,擦除一些区域的圆圈,如下图所示:
原始绘制方法和批量绘制方法要是实现上述效果,都很容易,只要把不需要绘制圆圈的位置,直接忽略掉即可以。
比如用一个map记录需要忽略的圆圈的坐标,遍历的时候判断在map记录中的地方就直接跳过不进行绘制操作。
如果是canvas pattern的方式,应该怎么实现上图的效果呢? 经过思索发现可以通过ctx.clip方法。
clip,裁剪。如果通过ctx.clip定义了裁剪区域,绘制的图形只会在裁剪区域的部分显示出来,裁剪区域之外的,则不会显示。
没一个圆圈都会占用一个矩形区域,本案例中,可以把要显示的的圆圈所占的矩形区域都定义到裁剪区域里面,而不要显示的圆圈的矩形区域则排除到裁剪区域之外,如下图所示,绘制圆圈的矩形区域用实线表示出来,不绘制圆圈的区域用虚线表示:
只需要把所有实线表示的矩形区域都添加到要clip的路径中去,然后调用fill方法,则只会在实现定义的矩形区域显示出来圆圈。以下是示例代码:
for(var i = 0;i < 400; i ++){ for(var j = 0;j < 400;j ++){ var r = Math.random(); if(r <0.2){ templateMap[i+":" + j] = true; continue; }
var x = 10 + j * tempCanvas.width; var y = 10 + i * tempCanvas.height; var rect = { x : x, y : y, width : tempCanvas.width, height:tempCanvas.height }; ctx.rect(rect.x,rect.y,rext.width,rect.height); } ctx.clip();
首先遍历所有的圆圈坐标,为了演示效果,用Math.random为了模拟随机产生一个数,如果这个数小于0.2,则当前圆圈的矩形区域不会被加入裁剪区域,也就是该圆圈不会显示出来。
观察上面 「裁剪区域」 这个图,以第一行为例,第一、第二、第三个矩形区域是连在一块的,完全没有必要调用三次ctx.rect方法,而是先用演算法把三个区域合并为一个矩形区域,然后调用一次ctx.rect方法即可,如下图:
下面是合并裁剪区域的演算法,目前只是实现了同一行的合并,更加优化的合并演算法并没有实现,代码如下:
function calRectMap (tempCanvas){ if(rectMap != null){ return; } rectMap = rectMap || []; for(var i = 0;i < 400; i ++){ for(var j = 0;j < 400;j ++){ var r = Math.random(); if(r <0.2){ templateMap[i+":" + j] = true; continue; }
var x = 10 + j * tempCanvas.width; var y = 10 + i * tempCanvas.height; var rect = { x : x, y : y, width : tempCanvas.width, height:tempCanvas.height }; lineRectMap[i] = lineRectMap[i] || [];
lineRectMap[i][j] = rect; } unionLineRects(lineRectMap[i],rectMap); } }
function unionLineRect(rect1,rect2){ return { x: rect1.x, y : rect1.y, width:rect1.width + rect2.width, height:rect1.height } }
function unionLineRects(lineRectMap,rectMap){ var lastRect = null,lastNotNullIndex = null; for(var j = 0;j < 400;j ++){
var currentRect = lineRectMap[j]; if(lastRect == null){ lastRect = currentRect; }else{ if( lastNotNullIndex == j - 1 && currentRect){ lastRect = unionLineRect(lastRect,currentRect); } } if(currentRect != null){ lastNotNullIndex = j; }else if (lastRect){ rectMap.push(lastRect); lastNotNullIndex = null; lastRect = null; } } if(lastRect){ rectMap.push(lastRect); } }
相关合并的演算法,此处不再详细说明。 合并之后,测试绘制的时间降低到了10几毫秒,算是比较好的绘制效果了:
由于笔者本人也长期研究webgl的技术,所以尝试著用webgl实线了2d的绘制,相关细节不在此处赘述,后面会写专门的文章如何用webgl绘制2d图形。最终测试的效率不是很理想,差不多100多毫秒,和上面的批量绘制差不多。 因为用webgl绘制,单次的绘制效率应该不会太差,但是由于需要遍历调用10万次绘制命令,必然效率不高。另外webgl绘制的效果其实是没有2d绘制的效果好的,锯齿严重。 要实现好的效果,还需要引入去锯齿相关技术。 绘制的效果如下:
用webgl绘制2d图形的相关主题,回头会另外写一篇文章介绍。敬请关注。
webgl2 引入了实例化数组,通过这个功能,可以实现把很多次的绘制调用合并为一个绘制调用,这会极大提高绘制效率。
有关实例化数组的功能,参考https://www.jianshu.com/p/d40a8b38adfe
绘制10万个圆形的效率大概在每帧零点零几毫秒,简直就是大boss级别的快,如下图:
通过这篇文章,除了想给读者传递相关知识点之外,其实还想表达一个观点:
其实IT行业的知识更新越来越快,能够以不变应万变的人,就是拥有良好的学习力、创造力、判断力和思考力的人。这些能力会让你在变换万千的技术海洋中,屹立不倒,不被淹没。
当然,标书可能有点好为人师了。 在日常的工作中,彪叔更喜欢做的事情,就是启迪下属的思考,而不仅仅是某个问题的解决方案。这是比学习知识更加重要的素质。彪叔也会在我的其他文章中,分享底层能力的相关认知。有兴趣的猿们可以关注彪叔的公号:ITman彪叔
欢迎关注公众号:ITman彪叔