使用 Canvas 实现「擦一擦」效果

1,874 阅读4分钟

一、背景


根据产品需求,需要实现如下效果。(由于 GIF 截图不是很清晰,所以没法记录完整的鼠标轨迹)

在移动设备上,手机按压屏幕且移动时可以擦除模糊,露出下面清晰的图片 captured.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()

image.png 效果是在图上画了一个红色的圆。而我们所希望的效果是画笔画过的部分被擦除

canvas 有一个神奇的属性 globalCompositeOperation

每次在 canvas 上绘制新的一笔的时候,实际上最多会存在三种区域的碰撞。以上图为例子:

  • pending 区域:图中白色部分,未填充任何图像。等同于 div 等其他元素的背景部分,默认透明
  • souce 区域:图中蓝色部分,在绘制前已经存在图像的部分
  • destination 区域:图中红色部分,当前新绘制的部分

globalCompositeOperation 的默认属性为 source-over,即 destination 区域将覆盖 souce 区域

而实际上有超过 20 个值,常用的 12 个效果如下:

image.png

全部值:developer.mozilla.org/zh-CN/docs/…

而我们要的擦除效果刚好是 destination-out,所以同样使用上面的代码,在画一笔之前加入如下代码之后就可以得到我们想要的结果

ctx.globalCompositeOperation = 'destination-out'

image.png

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')
  }
}

captured (2).gif

四、设置原图


接下来我们把蓝色底图设置为自己需要的图片。我们在 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')

captured (3).gif

五、高斯模糊


终于只差最后一步了。在给图片高斯模糊之前,我们先要用 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;
}

captured (4).gif

完美 ~

六、完善


这只是最简单的实现。我们可能还需要计算每次擦一擦后的图像比例(比如擦除指定比例像素后执行特定方法)。还需要暴露各种参数让擦一擦成为一个完整的 SDK

这些都比较简单,在此就不赘述了

参考: