Canvas动画:画一个炫酷蜘蛛侠

1,661 阅读1分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情

particle-move-5.gif

前言

本篇带大家用canvas来画一个蜘蛛侠~ :)

首先,找一张背景透明的蜘蛛侠图片,并借助工具将其转化成base64图片数据。

image.png

简单的HTML和CSS代码

<canvas id="spider-man"></canvas>

没错,html值需要一个canvas就可以了,相应地,css也很简单:

body {
  padding: 0;
  margin: 0;
  display: flex;
  place-items: center;
  place-content: center;
  width: 100vw;
  height: 100vh;
  background: rgba(0, 0, 0, 0.9);
}

#spider-man {
  width: 960px;
  height: 484px;
  border: 1px solid white;
}

css代码有一点需要注意:canvas的size大小要与图片的size保持一致

将图片画至画布上

  1. 首先加载图片资源,并在图片加载完毕之后执行render函数:
const imageSource = 'iVBORw0KGgoAAAANSUhEUgAAA8AAAAHk...' //前文转换好的base64图片数据

// 加载图片资源
const loadImage = () => {
  const img = new Image()
  img.src = `data:image/png;base64,${imageSource}`
  img.addEventListener('load', () => {
    render(img)
  })
}

loadImage()

render的实现也很简单,就是获取画布的上下文对象,然后调用drawImage api绘制图片:

const CANVAS_WIDTH = 960 // 这里对应图片的size
const CANVAS_HEIGHT = 484

