Skip to content

canvas 入门 [2023-03-01]

抖音:

  1. canvas 的意义
  2. canvas 基本绘图
  3. 图像清晰度
  4. 动画
  5. 文字也能很酷炫
  6. 用 canvas 玩转图片
  7. 在 canvas 中绘制与拖动
  8. 从 canvas 到编程本质

关键词

ts
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.beginPath() // 开始准备路径
ctx.moveTo(100, 20) // 移动到某个点, 坐标以左上角为原点
ctx.lineTo(200, 40) // 从上一个点, 连接到某个点
ctx.lineTo(240, 100) // 从上一个点, 连接到某个点
ctx.closePath() // 使终点连接到起点
ctx.strokeStyle = '#fff' // 线的样式
ctx.stroke() // 画的第一种方式, 表示描边, 把路径的线画出来

ctx.fillStyle = '#fff' // 填充样式
ctx.fill() // 填充

ctx.arc(100, 20, 6, 0, 2 * Math.PI) // 画一个圆, 圆心坐标(100,20) 半径6 起始弧度0 结束弧度2 PI 默认顺时针

ctx.fillStyle = '#fff' // 填充样式
ctx.font = '24px JetBrainsMono'
ctx.fillText('hello world!', 100, 100)

图像清晰度

  • 自然尺寸(原始尺寸) ${img}.naturalWidth ${img}.naturalHeight
  • 样式尺寸(展示到页面尺寸) style="width: 200px; height: 200px"
  • 缩放倍率 devicePixelRatio
  • 保持清晰 自然尺寸 = 样式尺寸 * 缩放倍率

canvas 基本绘图说明

  • 所有的绘图都必须在上下文中完成

  • 同一个 canvas 只能产生唯一的上下文

  • 上下文类型可以是:

    • 2d: 绘制 2d 图形
    • bitmaprender: 绘制位图上下文
    • webgl: 绘制 3d 的上下文,只在实现 WebGL 2.0 的浏览器上可用
    • webgl2: 绘制 3d 的上下文,只在实现 WebGL 3.0 的浏览器上可用

1. 基础画图

根据最后一节,尝试拓展了,鼠标在内部移动,有一个跟随移动的点并连线

查看代码
vue
<template>
  <div>
    <canvas id="canvas-base" />
  </div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

const getRandom = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1) + min)

