canvas高效绘制10万图形,你必须知道的高效绘制技巧

最近的一个客户项目中,简化的需求是绘制按照行列绘制很多个圆圈。需求看起来不难,上手就可以做,写两个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');

结果绘制出了按照行列排布的很多个圆圈了,如下图所示:


原始方法绘制很多圆圈

恩,很简单嘛,可以回家睡觉了。
等等,客户要求绘制的极限是10万个,而且每次绘制不能卡顿。先看下绘制10万个圆圈的时间是多久,用console.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所定义的路径会自动和路径集合中的最后一个路径连接起来,如下图所示:


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填充区域。最终绘制的效果和绘制消耗的时间如下图所示:


Canvas Pattern 方式绘制10万个圆

通过上图可以看出,效率极高,可以达到零点几毫秒的级别。

新的需求

如果客户需求只是这么简单,相信使用canvas pattern对象这种方式,效率是最高的。但是,客户的实际需求是,先绘制10万个的圆圈,然后可以用擦除工具,擦除一些区域的圆圈,如下图所示:


擦除后的效果

原始绘制方法和批量绘制方法要是实现上述效果,都很容易,只要把不需要绘制圆圈的位置,直接忽略掉即可以。

比如用一个map记录需要忽略的圆圈的坐标,遍历的时候判断在map记录中的地方就直接跳过不进行绘制操作。

canvas pattern + 裁剪

如果是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,则当前圆圈的矩形区域不会被加入裁剪区域,也就是该圆圈不会显示出来。
通过上面裁剪操作后,“擦除后的效果”算是实现了。但是,经过测试,性能却低回去了,为什么,因为增加了很多rect操作。测试下来,一幁的绘制时间大概在80多毫秒,比批量绘制还是高一点,但是感觉还是不够好。

Pattern + 合并裁剪

观察上面 “裁剪区域” 这个图,以第一行为例,第一、第二、第三个矩形区域是连在一块的,完全没有必要调用三次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的技术,所以尝试着用webgl实线了2d的绘制,相关细节不在此处赘述,后面会写专门的文章如何用webgl绘制2d图形。最终测试的效率不是很理想,差不多100多毫秒,和上面的批量绘制差不多。 因为用webgl绘制,单次的绘制效率应该不会太差,但是由于需要遍历调用10万次绘制命令,必然效率不高。另外webgl绘制的效果其实是没有2d绘制的效果好的,锯齿严重。 要实现好的效果,还需要引入去锯齿相关技术。 绘制的效果如下:


webgl绘制

用webgl绘制2d图形的相关主题,回头会另外写一篇文章介绍。敬请关注。

webgl2绘制

webgl2 引入了实例化数组,通过这个功能,可以实现把很多次的绘制调用合并为一个绘制调用,这会极大提高绘制效率。

有关实例化数组的功能,参考https://www.jianshu.com/p/d40a8b38adfe

绘制10万个圆形的效率大概在每帧零点零几毫秒,简直就是大boss级别的快,如下图:

WebGL实例化数组绘制

后记

通过这篇文章,除了想给读者传递相关知识点之外,其实还想表达一个观点:
相比于知识点,程序员更加需要锻炼的是底层思维能力。在我看来,底层思维能力包括:学习力、创造力、判断力和思考力。而勤于思考的人,不拘泥于司空见惯,都能够从日常枯燥的任务中发现很多有趣的东西,启发更多深入的思路。
勤于思索是很重要的。 知识是死的,人是活的,同样的知识点,在思考力强的人手上,就能延伸出很多好的解决方案。
这就要求人勤于探索,不要满足于把任务完成,而是要多深入思考,多总结,探索更多的方案和可能性。这本身有助于锻炼思考力和创造力,而思考力和创造力又会反过来帮助你解决更多的问题。

其实IT行业的知识更新越来越快,能够以不变应万变的人,就是拥有良好的学习力、创造力、判断力和思考力的人。这些能力会让你在变换万千的技术海洋中,屹立不倒,不被淹没。

当然,标书可能有点好为人师了。 在日常的工作中,彪叔更喜欢做的事情,就是启迪下属的思考,而不仅仅是某个问题的解决方案。这是比学习知识更加重要的素质。彪叔也会在我的其他文章中,分享底层能力的相关认知。有兴趣的猿们可以关注彪叔的公号:ITman彪叔

欢迎关注公众号:


ITman彪叔公众号
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,529评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,015评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,409评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,385评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,387评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,466评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,880评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,528评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,727评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,528评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,602评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,302评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,873评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,890评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,132评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,777评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,310评论 2 342

推荐阅读更多精彩内容

  • 一:canvas简介 1.1什么是canvas? ①:canvas是HTML5提供的一种新标签 ②:HTML5 ...
    GreenHand1阅读 4,657评论 2 32
  • 一、canvas简介 1.1 什么是canvas?(了解) 是HTML5提供的一种新标签 Canvas是一个矩形区...
    Looog阅读 3,932评论 3 40
  • 神奇且强大的canvas 一.Canvas的基本介绍 1.什么是Canvas 定义:是HTML5提供的一种新标签,...
    Ainy尘世繁花终凋落阅读 10,685评论 1 18
  • 1 Canvas接口元素定义 1.1 getContext()方法 为了在canvas上绘制,你必须先得到一个画布...
    Kevin_Junbaozi阅读 1,281评论 1 2
  • 五年时间能做什么? 其实, 我也不知道。 如果, 有了这五年, 这五年我会慢慢了解一个人, 这五年我会悄悄走进一个...
    尚心yjz阅读 216评论 3 0