canvas画板之画笔的多种效果

前言

我之前做了一个画板,已经迭代了两个版本,但既然是画板,如果只有一种画笔就显得太单调了,我就搜罗了一下网上的各种方案和自己的一些想法,目前做出了5种样式,包括基础的总共6种,当然有了一些思路后,后续会继续增加。我会在本文详细说明实现思路和具体代码,6种样式包括:

  1. 基础单色
  2. 荧光
  3. 多色画笔
  4. 喷雾
  5. 蜡笔
  6. 泡泡

预览

预览地址:https://songlh.top/paint-board/
源码:https://github.com/LHRUN/paint-board 欢迎Star⭐️

基础单色

画笔的基础实现,除了点与点之间的连接,还需要注意两点

  1. 首先是在鼠标移动时计算当前移动的速度,然后根据速度计算线宽,这个是为了实现鼠标移动快,线宽就变窄,移动慢,线宽就恢复正常这个效果
  2. 为了避免直线连接点效果不好,我会采用贝塞尔曲线进行连接

/**
 * 鼠标移动时添加新的坐标
 * @param position
 */
addPosition(position: MousePosition) {
  this.positions.push(position)
  // 处理当前线宽
  if (this.positions.length > 1) {
    // 计算移动速度
    const mouseSpeed = this._computedSpeed(
      this.positions[this.positions.length - 2],
      this.positions[this.positions.length - 1]
    )
    // 计算线宽
    const lineWidth = this._computedLineWidth(mouseSpeed)
    this.lineWidths.push(lineWidth)
  }
}

/**
 * 计算移动速度
 * @param start 起点
 * @param end 终点
 */
_computedSpeed(start: MousePosition, end: MousePosition) {
  // 获取距离
  const moveDistance = getDistance(start, end)

  const curTime = Date.now()
  // 获取移动间隔时间   lastMoveTime:最后鼠标移动时间
  const moveTime = curTime - this.lastMoveTime
  // 计算速度
  const mouseSpeed = moveDistance / moveTime
  // 更新最后移动时间
  this.lastMoveTime = curTime
  return mouseSpeed
}

/**
 * 计算画笔宽度
 * @param speed 鼠标移动速度
 */
_computedLineWidth(speed: number) {
  let lineWidth = 0
  const minWidth = this.minWidth
  const maxWidth = this.maxWidth
  if (speed >= this.maxSpeed) {
    lineWidth = minWidth
  } else if (speed <= this.minSpeed) {
    lineWidth = maxWidth
  } else {
    lineWidth = maxWidth - (speed / this.maxSpeed) * maxWidth
  }

  lineWidth = lineWidth * (1 / 3) + this.lastLineWidth * (2 / 3)
  this.lastLineWidth = lineWidth
  return lineWidth
}

渲染时就遍历所有坐标

/**
 * 自由画笔渲染
 * @param context canvas二维渲染上下文
 * @param instance FreeDraw
 */
function freeDrawRender(
  context: CanvasRenderingContext2D,
  instance: FreeLine
) {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // 现在是只有基础画笔,后续会增加不同的case
    case FreeDrawStyle.Basic:
      context.strokeStyle = instance.colors[0]
      break
    default:
      break
  }
  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      case FreeDrawStyle.Basic:
        _drawBasic(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * 绘制基础线条
 * @param instance FreeDraw 实例
 * @param i 下标
 * @param context canvas二维渲染上下文
 * @param cb 一些绘制前的处理,修改一些样式
 * 
 * 画笔轨迹是借鉴了网上的一些方案,分两种情况
 * 1. 如果是前两个坐标,就通过lineTo连接即可
 * 2. 如果是前两个坐标之后的坐标,就采用贝塞尔曲线进行连接,
 *    比如现在有a, b, c 三个点,到c点时,把ab坐标的中间点作为起点
 *     bc坐标的中间点作为终点,b点作为控制点进行连接
 */
function _drawBasic(
  instance: FreeLine,
  i: number,
  context: CanvasRenderingContext2D
  cb?: (
    instance: FreeDraw,
    i: number,
    context: CanvasRenderingContext2D
  ) => void
) {
  const { positions, lineWidths } = instance
  const { x: centerX, y: centerY } = positions[i - 1]
  const { x: endX, y: endY } = positions[i]
  context.beginPath()
  if (i == 1) {
    context.moveTo(centerX, centerY)
    context.lineTo(endX, endY)
  } else {
    const { x: startX, y: startY } = positions[i - 2]
    const lastX = (startX + centerX) / 2
    const lastY = (startY + centerY) / 2
    const x = (centerX + endX) / 2
    const y = (centerY + endY) / 2
    context.moveTo(lastX, lastY)
    context.quadraticCurveTo(centerX, centerY, x, y)
  }

  context.lineWidth = lineWidths[i]
  cb?.(instance, i, context)
  context.stroke()
}

荧光

荧光只需在基础样式上增加一个阴影即可

function freeDrawRender(
  context: CanvasRenderingContext2D,
  instance: FreeLine
) {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // 荧光 增加阴影效果
    case FreeDrawStyle.Shadow:
      context.shadowColor = instance.colors[0]
      context.strokeStyle = instance.colors[0]
      break
    default:
      break
  }
  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // 荧光
      case FreeDrawStyle.Shadow:
        _drawBasic(instance, i, context, (instance, i, context) => {
          context.shadowBlur = instance.lineWidths[i]
        })
        break
      default:
        break
    }
  }
  context.restore()
}

多色画笔