onMounted(() => {
  const canvas = document.querySelector('#canvas-base') as HTMLCanvasElement
  canvas.width = (canvas.parentElement?.clientWidth || 500) * devicePixelRatio
  canvas.height = 500 * devicePixelRatio
  /** 上下文 */
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D

  class Point {
    x: number
    y: number
    /** 半径 */
    r: number
    /** 每个点的x方向移动速度,随机 */
    xSpeed: number
    /** 每个点的y方向移动速度,随机 */
    ySpeed: number
    /** 记录小球上一次运动的时间 */
    lastDrawTime?: number
    /** 是否固定,不随机改变位置 */
    fixed?: boolean
    constructor() {
      this.r = 4 * devicePixelRatio
      this.x = getRandom(0, canvas.width - this.r / 2)
      this.y = getRandom(0, canvas.height - this.r / 2)
      this.xSpeed = getRandom(-50, 50)
      this.ySpeed = getRandom(-50, 50)
      this.lastDrawTime = undefined
    }
    draw() {
      // 更新坐标
      if (this.lastDrawTime && !this.fixed) {
        // 如果上次有时间则更新坐标, 如果没有表示第一次
        const duration = (Date.now() - this.lastDrawTime) / 1000
        // 根据时间计算出移动的距离
        const xDis = duration * this.xSpeed
        const yDis = duration * this.ySpeed
        let x = this.x + xDis
        let y = this.y + yDis
        /*
          如果x移动超过宽度-半径, 则将x点设置到这个位置,并反向移动
          x小于0+半径; y大于高度-半径; y 小于高度+半径, 同理
        */
        if (x > canvas.width - this.r / 2) {
          x = canvas.width - this.r / 2
          this.xSpeed = -this.xSpeed // 反向移动
        }

        if (x < this.r / 2) {
          x = this.r / 2
          this.xSpeed = -this.xSpeed
        }
        if (y > canvas.height - this.r / 2) {
          y = canvas.height - this.r / 2
          this.ySpeed = -this.ySpeed
        }
        if (y < this.r / 2) {
          y = this.r / 2
          this.ySpeed = -this.ySpeed
        }
        this.x = x
        this.y = y
      }
      ctx.beginPath()
      ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI)
      ctx.fillStyle = 'rgb(200, 200, 200)'
      ctx.fill()
      this.lastDrawTime = Date.now()
    }
  }

  class Graph {
    points: Point[]
    constructor(pointNumber = 40, /** 最大距离 */ readonly maxDis = 200) {
      this.points = new Array(pointNumber).fill(0).map(() => new Point())
    }
    draw() {
      requestAnimationFrame(() => {
        this.draw() // 浏览器每次渲染时重画
      })
      ctx.clearRect(0, 0, canvas.width, canvas.height) // 清空画布
      this.points.forEach((point, i) => {
        point.draw()
        for (let j = i + 1; j < this.points.length; j++) {
          const targetPoint = this.points[j]
          // 根据勾股定理计算直线距离
          const d = Math.sqrt((point.x - targetPoint.x) ** 2 + (point.y - targetPoint.y) ** 2)
          ctx.beginPath() // 开始准备路径
          ctx.moveTo(point.x, point.y) // 移动到某个点, 坐标以左上角为原点
          ctx.lineTo(targetPoint.x, targetPoint.y) // 从上一个点, 连接到某个点
          ctx.closePath() // 使终点连接到起点
          ctx.strokeStyle = `rgba(200, 200, 200, ${1 - d / this.maxDis})` // 根据最大距离设置透明度
          ctx.stroke()
        }
      })
    }
  }

  const graph = new Graph()
  graph.draw()

  /**
   * 拓展,鼠标在canvas中移动,加一个点并连线
   */
  let mousePoint!: Point
  canvas.onmouseover = e => {
    if (!mousePoint) {
      mousePoint = new Point()
      mousePoint.fixed = true
    }
    !graph.points.includes(mousePoint) && graph.points.push(mousePoint)
    canvas.onmousemove = e => {
      const rect = canvas.getBoundingClientRect()
      mousePoint.x = e.clientX - rect.left
      mousePoint.y = e.clientY - rect.top
    }
  }
  canvas.onmouseout = () => {
    let index = graph.points.findIndex(point => point === mousePoint)
    if (index > -1 && mousePoint) {
      graph.points.splice(index, 1)
    }
  }
  const observer = new ResizeObserver(() => {
    canvas.width = canvas.parentElement?.clientWidth || 500
  })
  observer.observe(canvas.parentElement as HTMLDivElement)
  onUnmounted(() => {
    observer.unobserve(canvas.parentElement as HTMLDivElement)
  })
})
</script>

<style lang="less" scoped>
#canvas-base {
  background-color: black;
  border: 1px solid #f0f2f5;
  width: 100%;
  height: 500px;
}
</style>

2. 文字也可以很酷炫

查看代码
vue
<template>
  <div>
    <canvas id="canvas-text" />
  </div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

const getRandomChar = () => {
  const str = '1234567890qwertyuiopasdfghjkllzxcvbnm'
  return str[Math.floor(Math.random() * str.length)]
}

onMounted(() => {
  const canvas = document.querySelector('#canvas-text') as HTMLCanvasElement
  canvas.width = (canvas.parentElement?.clientWidth || 500) * devicePixelRatio
  canvas.height = 500 * devicePixelRatio
  /** 上下文 */
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
  const fontSize = 16 * devicePixelRatio
  const columnCount = Math.floor(canvas.width / fontSize)
  const charIndex = new Array(columnCount).fill(0)
  ctx.textBaseline = 'top' // 调整与y轴对齐基线
  const draw = () => {
    ctx.font = `${fontSize}px JetBrainsMono`
    ctx.fillStyle = 'rgba(0,0,0,0.1)' // 每次给一个快透明的矩形,以达到慢慢消失
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.fillStyle = 'green' // 填充样式
    for (let i = 0; i < charIndex.length; i++) {
      const x = i * fontSize
      const y = charIndex[i] * fontSize
      ctx.fillText(getRandomChar(), x, y)
      if (y > canvas.height && Math.random() > 0.99) {
        // 如果超过高度然后通过随机数模拟延迟
        charIndex[i] = 0
      } else {
        charIndex[i]++
      }
    }
  }
  draw()
  let timer = setInterval(draw, 50)
  const observer = new ResizeObserver(() => {
    canvas.width = canvas.parentElement?.clientWidth || 500
  })
  observer.observe(canvas.parentElement as HTMLDivElement)
  onUnmounted(() => {
    clearInterval(timer)
    observer.unobserve(canvas.parentElement as HTMLDivElement)
  })
})
</script>

