一、背景
根据产品需求,需要实现如下效果。(由于 GIF 截图不是很清晰,所以没法记录完整的鼠标轨迹)
在移动设备上,手机按压屏幕且移动时可以擦除模糊,露出下面清晰的图片
二、思路
关于图像处理,在前端基本也就靠 Canvas 了。咱们直接开始:
分析需求,要实现该功能,依次有 3 个步骤
- 设置原图 A
- 在原图基础上再放置一个模糊后的原图 B
- 添加逻辑,擦除 B 之后可以露出 A
甚至可以考虑组件更广泛的使用,比如刮刮乐之类的。所以组件功能可以抽象为「擦除 Canvas 上原有的图像」
三、跟随擦除
首先实现「擦除 B 之后可以露出 A」的步骤
3.1 擦除
先实现一个最简单的擦除效果
<canvas style="border: 1px solid;" width="400" height="400"></canvas>
复制代码
const canvasElm = document.getElementsByTagName('canvas')[0]
const ctx = canvasElm.getContext('2d')
// canvas 填充满蓝色
ctx.rect(0, 0, canvasElm.clientWidth / 2, canvasElm.clientHeight / 2)
ctx.fillStyle = 'blue'
ctx.fill()
// 设置一个圆形画笔
ctx.lineCap = ctx.lineJoin = 'round'
ctx.lineWidth = 50
// 定位 canvas 正中央
const x = canvasElm.clientWidth / 2
const y = canvasElm.clientHeight / 2
// 画一笔,并设置颜色为红色
ctx.strokeStyle = 'red'
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(x, y)
ctx.stroke()
复制代码
效果是在图上画了一个红色的圆。而我们所希望的效果是画笔画过的部分被擦除
canvas 有一个神奇的属性 globalCompositeOperation
每次在 canvas 上绘制新的一笔的时候,实际上最多会存在三种区域的碰撞。以上图为例子:
- pending 区域:图中白色部分,未填充任何图像。等同于
div
等其他元素的背景部分,默认透明 - souce 区域:图中蓝色部分,在绘制前已经存在图像的部分
- destination 区域:图中红色部分,当前新绘制的部分
globalCompositeOperation
的默认属性为 source-over
,即 destination 区域将覆盖 souce 区域
而实际上有超过 20 个值,常用的 12 个效果如下:
而我们要的擦除效果刚好是 destination-out
,所以同样使用上面的代码,在画一笔之前加入如下代码之后就可以得到我们想要的结果
ctx.globalCompositeOperation = 'destination-out'
复制代码
3.2 跟随
接下来我们实现跟随效果。一次擦一擦可以分解为三个动作
- touchstart – 手指开始碰到 canvas
- touchmove – 手指移动中
- touchend – 手指离开 canvas
3.2.1 底图
先设置好底图
const canvasElm = document.getElementsByTagName('canvas')[0]
const ctx = canvasElm.getContext('2d')
// canvas 填充满蓝色
ctx.rect(0, 0, canvasElm.clientWidth, canvasElm.clientHeight)
ctx.fillStyle = 'blue'
ctx.fill()
// 设置一个圆形画笔
ctx.lineCap = ctx.lineJoin = 'round'
ctx.lineWidth = 100
ctx.globalCompositeOperation = 'destination-out'
复制代码
3.2.2 工具
几个工具方法
// 获取当前 touch 的 canvas 坐标
getClipArea(e) {
let x = e.targetTouches[0].pageX;
let y = e.targetTouches[0].pageY;
let ndom = this.canvas;
while (ndom && ndom.tagName !== 'BODY') {
x -= ndom.offsetLeft;
y -= ndom.offsetTop;
ndom = ndom.offsetParent;
}
return {
x,
y
};
}
复制代码
// 一点、两点之间划线
drawLine(pos,ctx) {
const { x, y, xEnd, yEnd } = pos
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(xEnd || x, yEnd || y)
ctx.stroke()
}
复制代码
3.2.3 事件
因为是 touch 事件,需要切换浏览器到移动端模式
// 移动 begin
canvasElm.ontouchstart = e => {
e.preventDefault()
const pos = {
...getClipArea(e)
}
drawLine(pos, ctx)
// 移动 ing
canvasElm.ontouchmove = e => {
e.preventDefault()
const { x: xEnd, y: yEnd } = getClipArea(e)
Object.assign(pos, { xEnd, yEnd })
drawLine(pos, ctx)
Object.assign(pos, { x: xEnd, y: yEnd })
}
// 移动 end
canvasElm.ontouchend = () => {
console.log('ontouched')
}
}
复制代码
四、设置原图
接下来我们把蓝色底图设置为自己需要的图片。我们在 css 设置 background-image 之后,通常还会设置 position、size 等一系列属性。为了方便理解,我们默认背景图属性如下
canvas {
background-size: cover;
background-position: center;
}
复制代码
所以为了和 background-image 完全重合,在 canvas 上填充图片时也需要达到同样的效果
function fillImageWithCoverAndCenter(src) {
const img = new Image()
img.src = src
img.onload = () => {
const imageAspectRatio = img.width / img.height
const canvasAspectRatio = canvasElm.width / canvasElm.height
// 根据图片比例和 canvas 比例的大小关系,使用不同的规则
if (imageAspectRatio > canvasAspectRatio) {
const imageWidth = canvasElm.height * imageAspectRatio
ctx.drawImage(
img,
(canvasElm.width - imageWidth) / 2,
0,
imageWidth,
canvasElm.height
)
} else {
const imageHeight = canvasElm.width / imageAspectRatio
ctx.drawImage(
img,
0,
(canvasElm.height - imageHeight) / 2,
canvasElm.width,
imageHeight
)
}
ctx.globalCompositeOperation = 'destination-out'
}
}
fillImageWithCoverAndCenter('./girl.jpg')
复制代码
五、高斯模糊
终于只差最后一步了。在给图片高斯模糊之前,我们先要用 getImageData 方法获取所有的像素点信息。
获取到的像素点是以 rgba 的方式存储的
- R – 红色 (0-255)
- G – 绿色 (0-255)
- B – 蓝色 (0-255)
- A – alpha 通道 (0-255; 0 是透明的,255 是完全可见的)
而且信息完全平铺在一个一维数组中:[0,0,0,255, 255,255,255,255, 0,0,255,255 ...]
高斯模糊的原理在这就不赘述了,大致就是每一个像素都取周边像素的平均值。
高斯模糊的算法 – 阮一峰 www.ruanyifeng.com/blog/2012/1…
// 这个方法一定要在 image.onload 中执行
// 也就时之前填充图片之后,设置 globalCompositeOperation 之前
function gaussBlur() {
// 获取像素信息
const imgData = ctx.getImageData(
0,
0,
canvasElm.width,
canvasElm.height
)
// 模糊强度
const sigma = 10
// 模糊半径
const radius = 10
const pixes = imgData.data
const width = imgData.width
const height = imgData.height
const gaussMatrix = []
const a = 1 / (Math.sqrt(2 * Math.PI) * sigma)
const b = -1 / (2 * sigma * sigma)
let gaussSum = 0
// 生成高斯矩阵
for (let i = 0, x = -radius; x <= radius; x++, i++) {
const g = a * Math.exp(b * x * x)
gaussMatrix[i] = g
gaussSum += g
}
// 归一化, 保证高斯矩阵的值在[0,1]之间
for (let i = 0, len = gaussMatrix.length; i < len; i++) {
gaussMatrix[i] /= gaussSum
}
const B_LIST_LENGTH = 3
// x 方向一维高斯运算
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const bList = new Array(B_LIST_LENGTH).fill(0)
gaussSum = 0
for (let j = -radius; j <= radius; j++) {
const k = x + j
if (k >= 0 && k < width) {
// 确保 k 没超出 x 的范围
// r,g,b,a 四个一组
const i = (y * width + k) * 4
for (let l = 0; l < bList.length; l++) {
bList[l] += pixes[i + l] * gaussMatrix[j + radius]
}
gaussSum += gaussMatrix[j + radius]
}
}
const i = (y * width + x) * 4
// 除以 gaussSum 是为了消除处于边缘的像素, 高斯运算不足的问题
for (let l = 0; l < bList.length; l++) {
pixes[i + l] = bList[l] / gaussSum
}
}
}
// y 方向一维高斯运算
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const bList = new Array(B_LIST_LENGTH).fill(0)
gaussSum = 0
for (let j = -radius; j <= radius; j++) {
const k = y + j
if (k >= 0 && k < height) {
// 确保 k 没超出 y 的范围
const i = (k * width + x) * 4
for (let l = 0; l < bList.length; l++) {
bList[l] += pixes[i + l] * gaussMatrix[j + radius]
}
gaussSum += gaussMatrix[j + radius]
}
}
const i = (y * width + x) * 4
for (let l = 0; l < bList.length; l++) {
pixes[i + l] = bList[l] / gaussSum
}
}
}
// 填充模糊之后的图像
ctx.putImageData(imgData, 0, 0)
}
复制代码
之后再给 canvas 设置好背景
canvas {
background-image: url(./girl.jpg);
background-size: cover;
background-position: center;
}
复制代码
完美 ~
六、完善
这只是最简单的实现。我们可能还需要计算每次擦一擦后的图像比例(比如擦除指定比例像素后执行特定方法)。还需要暴露各种参数让擦一擦成为一个完整的 SDK
这些都比较简单,在此就不赘述了
参考:
- HTML5 实现橡皮擦的擦除效果 – www.cnblogs.com/axes/p/3850…
- 基于H5canvas和js的高斯模糊处理 – www.jianshu.com/p/e3142f0ed…