多色画笔需要使用context.createPattern,这个api是可以通过canvas创建一个指定的模版,然后可以让这个模版在指定的方向上重复元图像,具体使用可以看MDN

/**
 * 自由画笔渲染
 * @param context canvas二维渲染上下文
 * @param instance FreeDraw
 * @param material 画笔素材
 */
export const freeDrawRender = (
  context: CanvasRenderingContext2D,
  instance: FreeDraw,
  material: Material
) => {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // 多色画笔
    case FreeDrawStyle.MultiColor:
      context.strokeStyle = getMultiColorPattern(instance.colors)
      break
    default:
      break
  }

  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // 多色画笔
      case FreeDrawStyle.MultiColor:
        _drawBasic(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * 获取多色模版
 * @param colors 多色数组
 */
const getMultiColorPattern = (colors: string[]) => {
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d') as CanvasRenderingContext2D
  const COLOR_WIDTH = 5 // 每个颜色的宽度

  canvas.width = COLOR_WIDTH * colors.length
  canvas.height = 20
  colors.forEach((color, i) => {
    context.fillStyle = color
    context.fillRect(COLOR_WIDTH * i, 0, COLOR_WIDTH, 20)
  })
  return context.createPattern(canvas, 'repeat') as CanvasPattern
}

喷雾

喷雾是一种类似雪花的效果,在鼠标移动路径上随机绘制,但是最初我在写的时候发现,如果对每个点都进行随机雪花点记录然后缓存下来,内存占用过多,我就尝试了提前生成5套不同的数据,按顺序展示,也能达到随机的效果

export const freeDrawRender = (
  context: CanvasRenderingContext2D,
  instance: FreeDraw,
  material: Material
) => {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // 喷雾
    case FreeDrawStyle.Spray:
      context.fillStyle = instance.colors[0]
      break
    default:
      break
  }

  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // 喷雾
      case FreeDrawStyle.Spray:
        _drawSpray(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * 绘制喷雾
 * @param instance FreeDraw 实例
 * @param i 下标
 * @param context canvas二维渲染上下文
 */
const _drawSpray = (
  instance: FreeDraw,
  i: number,
  context: CanvasRenderingContext2D
) => {
  const { x, y } = instance.positions[i]
  for (let j = 0; j < 50; j++) {
    /**
     * sprayPoint 是我提前生成的5套随机喷雾数据,按顺序展示
     * {
     *    angle 弧度
     *    radius 半径
     *    alpha 透明度
     * }
     */
    const { angle, radius, alpha } = sprayPoint[i % 5][j]
    context.globalAlpha = alpha
    const distanceX = radius * Math.cos(angle)
    const distanceY = radius * Math.sin(angle)
    // 根据宽度限制喷雾宽度,因为喷雾太细了不好看,我就统一放大一倍
    if (
      distanceX < instance.lineWidths[i] * 2 &&
      distanceY < instance.lineWidths[i] * 2 &&
      distanceX > -instance.lineWidths[i] * 2 &&
      distanceY > -instance.lineWidths[i] * 2
    ) {
      context.fillRect(x + distanceX, y + distanceY, 2, 2)
    }
  }
}

蜡笔

蜡笔效果也是使用了context.createPattern,首先我是以当前画笔颜色为底色,然后通过在网上找的一张蜡笔材质的透明图覆盖在上面,就可以实现蜡笔的效果

/**
 * 自由画笔渲染
 * @param context canvas二维渲染上下文
 * @param instance FreeDraw
 * @param material 画笔素材
 */
export const freeDrawRender = (
  context: CanvasRenderingContext2D,
  instance: FreeDraw,
  material: Material
) => {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // 蜡笔
    case FreeDrawStyle.Crayon:
      context.strokeStyle = getCrayonPattern(
        instance.colors[0],
        material.crayon
      )
      break
    default:
      break
  }

  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // 蜡笔
      case FreeDrawStyle.Crayon:
        _drawBasic(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * 获取蜡笔模版
 * @param color 蜡笔底色
 * @param crayon 蜡笔素材
 */
const getCrayonPattern = (color: string, crayon: Material['crayon']) => {
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d') as CanvasRenderingContext2D
  canvas.width = 100
  canvas.height = 100
  context.fillStyle = color
  context.fillRect(0, 0, 100, 100)
  if (crayon) {
    context.drawImage(crayon, 0, 0, 100, 100)
  }
  return context.createPattern(canvas, 'repeat') as CanvasPattern
}

泡泡

  1. 鼠标移动时记录泡泡的半径和透明度
  2. 渲染时通过context.arc进行画圆绘制

addPosition(position: MousePosition) {
  // ...
  // 记录泡泡半径和透明度
  if (this.style === FreeDrawStyle.Bubble && this.bubbles) {
    this.bubbles.push({
      // getRandomInt 获取范围内随机整数
      radius: getRandomInt(this.minWidth * 2, this.maxWidth * 2),
      // 透明度
      opacity: Math.random()
    })
  }
  // ...
}

/**
 * 绘制泡泡
 * @param instance FreeDraw 实例
 * @param i 下标
 * @param context canvas二维渲染上下文
 */
const _drawBubble = (
  instance: FreeDraw,
  i: number,
  context: CanvasRenderingContext2D
) => {
  context.beginPath()
  if (instance.bubbles) {
    const { x, y } = instance.positions[i]
    context.globalAlpha = instance.bubbles[i].opacity
    context.arc(x, y, instance.bubbles[i].radius, 0, Math.PI * 2, false)
    context.fill()
  }
}

总结

如果有发现问题或者有好的方案,欢迎讨论👻

画板系列文章:

参考资料