<style lang="less" scoped>
#canvas-text {
  background-color: black;
  border: 1px solid #f0f2f5;
  width: 100%;
  height: 500px;
}
</style>

3. 用 canvas 玩转图片

关键信息

TIP

index = (y * width + x) * 4 y 行 + x * 4 是要取的像素点的索引,因为是 rgba,所以 4 个元素为一个点

点击图中某个地方,周围相近的颜色变绿

查看代码
vue
<template>
  <div>
    <canvas id="canvas-image" />
  </div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import ImageFile from './assets/1.jpg'
/**
 * 简单对比
 * 分别将ragb相减,然后求绝对值,再相加
 * 如果完全没差异的话,求出来肯定是0
 * 如果有差异求出来肯定是正数
 */
const diffColor = (color1: Uint8ClampedArray, color2: Uint8ClampedArray) => {
  return (
    Math.abs(color1[0] - color2[0]) +
    Math.abs(color1[1] - color2[1]) +
    Math.abs(color1[2] - color2[2]) +
    Math.abs(color1[3] - color2[3])
  )
}

onMounted(() => {
  const canvas = document.querySelector('#canvas-image') as HTMLCanvasElement
  canvas.width = 400 * devicePixelRatio
  canvas.height = 400 * devicePixelRatio

  /** 上下文 */
  const ctx = canvas.getContext('2d', {
    //如果需要反复读取canvas中的数据,浏览器建议加上这个,浏览器会执行一些优化
    willReadFrequently: true
  }) as CanvasRenderingContext2D

  const img = new Image()
  img.onload = () => {
    ctx.drawImage(img, 0, 0)
  }
  img.src = ImageFile

  const point2Index = (x: number, y: number) => {
    return (y * canvas.width + x) * 4
  }
  const getColor = (x: number, y: number, imageData: ImageData) => {
    const index = point2Index(x, y)
    return imageData.data.slice(index, index + 4)
  }

  const resetColor = new Uint8ClampedArray([0, 255, 0, 255])

  canvas.addEventListener('click', e => {
    // 点击拿x和y坐标
    const x = e.offsetX,
      y = e.offsetY
    // canvas所有像素点的颜色
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
    const clickColor = getColor(x, y, imageData)

    // 当图片较大时,递归应改为循环
    // const _changeColor = (x, y) => {
    //   // 如果超出边界,返回
    //   if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.width) return
    //   const targetColor = getColor(x, y, imageData)
    //   // 如果求出来点击颜色和目标颜色差值大于100则不改变
    //   if (diffColor(targetColor, clickColor) > 100) return
    //   // 如果目标颜色已经是要设置的颜色也不改变,防止一直递归
    //   if (diffColor(targetColor, resetColor) === 0) return
    //   // 修改图片像素点信息,只是内存中修改了,canvas并未修改
    //   imageData.data.set(Array.from(resetColor), point2Index(x, y))
    //   // 周围的点
    //   // _changeColor(x + 1, y)
    //   // _changeColor(x, y + 1)
    //   // _changeColor(x - 1, y)
    //   // _changeColor(x, y - 1)
    // }
    // _changeColor(x, y)

    // 周围的点 递归改循环 参考的,自己写不出来
    function _changeColor(x: number, y: number) {
      const pixelStack = [[x, y]]
      while (pixelStack.length) {
        const [x, y] = pixelStack.pop() || []
        if (!x && !y) return
        if (x >= 0 && y >= 0 && x < canvas.width && y < canvas.height) {
          const targetColor = getColor(x, y, imageData)
          // 如果求出来点击颜色和目标颜色差值大于100则不改变
          if (diffColor(targetColor, clickColor) > 100) continue
          // 如果目标颜色已经是要设置的颜色也不改变,防止一直递归
          if (diffColor(targetColor, resetColor) === 0) continue
          // 修改图片像素点信息,只是内存中修改了,canvas并未修改
          imageData.data.set(Array.from(resetColor), point2Index(x, y))
          pixelStack.push([x + 1, y])
          pixelStack.push([x - 1, y])
          pixelStack.push([x, y + 1])
          pixelStack.push([x, y - 1])
        }
      }
    }
    _changeColor(x, y)

    // 通过此方法将修改后的像素点信息更新到canvas中
    ctx.putImageData(imageData, 0, 0)
  })
})
</script>

<style lang="less" scoped>
#canvas-image {
  // background-color: black;
  border: 1px solid #f0f2f5;
  width: 400px;
  margin: 0 auto;
  height: 400px;
}
</style>

4. 绘制与拖动