// 绘制图片
const renderImage = (
  ctx: CanvasRenderingContext2D,
  image: HTMLImageElement
) => {
  ctx.drawImage(image, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
}

const render = (image: HTMLImageElement) => {
  const $canvas: HTMLCanvasElement = document.querySelector('#image-canvas')
  $canvas.width = CANVAS_WIDTH
  $canvas.height = CANVAS_HEIGHT
  const ctx: CanvasRenderingContext2D = $canvas.getContext('2d')
  renderImage(ctx, image)
}

image.png

好了,图片绘制上了,但我们也不仅仅是绘制一张图片这么简单!

瀑布粒子效果

天上掉下万千粒子,现在开始让画布“动”起来~

class先实现一个粒子对象:

class Particle {
  private ctx: CanvasRenderingContext2D
  public x: number // 粒子中心x坐标
  public y: number // 粒子中心y坐标
  public size: number // 粒子半径
  public color: string // 粒子颜色

  constructor(
    ctx: CanvasRenderingContext2D,
  ) {
    this.ctx = ctx
    this.x = Math.random() * CANVAS_WIDTH
    this.y = Math.random() * 2.5
    this.color = 'white'
    this.size = Math.random() * 1.5 + 1.25
  }

  public draw() { // 画出来
    const { ctx, x, y, size, color } = this
    ctx.beginPath()
    ctx.fillStyle = color
    ctx.arc(x, y, size, 0, Math.PI * 2)
    ctx.fill()
  }
}

// 创造5000个粒子先
const createParticle = (ctx: CanvasRenderingContext2D): Particle[] => {
  const particles: Particle[] = []
  const amount = 5000
  for (let i = 0; i < amount; i++) {
    particles.push(new Particle(ctx))
  }
  return particles
}

// 绘制粒子
const renderParticel = (
  particles: Particle[]
) => {
  particles.forEach((p) => p.draw())
}

// 加入到主render函数中
const render = (image: HTMLImageElement) => {
  // ...
  // 省略其他,只展示渲染粒子代码
  const particles = createParticle(ctx)
  renderParticel(particles)
}

image.png

看出效果了吗?与第一张图相比,顶部多了一层白色色块,这些就是我们创建的粒子,只是现在它们都集中在了顶部,一动不动!

我们让它动起来,从顶部倾泻下来,像瀑布一般~

// 给Particle对象增加一些属性
class Particle {
  // ... 省略其他属性(见上文代码),新增以下属性
  private velocity: number // Y方向移动速度
  
  // ... 省略其他代码
  
  private update() { // 更新粒子的属性信息
    this.y += 2.5 + this.velocity
    if (this.y > CANVAS_HEIGHT) { // 超出画布区域时,重置回画布顶部
      this.y = 0
      this.x = Math.random() * CANVAS_WIDTH
    }
  }

  public draw() { // 画出来
    // ... 省略其他代码
    this.update() // 更新粒子的坐标信息,下次绘制的时候产生移动效果
  }
}

canvas做动画的核心其实就是重复绘制。我们知道,canvas绘制的内容是静态的,但只要我们每一帧都绘制一次,并且每次绘制改变一下绘制对象的坐标,这样,画布看起来就是具有动画效果的。

const render = (image: HTMLImageElement) => {
  const $canvas: HTMLCanvasElement = document.querySelector('#image-canvas')
  $canvas.width = CANVAS_WIDTH
  $canvas.height = CANVAS_HEIGHT
  const ctx: CanvasRenderingContext2D = $canvas.getContext('2d')
  const particles = createParticle(ctx)
  
  const animate = () => {
    renderImage(ctx, image)
    renderParticel(particles)
    requestAnimationFrame(animate) // 借助requestAnimationFrame重复绘制
  }
 animate()
}

particle-move-1.gif

可以看到,粒子已经自顶部倾泻下来了,周而复始。但是...我们发现画布的背景变白色了,给画布加个黑色背景试试看:

const render = (image: HTMLImageElement) => {
  // ...省略其他代码
  const animate = () => {
    ctx.fillStyle = 'rgba(0, 0, 0)'
    ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
    // ...省略其他代码
  }
  animate()
}

particle-move-2.gif

完美!我们再设置一个透明度,这个透明度的作用在于使我们能看见上(上上上...)一帧的画布内容:

const render = (image: HTMLImageElement) => {
  // ...省略其他代码
  // ...省略其他代码
  const animate = () => {
    ctx.globalAlpha = 0.05 // 设置画布透明度
    // ...省略其他代码
  }
  animate()
}

particle-move-3.gif

粒子转化为图片

当前我们的龙舟还是一张图片,粒子虽然有瀑布效果了,但龙舟还是静态的,我们需要把这两个结合起来。因此,我们利用getImageData获取画布上图片的像素信息。

let pixels: number[][] = [] // 存储图片像素信息

// 将每个像素的rgb信息转换为亮度
const calculateReleativeBrightness = (
  r: number,
  g: number,
  b: number
): number => {
  return Math.sqrt(r * r * 0.299 + g * g * 0.587 + b * b * 0.114) / 100
}

// 获取像素信息
const getPixels = (ctx: CanvasRenderingContext2D) => {
  const data = ctx.getImageData(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT).data
  for (let j = 0; j < CANVAS_HEIGHT; j++) {
    const row = []
    for (let i = 0; i < CANVAS_WIDTH; i++) {
      const red = data[j * 4 * CANVAS_WIDTH + i * 4]
      const green = data[j * 4 * CANVAS_WIDTH + (i * 4 + 1)]
      const blue = data[j * 4 * CANVAS_WIDTH + (i * 4 + 2)]
      const brightness = calculateReleativeBrightness(red, green, blue)
      row.push([brightness])
    }
    pixels.push(row)
  }
}

而在render函数里面,我们获取完图片的像素信息之后,需要将图片从画布中清除:

const render = (image: HTMLImageElement) => {
  const $canvas: HTMLCanvasElement = document.querySelector('#image-canvas')
  $canvas.width = CANVAS_WIDTH
  $canvas.height = CANVAS_HEIGHT
  const ctx: CanvasRenderingContext2D = $canvas.getContext('2d')
  const particles = createParticle(ctx)
  // 绘制图片
  renderImage(ctx, image)
  // 获取图片像素信息
  getPixels(ctx)
  // 清除画布(图片)
  clearCanvas(ctx)
  ctx.globalAlpha = 0.05
  const animate = () => { // 这里不再绘制图片了
    ctx.fillStyle = 'rgba(0, 0, 0)'
    ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
    renderParticel(particles)
    requestAnimationFrame(animate)
  }
  animate()
}

还不够,我们有了每个像素点的(亮度)信息,要怎么用到粒子对象上呢?结合亮度控制粒子的移动速率:

// 给Particle对象增加一些属性
class Particle {
  // ... 省略其他属性(见上文代码),新增以下属性
  private speed: number // 控制速率
  public posX: number // 粒子对应pixels所在位置的x坐标
  public posY: number // 粒子对应pixels所在位置的y坐标
  
  // ... 省略其他代码
  
  private update() { // 更新粒子的属性信息
    this.posX = Math.floor(this.x)
    this.posY = Math.floor(this.y)
    const pixel = pixels[this.posY][this.posX] // 取出当前粒子对应的像素点
    this.speed = pixel[0]
    this.y += 2.5 - this.speed + this.velocity
    // ... 省略其他代码
  }
}

我们看到,粒子落在龙舟图像的部分已经慢下来了

particle-move-4.gif

目前还是黑白的,我们把颜色带上:

// 获取像素信息
const getPixels = (ctx: CanvasRenderingContext2D) => {
  const data = ctx.getImageData(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT).data
  for (let j = 0; j < CANVAS_HEIGHT; j++) {
    const row = []
    for (let i = 0; i < CANVAS_WIDTH; i++) {
      // ...省略其他代码
      const brightness = calculateReleativeBrightness(red, green, blue)
      const color = `rgb(${red}, ${green}, ${blue})`
      row.push([brightness, color])
    }
    pixels.push(row)
  }
}
class Particle {
  // ... 省略其他代码
  
  private update() { // 更新粒子的属性信息
    // ... 省略其他代码
    const pixel = pixels[this.posY][this.posX] // 取出当前粒子对应的像素点
    this.speed = pixel[0]
    this.color = pixel[1]
    // ... 省略其他代码
  }
}

particle-move-5.gif