最近项目刚好用到这种效果,也就是有点像刮刮卡一样,在移动设备上,把某张图片刮掉显示出另一张图片。效果图如下:
DEMO请戳右:DEMO
这种在网上还是挺常见的,本来就想直接网上找个demo套用下他的方法就行了,套用了才发现,在android上卡出翔了,因为客户要求,在android不要求特别流畅,至少要能玩,但是网上找的那个demo实在太卡,根本就是没法玩的情况。于是就想自己写一个算了,本文也就权当记录一下研究过程。
这种刮图的效果,首先想到就是用HTML5的canvas来实现,而canvas的API中,可以清除像素的就是clearRect方法,但是clearRect方法的清除区域矩形,毕竟大部分人的习惯中的橡皮擦都是圆形的,所以就引入了剪辑区域这个强大的功能,也就是clip方法。用法很简单:
1.
ctx.save()
2.
ctx.beginPath()
3.
ctx.arc(x2,y2,a,
0
,
2
*Math.PI);
4.
ctx.clip()
5.
ctx.clearRect(
0
,
0
,canvas.width,canvas.height);
6.
ctx.restore();
上面那段代码就实现了圆形区域的擦除,也就是先实现一个圆形路径,然后把这个路径作为剪辑区域,再清除像素就行了。有个注意点就是需要先保存绘图环境,清除完像素后要重置绘图环境,如果不重置的话以后的绘图都是会被限制在那个剪辑区域中。
擦除效果有了,现在就是写鼠标移动擦除的效果了,下面我均用鼠标来描述,因为移动端也差不多,就是把mousedown换成touchstart,mousemove换成touchmove,mouseup换成touchend、以及获取坐标点由e.clientX换成e.targetTouches[0].pageX而已。
实现鼠标移动擦除,刚开始就是想到鼠标移动时在触发的mousemove事件中对鼠标所在位置进行圆形区域擦除,写出来后发现,当鼠标移动速度很快的时候,擦除的区域就不连贯了,就会出现下面这种效果,这显然不是我们想要的橡皮擦擦除效果。
既然所有点不连贯,那接下来要做的事就是把这些点连贯起来,如果是实现画图功能的话,就可以直接通过lineTo把两点之间连接起来再绘制,但是擦除效果中的剪辑区域要求要是闭合路径,如果是单纯的把两个点连起来就无法形成剪辑区域了。然后我就想到用计算的方法,算出两个擦除区域中的矩形四个端点坐标来实现,也就是下图中的红色矩形:
计算方法也很简单,因为可以知道两个剪辑区域连线两个端点的坐标,又知道我们要多宽的线条,矩形的四个端点坐标就变得容易求了,所以就有了下面的代码:
01.
var asin = a*Math.sin(Math.atan((y2-y1)/(x2-x1)));
02.
var acos = a*Math.cos(Math.atan((y2-y1)/(x2-x1)))
03.
var x3 = x1+asin;
04.
var y3 = y1-acos;
05.
var x4 = x1-asin;
06.
var y4 = y1+acos;
07.
var x5 = x2+asin;
08.
var y5 = y2-acos;
09.
var x6 = x2-asin;
10.
var y6 = y2+acos;
x1、y1和x2、y2就是两个端点,从而求出了四个端点的坐标。这样一来,剪辑区域就是圈加矩形,代码组织起来就是:
01.
var hastouch =
"ontouchstart"
in window?
true
:
false
,
//判断是否为移动设备
02.
tapstart = hastouch?
"touchstart"
:
"mousedown"
,
03.
tapmove = hastouch?
"touchmove"
:
"mousemove"
,
04.
tapend = hastouch?
"touchend"
:
"mouseup"
;
05.
06.
canvas.addEventListener(tapstart , function(e){
07.
e.preventDefault();
08.
09.
x1 = hastouch?e.targetTouches[
0
].pageX:e.clientX-canvas.offsetLeft;
10.
y1 = hastouch?e.targetTouches[
0
].pageY:e.clientY-canvas.offsetTop;
11.
<br>
//鼠标第一次点下的时候擦除一个圆形区域,同时记录第一个坐标点
12.
ctx.save()
13.
ctx.beginPath()
14.
ctx.arc(x1,y1,a,
0
,
2
*Math.PI);
15.
ctx.clip()
16.
ctx.clearRect(
0
,
0
,canvas.width,canvas.height);
17.
ctx.restore();
18.
19.
canvas.addEventListener(tapmove , tapmoveHandler);
20.
canvas.addEventListener(tapend , function(){
21.
canvas.removeEventListener(tapmove , tapmoveHandler);
22.
});<br>
//鼠标移动时触发该事件
23.
function tapmoveHandler(e){
24.
e.preventDefault()
25.
x2 = hastouch?e.targetTouches[
0
].pageX:e.clientX-canvas.offsetLeft;
26.
y2 = hastouch?e.targetTouches[
0
].pageY:e.clientY-canvas.offsetTop;
27.
<br>
//获取两个点之间的剪辑区域四个端点
28.
var asin = a*Math.sin(Math.atan((y2-y1)/(x2-x1)));
29.
var acos = a*Math.cos(Math.atan((y2-y1)/(x2-x1)))
30.
var x3 = x1+asin;
31.
var y3 = y1-acos;
32.
var x4 = x1-asin;
33.
var y4 = y1+acos;
34.
var x5 = x2+asin;
35.
var y5 = y2-acos;
36.
var x6 = x2-asin;
37.
var y6 = y2+acos;
38.
<br>
//保证线条的连贯,所以在矩形一端画圆
39.
ctx.save()
40.
ctx.beginPath()
41.
ctx.arc(x2,y2,a,
0
,
2
*Math.PI);
42.
ctx.clip()
43.
ctx.clearRect(
0
,
0
,canvas.width,canvas.height);
44.
ctx.restore();
45.
<br>
//清除矩形剪辑区域里的像素
46.
ctx.save()
47.
ctx.beginPath()
48.
ctx.moveTo(x3,y3);
49.
ctx.lineTo(x5,y5);
50.
ctx.lineTo(x6,y6);
51.
ctx.lineTo(x4,y4);
52.
ctx.closePath();
53.
ctx.clip()
54.
ctx.clearRect(
0
,
0
,canvas.width,canvas.height);
55.
ctx.restore();
56.
<br>
//记录最后坐标
57.
x1 = x2;
58.
y1 = y2;
59.
}
60.
})
如此一来,鼠标擦除的效果就实现了,不过还有一个要实现的点,就是大部分擦除的效果,当你擦了一定数量的像素后,就会自动把所有图片内容呈现出来,这个效果,我是用imgData来实现的。代码如下:
01.
var imgData = ctx.getImageData(
0
,
0
,canvas.width,canvas.height);
02.
var dd =
0
;
03.
for
(var x=
0
;x<imgData.width;x+=
1
){
04.
for
(var y=
0
;y<imgData.height;y+=
1
){
05.
var i = (y*imgData.width + x)*
4
;
06.
if
(imgData.data[i+
3
] >
0
){
07.
dd++
08.
}
09.
}
10.
}
11.
if
(dd/(imgData.width*imgData.height)<
0.4
){
12.
canvas.className =
"noOp"
;
13.
}
获取到imgData,对imgData里的像素进行遍历,然后再对imgData的data数组里的rgba中的alpha进行分析,也就是分析透明度,如果像素被擦除了,那透明度就是0了,也就是把当前画布中透明度不为0的像素的数量跟画布总像素数进行比较,如果透明度不为0 的像素数比例低于40%,那说明当前画布上就以后有百分六十以上的区域被擦除了,就可以自动呈现图片了。
此处注意,我是把检查像素这段代码方法mouseup事件里面的,因为这个计算量相对来说还是不小,如果用户狂点鼠标,就会狂触发mouseup事件,那就会让浏览器崩溃了,缓解办法如下:加个timeout,延迟执行像素计算,而在每一次点击的时候再清除timeout,也就是如果用户点击很快,这个计算也就触发不了了,还有一个提升的办法就是抽样检查,我上面的写法是逐个像素检查,逐个像素检查的话像素量太大,肯定会卡的,所以可以采用抽样检查,比如每隔30个像素检查一次,修改后的代码如下:
01.
timeout = setTimeout(function(){
02.
var imgData = ctx.getImageData(
0
,
0
,canvas.width,canvas.height);
03.
var dd =
0
;
04.
for
(var x=
0
;x<imgData.width;x+=
30
){
05.
for
(var y=
0
;y<imgData.height;y+=
30
){
06.
var i = (y*imgData.width + x)*
4
;
07.
if
(imgData.data[i+
3
] >
0
){
08.
dd++
09.
}
10.
}
11.
}
12.
if
(dd/(imgData.width*imgData.height/
900
)<
0.4
){
13.
canvas.className =
"noOp"
;
14.
}
15.
},
100
)
这样就可以较大限度的防止用户狂点击了,如果有其他更好的检查方法欢迎给出意见,谢谢。
到了这一步就都写完了,然后就是测试的时候了,结果并不乐观,在android上还是卡啊卡啊,所以又得另想办法,最终发现了绘图环境中的globalCompositeOperation这个属性,这个属性的默认值是source-over,也就是,当你在已有像素上进行绘图时会叠加,但是还有一个属性是destination-out,官方解释就是:在源图像外县市目标图像。只有源图像外的目标图像部分才会被显示,源图像是透明的。好像不太好理解,但是其实自己测试一下就会发现很简单,也就是在已有像素的基础上进行绘图时,你绘制的区域里的已有像素都会被置为透明,直接看张图更容易理解:
globalCompositeOperation属性效果图解。
有了这个属性后,就意味着不需要用到clip,也就不需要用sin、cos什么的计算剪辑区域,直接用条粗线就行了,这样一来就能够很大限度的降低了计算量,同时减少了绘图环境API的调用,性能提升了,在android上运行应该也会流畅很多,下面是修改后的代码:
01.
//通过修改globalCompositeOperation来达到擦除的效果
02.
function tapClip(){
03.
var hastouch =
"ontouchstart"
in window?
true
:
false
,
04.
tapstart = hastouch?
"touchstart"
:
"mousedown"
,
05.
tapmove = hastouch?
"touchmove"
:
"mousemove"
,
06.
tapend = hastouch?
"touchend"
:
"mouseup"
;
07.
08.
canvas.addEventListener(tapstart , function(e){<br> clearTimeout(timeout)
09.
e.preventDefault();
10.
11.
x1 = hastouch?e.targetTouches[
0
].pageX:e.clientX-canvas.offsetLeft;
12.
y1 = hastouch?e.targetTouches[
0
].pageY:e.clientY-canvas.offsetTop;
13.
14.
ctx.lineCap =
"round"
;
//设置线条两端为圆弧
15.
ctx.lineJoin =
"round"
;
//设置线条转折为圆弧
16.
ctx.lineWidth = a*
2
;
17.
ctx.globalCompositeOperation =
"destination-out"
;
18.
19.
ctx.save();
20.
ctx.beginPath()
21.
ctx.arc(x1,y1,
1
,
0
,
2
*Math.PI);
22.
ctx.fill();
23.
ctx.restore();
24.
25.
canvas.addEventListener(tapmove , tapmoveHandler);
26.
canvas.addEventListener(tapend , function(){
27.
canvas.removeEventListener(tapmove , tapmoveHandler);
28.
<br> timeout = setTimeout(function(){
29.
var imgData = ctx.getImageData(
0
,
0
,canvas.width,canvas.height);
30.
var dd =
0
;
31.
for
(var x=
0
;x<imgData.width;x+=
30
){
32.
for
(var y=
0
;y<imgData.height;y+=
30
){
33.
var i = (y*imgData.width + x)*
4
;
34.
if
(imgData.data[i+
3
] >
0
){
35.
dd++
36.
}
37.
}
38.
}
39.
if
(dd/(imgData.width*imgData.height/
900
)<
0.4
){
40.
canvas.className =
"noOp"
;
41.
}<br> },
100
)
42.
});
43.
function tapmoveHandler(e){
44.
e.preventDefault()
45.
x2 = hastouch?e.targetTouches[
0
].pageX:e.clientX-canvas.offsetLeft;
46.
y2 = hastouch?e.targetTouches[
0
].pageY:e.clientY-canvas.offsetTop;
47.
48.
ctx.save();
49.
ctx.moveTo(x1,y1);
50.
ctx.lineTo(x2,y2);
51.
ctx.stroke();
52.
ctx.restore()
53.
54.
x1 = x2;
55.
y1 = y2;
56.
}
57.
})
58.
}
擦除那部分代码就这么一点,也就相当于画图功能,直接设置line属性后通过lineTo进行绘制线条,只要事前把globalCompositeOperation设成destination-out,你所进行的一切绘制,都变成了擦除效果。鼠标滑动触发的事件里面代码也少了很多,绘图对象的调用次数减少了,计算也减少了,性能提升大大滴。
改好代码后就立即用自己的android机子测试了一下,果然如此,跟上一个相比,流畅了很多,至少达到了客户要求的能玩的地步了。