关键词

  • ctx.lineCap 指定如何绘制每一条线段末端的属性
    • butt 线段末端以方形结束。
    • round 线段末端以圆形结束。
    • square 线段末端以方形结束,但是增加了一个宽度和线段相同,高度是线段厚度一半的矩形区域。
查看代码
vue
<template>
  <div>
    <input type="color" name="" id="color-picker" />
    <canvas id="canvas-darg" />
  </div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

onMounted(() => {
  const colorPicker = document.querySelector('#color-picker') as HTMLInputElement
  const canvas = document.querySelector('#canvas-darg') as HTMLCanvasElement
  canvas.width = (canvas.parentElement?.clientWidth || 500) * devicePixelRatio
  console.log('[ devicePixelRatio ]', canvas.parentElement?.clientWidth, devicePixelRatio)
  canvas.height = 500 * devicePixelRatio
  /** 上下文 */
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D

  /** 所有需要画的矩形的数组 */
  const shapes: Rectangle[] = []
  /** 通过坐标判断是哪个shape,倒序,因为后画的可能在之前画的上面 */
  const getShape = (x: number, y: number) => {
    for (let i = shapes.length - 1; i >= 0; i--) {
      const shape = shapes[i]
      if (x >= shape.minX && x <= shape.maxX && y >= shape.minY && y <= shape.maxY) {
        return shape
      }
    }
    return null
  }

  class Rectangle {
    color: string
    startX: number
    startY: number
    endX: number
    endY: number
    constructor(color: string, startX: number, startY: number) {
      this.color = color
      this.startX = startX
      this.startY = startY
      this.endX = startX
      this.endY = startY
    }
    /**
     * 由于可能正着拖和反着拖,所以开始位置不一定比结束位置小,所以最小值作为矩形左上顶点
     */
    get minX() {
      return Math.min(this.startX, this.endX)
    }
    get maxX() {
      return Math.max(this.startX, this.endX)
    }
    get minY() {
      return Math.min(this.startY, this.endY)
    }
    get maxY() {
      return Math.max(this.startY, this.endY)
    }
    draw() {
      ctx.beginPath()
      ctx.moveTo(this.minX * devicePixelRatio, this.minY * devicePixelRatio)
      ctx.lineTo(this.minX * devicePixelRatio, this.minY * devicePixelRatio)
      ctx.lineTo(this.maxX * devicePixelRatio, this.minY * devicePixelRatio)
      ctx.lineTo(this.maxX * devicePixelRatio, this.maxY * devicePixelRatio)
      ctx.lineTo(this.minX * devicePixelRatio, this.maxY * devicePixelRatio)
      ctx.closePath()
      ctx.fillStyle = this.color
      ctx.fill()
      ctx.strokeStyle = '#000'
      ctx.lineWidth = 2 * devicePixelRatio
      ctx.lineCap = 'square'
      ctx.stroke()
    }
  }

  canvas.onmousedown = e => {
    const rect = canvas.getBoundingClientRect()
    const clickX = e.clientX - rect.left
    const clickY = e.clientY - rect.top

    const shape = getShape(clickX, clickY)
    if (shape) {
      document.body.style.cursor = 'move'
      // 拖动
      const { startX, startY, endX, endY } = shape // 初始位置
      window.onmousemove = e => {
        const disX = e.clientX - rect.left - clickX // 移动的距离
        const disY = e.clientY - rect.top - clickY
        shape.startX = startX + disX
        shape.startY = startY + disY
        shape.endX = endX + disX
        shape.endY = endY + disY
      }
    } else {
      const shape = new Rectangle(colorPicker.value, clickX, clickY)
      shapes.push(shape)
      document.body.style.cursor = 'crosshair'
      window.onmousemove = e => {
        shape.endX = e.clientX - rect.left
        shape.endY = e.clientY - rect.top
      }
    }
    window.onmouseup = () => {
      window.onmousemove = null
      window.onmouseup = null
      document.body.style.cursor = ''
    }
  }
  function draw() {
    requestAnimationFrame(draw)
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    shapes.forEach(shape => shape.draw())
  }
  draw()

  const observer = new ResizeObserver(() => {
    canvas.width = canvas.parentElement?.clientWidth || 500
  })
  observer.observe(canvas.parentElement as HTMLDivElement)
  onUnmounted(() => {
    observer.unobserve(canvas.parentElement as HTMLDivElement)
  })
})
</script>

<style lang="less" scoped>
#canvas-darg {
  background-color: #eee;
  width: 100%;
  height: 500px;
}
</style>