2013年的时候曾经使用canvas实现了一个擦除效果的需求,即模拟用户在模糊的玻璃上擦除水雾看到清晰景色的交互效果。好在2012年的时候学习HTML5的时候研究过canvas了,所以在比较短的时间内实现了一个方案【下文方案一】,后来继续探索之后进一步更新了这个方案【下文方案二】,提高了交互的性能,也提升了用户体验。
今年初的另一个项目,提出了一个比较类似的需求,不过不是擦除效果,需要在一张地图上动态显示雾霾驱散的效果,这个交互需求有个小难点,雾霾的边缘是模糊的,而不是常见的那种整齐的。
这里说明一点,用canvas实现擦除 的基本原理是 与视觉效果刚好相反的,从视觉和直觉逻辑上看,擦除就是擦掉表层的图像而显露出底层的图案,但是在技术实现上,刚好相反,需要被擦除的图像如模糊的玻璃是直接显示的,而擦除后显示的清晰图案则是在其上绘制的,看上去就像是擦除了模糊的玻璃。
方案一:持续重绘思路下的擦除
这个方案的思路主要是利用canvas的clip方法,该方法可以在指定的位置以特定的形状来裁剪图片,这样就可以实现蒙版效果,因为该方法在调用的时候需要指定位置,因此要实现根据手指或者鼠标动态地指定不同位置的最直接的思路就是canvas动画的基本思路--持续重绘,就是在一个持续不断的循环中调用该接口,传递给该接口的坐标是手指的实际位置。
HTML结构:
<div> <img src="foo.jpg" style="position:absolute;width:100px; height:100px; left:0px; top:0px;" /> <canvas texsrc="foo.jpg" imgsrc='bar.jpg' width="100" height="100" style='position:absolute;width:100px; height:100px; left:0px; top:0px;background: transparent;'></canvas> </div>
从HTML结构可以看出上面所说的【原理相反】:需要被擦除的图片(foo.jpg)是位于底层的,而擦除后显示的图片(bar.jpg)是位于上层的。因为canvas的background样式设置为了透明,这也就从视觉上欺骗了用户,它其实是在上层,但是因为透明,所以除了绘制的部分,其他部分看不见,形成它在下层的错觉。
主体JS代码如下:
function CanvasDoodle(canvas){ this.canvas=canvas; this.ctx=canvas.getContext("2d"); this.imgSrc=canvas.getAttribute("imgsrc"); this.width=canvas.width; this.height=canvas.height; this.left=parseInt(canvas.style.left); this.top=parseInt(canvas.style.top); this.touchX=0; this.touchY=0; this.requireLoop=false; this.init(); } CanvasDoodle.prototype={ init:function(){ document.body.setAttribute("needRefresh","true"); var _self=this; this.img=new Image(); this.img.src=this.imgSrc; this.canvas.addEventListener('mousedown',function(e){ e.preventDefault(); _self.requireLoop=true; _self.touchX= e.clientX-_self.left,_self.touchY= e.clientY-_self.top; _self.loop(); },false); this.canvas.addEventListener('mousemove',function(e){ e.preventDefault(); if(_self.requireLoop){ _self.touchX= e.clientX-_self.left,_self.touchY= e.clientY-_self.top; } },false); this.canvas.addEventListener('mouseup',function(e){ e.preventDefault(); _self.requireLoop=false; }); }, loop:function(){ if(this.requireLoop){ var _self=this; requesetAnimFrame(function () {_self.loop()}); this.render(); } }, render:function(){ var _self=this; _self.ctx.save(); _self.ctx.beginPath(); _self.ctx.arc(_self.touchX,_self.touchY,15,0,Math.PI*2,true); _self.ctx.clip(); _self.ctx.drawImage(_self.img,0,0,_self.width,_self.height,0,0,_self.width,_self.height); _self.ctx.restore(); } }; new CanvasDoodle(document.getElementById('CanvasDoodle'));
实际效果如图:
代码比较简单,核心部分就是render方法,根据当前鼠标或者手指的位置在canvas的上下文中绘制一个圆形,然后裁剪,这样在下一步drawImage的时候就会在上下文中绘制一个圆形的局部图形而不是整个图片。这样在鼠标或者手指移动的时候就会动态绘制很多小圆,连起来就像是擦除了。
requesetAnimFrame相信大家不会陌生,是个循环调用。这里为了节省性能,设置了一个变量requireLoop来表示是否需要重绘,只有在鼠标按下或者手指接触的时候设置为真,在每个循环中开始重绘canvas(即调用render),结束的时候则设置为假,停止绘制。
这个方案是最原始的方案,有两大缺陷:
一是循环调用,尽管有一个requireLoop可以确定是否重绘,在交互发生的时候始终是在循环,性能并不好;
二是循环调用和用户的交互速度是不同步的,理想状况是手指或者鼠标每发生变化就重绘一次,但是现实并非如此,在非常快速滑动的时候,每次动态获取的坐标并不是紧紧相连的,就造成擦除的效果不是连续的,体验会变差。
方案二:No more loops
方案一的可优化点最明显的就是循环,其两大缺陷都是基于此的。因此方案二的主要思路放弃了clip方法。而是利用了canvas上下文的strokeStyle属性,该属性是指在canvas中绘制矢量图形的时候矢量线的绘制样式,其值可以为 color(颜色值)、 gradient(渐变对象)、 pattern( pattern对象 )。这个方案就是将方案一中的drawImg方式改为将canvas上下文的strokeStyle设置为图片,然后在绘制的时候直接画线就可以了,因为矢量线的背景就是需要展示的图片,这样就实现了擦除的效果。HTML结构不变,JS代码如下:
function CanvasDoodle(canvas){ this.canvas=canvas; this.ctx=canvas.getContext("2d"); this.imgSrc=canvas.getAttribute("imgsrc"); this.width=canvas.width; this.height=canvas.height; this.left=parseInt(canvas.style.left); this.top=parseInt(canvas.style.top); this.touchX=0; this.touchY=0; this.needDraw=false; this.init(); } CanvasDoodle.prototype={ init:function(){ var _self=this; var img=new Image(); img.onload=function(){ var pat=_self.ctx.createPattern(img,"no-repeat"); _self.ctx.strokeStyle=pat; _self.ctx.lineCap="round"; _self.ctx.lineJoin="round"; _self.ctx.lineWidth="25"; } img.src=this.imgSrc; this.canvas.addEventListener('mousedown',function(e){ e.preventDefault(); _self.needDraw=true; _self.ctx.beginPath(); _self.ctx.moveTo(e.clientX-_self.left,e.clientY-_self.top); },false); this.canvas.addEventListener('mousemove',function(e){ e.preventDefault(); if(_self.needDraw){ _self.ctx.lineTo(e.clientX-_self.left,e.clientY-_self.top); _self.ctx.stroke(); } },false); this.canvas.addEventListener('mouseup',function(e){ e.preventDefault(); _self.needDraw=false; }); } }; new CanvasDoodle(document.getElementById('CanvasDoodle'));
可以看到,已经没有循环调用了,只是在初始化的时候就设置strokeStyle为图片,在鼠标移动的时候直接lineTo然后stroke就可以了,即简单,又高效,并且即使快速移动鼠标也不会出现锯齿边缘了,因此这个改进的方案完全替代了方案一。效果如下:
新需求,新方案
相信今年年初的雾霾应该是妇孺皆知的了,因为一位前央视记者自费做了一个长期调查然后做了一次演讲,掀起了轩然大波。这个需求正是在这个时期提出的。希望在地图上动态显示雾霾的驱散效果。
先来点简单的
这里我们先分析另外一个略微简单一些的需求,循序渐进。
稍微变一下,是需要展示雾霾扩散效果,这将会是相对来说比较容易实现的,因为雾霾可以理解为均匀的灰色,即使不是均匀,也可以表示为自圆心向边缘不同程度的灰色渐变,上文说到过canvas的strokeStyle可以设置为渐变的,因此正好利用渐变就可以实现边缘的模糊。(并且还可以给canvas设置一些css3的动画,比如从小变大,或者由暗变明)。
HTML结构基本不变,主体JS 代码如下:
function CanvasFade(canvas){ this.canvas=canvas; this.ctx=canvas.getContext("2d"); this.width=canvas.width; this.height=canvas.height; } CanvasFade.prototype={ draw:function(config){ var _self=this; var cfg=config?config:{x:200,y:200,r:120}; var ratio=cfg.r/2; var grd = _self.ctx.createRadialGradient(cfg.x, cfg.y, 0.000, cfg.x, cfg.y, cfg.r); grd.addColorStop(0.000, 'rgba(255, 0, 0, 0.900)'); grd.addColorStop(0.5, 'rgba(255, 0, 0, 0.600)'); grd.addColorStop(1.0, 'rgba(255, 0, 0, 0.000)'); _self.ctx.fillStyle = grd; _self.ctx.arc(cfg.x, cfg.y, cfg.r,0,Math.PI*2,true); _self.ctx.fill(); } }; var canvasFade=new CanvasFade(Jquery('#theCanvas')[0]); canvasFade.draw({x:100,y:200y,r:20r});
可以看出实现起来非常简单,这段代码是测试使用的,颜色值是红色,看起来就像热区图,要实现雾霾扩散,只需要更改颜色值即可,实际效果如下:
ps. 这里是多次调用了draw的最终效果。
硬骨头
仅从视觉上看,这两个需求非常接近,因此很容易误以为目标就要实现了。程序开发的一大特点就是[There is more to it then meets the eyes]。那些看起来很炫酷的交互对于开发者可能非常容易实现,因为厂商可能在底层已经实现了。而那些看起来很简单的东西,可能需要花费更多的力气,这也常常成为产品人员和开发人员摩擦的一个原因。
我们来分析需求,雾霾本身覆盖在地图上,并不是均匀的(当然也可以简化成均匀的,这里不是主要困难点),主要问题就是雾霾驱散之后显示出来的是没有雾霾覆盖的地图,而不是纯颜色(可以参看文末gif图片)。边缘的模糊效果就很难实现,因为在设置strokeStyle的时候,如果设置为渐变色,很容易实现边缘模糊,但是就只能用颜色值,如果把strokeStyle设置为pattern就可以使用图片,可是这时就没法设置渐变了,边缘就是整齐切割的,无法满足需求,在反复尝试和求助google之后,终于在stackoverflow上找到了一点线索,貌似有个外国哥们也撞上了类似的需求,不过他非常聪明地绕过了strokeStyle这个问题,所以最终的实现方案就是受到他的启发而实现的,并非由我原创的。
先看代码:
function clipArc(ctx, x, y, r, f) { var temp = document.createElement('canvas'), tx = temp.getContext('2d'); temp.width = ctx.canvas.width; temp.height = ctx.canvas.height; tx.translate(-temp.width, 0); tx.shadowOffsetX = temp.width; tx.shadowOffsetY = 0; tx.shadowColor = '#000'; tx.shadowBlur = f; tx.arc(x, y, r, 0, 2 * Math.PI); tx.closePath(); tx.fill(); ctx.save(); ctx.globalCompositeOperation = 'destination-in'; ctx.drawImage(temp, 0, 0); ctx.restore(); } function CanvasFade(canvas){ this.canvas=canvas; this.ctx=canvas.getContext("2d"); this.imgSrc=canvas.getAttribute("imgSrc"); this.width=canvas.width; this.height=canvas.height; } CanvasFade.prototype={ init:function(config){ var _self=this; var cfg=config?config:{x:100,y:100,r:120,f:40}; var img=new Image(); img.onload=function(){ var pat=_self.ctx.createPattern(img,"no-repeat"); _self.ctx.fillStyle=pat; _self.ctx.fillRect(0, 0, _self.width, _self.height); clipArc(_self.ctx, cfg.x, cfg.y, cfg.r, cfg.f); }; img.src=this.imgSrc; } }; var c=document.querySelector('#theCanvas'); var cf=new CanvasFade(c); cf.init();
这里的秘密武器就是利用了shadow,即阴影,在canvas里边绘制图形的时候,可以给图形添加阴影,而阴影可以有边缘模糊的效果。这里在实际绘制的时候,先创建了一个过渡canvas(这个canvas本身并不绘制图形,主要起模糊剪切的作用),将这个canvas向左平移了一个宽度,这样它就移出了当前canvas的可视范围,然而精妙之处在于它上下文的shadowOffsetX设置为了向右一个宽度,这样其内部任何图形的阴影刚好又落在了当前canvas的正确位置,这里设置了它的阴影颜色为黑色,但是有一定的羽化效果(tx.shadowBlur = f),另一个秘密武器就是globalCompositeOperation,这个属性用来设定如何将一个源(新的)图像绘制到目标(已有的)的图像上,其详细信息可以参考http://www.html5canvastutorials.com/advanced/html5-canvas-global-composite-operations-tutorial/ ,实际效果如下图:
可以看出实际效果还是非常不错的。并且动画的形式可以是更多样的,而且这种形式也可以有更多的变种,以满足更广泛的需求。
至此,利用canvas制作擦除和扩散效果的方法介绍完毕了。欢迎批评指正^_^