用HTML5 Canvas 做擦除及擴散效果
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/ ,實際效果如下圖:
可以看出實際效果還是非常不錯的。並且動畫的形式可以是更多樣的,而且這種形式也可以有更多的變種,以滿足更廣泛的需求。