HyG Front-end Dev Engineer

canvas动画之速度与加速度

2021-07-25
HyG

本文将开始讲述动画编程的部分,会从基本的运动属性开始:速度、向量和加速度

  • 速度
  • 加速度

速度向量

速度向量指某个方向上的速度。这里包含速度的值和方向(既有大小,又有方向)。

任何一个速度向量又可以被分解为 x 方向和 y 方向。

接下来的示例中,我们会使用 vx 表示 x 轴上的速度向量,vy 表示 y 轴上的速度向量。

匀速直线运动

匀速直线运动是指运动快慢不变(即速度不变)、沿着直线的运动。在匀速直线运动中,路程与时间成正比,用公式 s=vt 计算。

\[s = vt\]

这里复用《动画中的三角学》一文中创建的 Ball 类,进行小球的匀速直线运动示例

已知x、y分速度的运动

代码如下

import stats from '../../common/stats'
import Ball from '../../common/Ball'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const vx = 10 // x 方向速度, 10 像素/s
const vy = 20 // y 方向速度, 20 像素/s
const x0 = 20 // 初始位置
const y0 = 20

if (canvas) {
  canvas.width = window.screen.width
  canvas.height = window.screen.height
  const context = canvas.getContext('2d')

  const ball = new Ball(10, '#1E88E5')
  if (context) {
    const drawFrame = (time: number) => {
      stats.begin()
      const timeInSeconds = time / 1000 // 将毫秒转为秒单位

      context.clearRect(0, 0, canvas.width, canvas.height)
      ball.x = vx * timeInSeconds + x0
      ball.y = vy * timeInSeconds + y0
      ball.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

核心代码为

ball.x = vx * timeInSeconds + x0
ball.y = vy * timeInSeconds + y0

效果如下

demo 链接 https://gaohaoyang.github.io/canvas-practice/17-uniform-linear-motion/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/17-uniform-linear-motion/index.ts

基于速度向量的运动

上述是通过速度的分量 vx 和 vy 进行计算的,如果未知速度的分量,只知道小球延45度每秒10像素运动,该如何实现呢?这就用到了三角函数,不清楚三角函数的可以回顾之前的文章《动画中的三角学》。

其分量为

vx = 10 * cos(45)
vy = 10 * sin(45)

所以完整代码如下

import stats from '../common/stats'
import Ball from '../common/Ball'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const alpha = 45 // 角度 45 度
const v = 10 // 速度, 10 像素/s
const x0 = 20 // 初始位置
const y0 = 20

if (canvas) {
  canvas.width = window.screen.width
  canvas.height = window.screen.height
  const context = canvas.getContext('2d')

  const ball = new Ball(10, '#1E88E5')
  if (context) {
    const drawFrame = (time: number) => {
      stats.begin()
      const timeInSeconds = time / 1000 // 将毫秒转为秒单位

      context.clearRect(0, 0, canvas.width, canvas.height)
      ball.x = v * Math.cos((alpha * Math.PI) / 180) * timeInSeconds + x0
      ball.y = v * Math.sin((alpha * Math.PI) / 180) * timeInSeconds + y0
      ball.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

demo 链接 https://gaohaoyang.github.io/canvas-practice/18-uniform-linear-motion2/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/18-uniform-linear-motion2/index.ts

注意这里的 timeInSeconds 是一个连续的时间,在 canvas 绘图中可能会经常使用每一帧的时间片段来进行计算,接下来讲到。

基于每帧时间间隔的实现

import stats from '../common/stats'
import Ball from '../common/Ball'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const alpha = 45 // 角度 45 度
const v = 10 // 速度, 10 像素/s
const x0 = 20 // 初始位置
const y0 = 20

if (canvas) {
  canvas.width = window.screen.width
  canvas.height = window.screen.height
  const context = canvas.getContext('2d')

  const ball = new Ball(10, '#1E88E5')
  ball.x = x0
  ball.y = y0

  if (context) {
    let then = 0
    const drawFrame = (time: number) => {
      stats.begin()
      const timeInSeconds = time / 1000 // 将毫秒转为秒单位
      const deltaTime = timeInSeconds - then
      then = timeInSeconds

      context.clearRect(0, 0, canvas.width, canvas.height)
      ball.x += v * Math.cos((alpha * Math.PI) / 180) * deltaTime
      ball.y += v * Math.sin((alpha * Math.PI) / 180) * deltaTime
      ball.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

效果与上一个 demo 相同,这里的核心逻辑为

每次计算每帧内的位移,再进行累加

ball.x += v * Math.cos((alpha * Math.PI) / 180) * deltaTime
ball.y += v * Math.sin((alpha * Math.PI) / 180) * deltaTime

demo 链接 https://gaohaoyang.github.io/canvas-practice/19-uniform-linear-frame-time/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/19-uniform-linear-frame-time/index.ts

移动到点击位置

在《动画中的三角学》一文中,我们实现了总是指向鼠标的箭头,现在我们将其稍加改造,改为总是移动到点击位置。

import stats from '../common/stats'
import Arrow from '../common/Arrow'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const v = 100 // 速度 10 像素/s

/**
 * 获取鼠标点击位置
 */
const getClickPos = (element: HTMLElement) => {
  const pos = {
    x: 0,
    y: 0,
  }
  element.addEventListener('click', (e: MouseEvent) => {
    pos.x = e.pageX
    pos.y = e.pageY
  })
  return pos
}

if (canvas) {
  canvas.width = window.screen.width
  canvas.height = window.screen.height
  const context = canvas.getContext('2d')

  const arrow = new Arrow()
  arrow.x = canvas.width / 2
  arrow.y = canvas.height / 2

  const mousePos = getClickPos(canvas)

  let then = 0
  if (context) {
    const drawFrame = (time: number) => {
      stats.begin()

      const timeInSeconds = time / 1000 // 将毫秒转为秒单位
      const deltaTimeInSeconds = timeInSeconds - then // 每帧的间隔时间,单位s
      then = timeInSeconds

      context.clearRect(0, 0, canvas.width, canvas.height)
      const dx = mousePos.x - arrow.x
      const dy = mousePos.y - arrow.y
      const angle = Math.atan2(dy, dx)

      arrow.x += v * Math.cos(angle) * deltaTimeInSeconds
      arrow.y += v * Math.sin(angle) * deltaTimeInSeconds
      arrow.rotation = angle

      arrow.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

demo 链接 https://gaohaoyang.github.io/canvas-practice/20-arrow-to-tap/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/20-arrow-to-tap/index.ts

总是跟随鼠标的箭头

要实现这个demo,只需将上述的点击事件换为 mousemove 事件即可

import stats from '../common/stats'
import Arrow from '../common/Arrow'
import { captureMouse } from '../common/utils'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const v = 100 // 速度 10 像素/s

if (canvas) {
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight
  const context = canvas.getContext('2d')

  const arrow = new Arrow()
  arrow.x = canvas.width / 2
  arrow.y = canvas.height / 2

  const mousePos = captureMouse(canvas)

  let then = 0
  if (context) {
    const drawFrame = (time: number) => {
      stats.begin()

      const timeInSeconds = time / 1000 // 将毫秒转为秒单位
      const deltaTimeInSeconds = timeInSeconds - then // 每帧的间隔时间,单位s
      then = timeInSeconds

      context.clearRect(0, 0, canvas.width, canvas.height)
      const dx = mousePos.x - arrow.x
      const dy = mousePos.y - arrow.y
      const angle = Math.atan2(dy, dx)

      arrow.x += v * Math.cos(angle) * deltaTimeInSeconds
      arrow.y += v * Math.sin(angle) * deltaTimeInSeconds
      arrow.rotation = angle

      arrow.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

demo 链接 https://gaohaoyang.github.io/canvas-practice/21-arrow-to-mouse-move/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/21-arrow-to-mouse-move/index.ts

加速度

中学物理中,加速运动的位移一般是通过连续时间变量来计算的,但 canvas 绘图中我们更多的是使用每一帧之间的时间片段。我们先回顾一下基于连续时间的加速度。

基于连续时间的加速度

匀加速直线运动的速度公式

\[v_t = v_0 + at\]

匀加速直线运动的位移公式

\[x = \dfrac{v_0+v_t}{2}t = v_0t + \dfrac{1}{2}at^2\]

示例

import stats from '../common/stats'
import Ball from '../common/Ball'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const v0x = 60 // x 方向初速度, 单位 像素/s
const v0y = 0 // x 方向初速度, 单位 像素/s
const ax = 0 // x 方向加速度, 单位 像素/s^2
const ay = 600 // y 方向加速度, 单位 像素/s^2
const x0 = 60 // 初始位置
const y0 = 20

if (canvas) {
  canvas.width = window.screen.width
  canvas.height = window.screen.height
  const context = canvas.getContext('2d')

  const ball = new Ball(10, '#1E88E5')
  if (context) {
    const drawFrame = (time: number) => {
      stats.begin()
      const timeInSeconds = time / 1000 // 将毫秒转为秒单位

      context.clearRect(0, 0, canvas.width, canvas.height)
      ball.x = v0x * timeInSeconds + (1 / 2) * ax * timeInSeconds ** 2 + x0
      ball.y = v0y * timeInSeconds + (1 / 2) * ay * timeInSeconds ** 2 + y0

      ball.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

效果如下

demo 链接 https://gaohaoyang.github.io/canvas-practice/22-accelerate/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/22-accelerate/index.ts

基于每帧间隔时间的加速运动

import stats from '../common/stats'
import Ball from '../common/Ball'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const v0x = 60 // x 方向初速度, 单位 像素/s
const v0y = 0 // x 方向初速度, 单位 像素/s
const ax = 0 // x 方向加速度, 单位 像素/s^2
const ay = 600 // y 方向加速度, 单位 像素/s^2
const x0 = 60 // 初始位置
const y0 = 20

if (canvas) {
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight
  const context = canvas.getContext('2d')

  const ball = new Ball(10, '#1E88E5')
  ball.x = x0
  ball.y = y0
  let vx = v0x
  let vy = v0y

  if (context) {
    let then = 0
    const drawFrame = (time: number) => {
      stats.begin()
      const timeInSeconds = time / 1000 // 将毫秒转为秒单位
      const deltaTime = timeInSeconds - then
      then = timeInSeconds

      context.clearRect(0, 0, canvas.width, canvas.height)

      vx += ax * deltaTime
      vy += ay * deltaTime

      ball.x += vx * deltaTime
      ball.y += vy * deltaTime

      // ball.x = v0x * timeInSeconds + (1 / 2) * ax * timeInSeconds ** 2 + x0
      // ball.y = v0y * timeInSeconds + (1 / 2) * ay * timeInSeconds ** 2 + y0

      ball.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

demo 链接 https://gaohaoyang.github.io/canvas-practice/23-accelerate-time/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/23-accelerate-time/index.ts

使用方向键控制小球加速

import stats from '../common/stats'
import Ball from '../common/Ball'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const v0x = 0 // x 方向初速度, 单位 像素/s
const v0y = 0 // x 方向初速度, 单位 像素/s
let ax = 0 // x 方向加速度, 单位 像素/s^2
let ay = 0 // y 方向加速度, 单位 像素/s^2
const x0 = window.innerWidth / 2 // 初始位置
const y0 = window.innerHeight / 2

if (canvas) {
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight

  document.addEventListener('keydown', (e: KeyboardEvent) => {
    console.log(e.key)
    switch (e.key) {
      case 'ArrowLeft':
        ax = -100
        break
      case 'ArrowRight':
        ax = 100
        break
      case 'ArrowUp':
        ay = -100
        break
      case 'ArrowDown':
        ay = 100
        break
      default:
        break
    }
  })
  document.addEventListener('keyup', () => {
    ax = 0
    ay = 0
  })

  const context = canvas.getContext('2d')

  const ball = new Ball(10, '#1E88E5')
  ball.x = x0
  ball.y = y0
  let vx = v0x
  let vy = v0y

  if (context) {
    let then = 0
    const drawFrame = (time: number) => {
      stats.begin()
      const timeInSeconds = time / 1000 // 将毫秒转为秒单位
      const deltaTime = timeInSeconds - then
      then = timeInSeconds

      context.clearRect(0, 0, canvas.width, canvas.height)

      vx += ax * deltaTime
      vy += ay * deltaTime

      ball.x += vx * deltaTime
      ball.y += vy * deltaTime

      ball.draw(context)
      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

demo 链接 https://gaohaoyang.github.io/canvas-practice/24-ctrl-ball-accelerate/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/24-ctrl-ball-accelerate/index.ts

添加重力加速度

真实世界中,物体总是受到向下的重力,如果我们想模拟这个场景,只需要在上个示例中添加2行代码,让小球总有一个向下的加速度,如下

const gravity = 50

...

vy += gravity * deltaTime

demo 链接 https://gaohaoyang.github.io/canvas-practice/25-ctrl-ball-accelerate-gravity/

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/25-ctrl-ball-accelerate-gravity/index.ts

宇宙飞船

使用本章前面学到的知识,实现一个模拟宇宙飞船正常飞行的能力吧

首先绘制宇宙飞船,新建一个 Ship 类,代码如下

/* eslint-disable no-param-reassign */
class Ship {
  x = 0

  y = 0

  width = 25

  height = 20

  rotation = 0

  showFlame = true

  /**
   * draw
   */
  public draw(c: CanvasRenderingContext2D) {
    c.save()
    c.translate(this.x, this.y)
    c.rotate(this.rotation)
    c.lineWidth = 1
    c.strokeStyle = '#ffffff'
    c.beginPath()
    c.moveTo(10, 0)
    c.lineTo(-10, 10)
    c.lineTo(-5, 0)
    c.lineTo(-10, -10)
    c.lineTo(10, 0)
    c.stroke()

    if (this.showFlame) {
      c.beginPath()
      c.moveTo(-7.5, -5)
      c.lineTo(-15, 0)
      c.lineTo(-7.5, 5)
      c.stroke()
    }

    c.restore()
  }
}

export default Ship

将其先绘制到 canvas 上看看

import Ship from '../common/Ship'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const x0 = window.innerWidth / 2 // 初始位置
const y0 = window.innerHeight / 2

if (canvas) {
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight

  const context = canvas.getContext('2d')

  const ship = new Ship()
  ship.x = x0
  ship.y = y0

  if (context) {
    ship.draw(context)
  }
}

效果如下

有喷射火焰时:

接下来写控制逻辑,飞船有3中控制方式左转、右转、点火,代码如下:

import stats from '../common/stats'
import Ship from '../common/Ship'

const canvas: HTMLCanvasElement | null = document.querySelector('#mainCanvas')

const aRotation = 80 // 飞船旋转角加速度
const aThrust = 50 // 推进加速度
const x0 = window.innerWidth / 2 // 初始位置
const y0 = window.innerHeight / 2

let aRotationShip = 0 // 旋转角加速度
let vRotationShip = 0 // 旋转角速度
let aThrustShip = 0 // 推进加速度
let vThrustShip = 0 // 推进速度

if (canvas) {
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight

  const ship = new Ship()
  ship.x = x0
  ship.y = y0

  document.addEventListener('keydown', (e: KeyboardEvent) => {
    console.log(e.key)
    switch (e.key) {
      case 'ArrowLeft':
        aRotationShip = -aRotation
        break
      case 'ArrowRight':
        aRotationShip = aRotation
        break
      case 'ArrowUp':
        aThrustShip = aThrust
        ship.showFlame = true
        break
      case 'ArrowDown':
        aThrustShip = -aThrust
        break
      default:
        break
    }
  })
  document.addEventListener('keyup', () => {
    aRotationShip = 0
    aThrustShip = 0
    ship.showFlame = false
  })

  const context = canvas.getContext('2d')

  if (context) {
    let then = 0
    const drawFrame = (time: number) => {
      stats.begin()
      const timeInSeconds = time / 1000 // 将毫秒转为秒单位
      const deltaTime = timeInSeconds - then
      then = timeInSeconds

      context.clearRect(0, 0, canvas.width, canvas.height)

      vRotationShip += aRotationShip * deltaTime
      ship.rotation += (vRotationShip * deltaTime * Math.PI) / 180

      vThrustShip += aThrustShip * deltaTime
      if (vThrustShip <= 0) {
        vThrustShip = 0
      }
      const angle = ship.rotation
      ship.x += vThrustShip * deltaTime * Math.cos(angle)
      ship.y += vThrustShip * deltaTime * Math.sin(angle)

      ship.draw(context)

      stats.end()
      window.requestAnimationFrame(drawFrame)
    }
    drawFrame(0)
  }
}

demo 链接 https://gaohaoyang.github.io/canvas-practice/26-space-ship/index.html

源码链接 https://github.com/Gaohaoyang/canvas-practice/blob/main/src/26-space-ship/index.ts

可以看到代码中先监听了键盘事件,将旋转角加速度常量值赋给真正飞船的旋转角加速度。然后根据角加速度算出飞船每帧的旋转角度

vRotationShip += aRotationShip * deltaTime // 计算角速度
ship.rotation += (vRotationShip * deltaTime * Math.PI) / 180 // 计算角度

对于飞船喷射逻辑,我们也监听了键盘事件,将推进加速度常量赋值给飞船的推进加速度。然后通过飞船加速度计算得出飞船速度。

vThrustShip += aThrustShip * deltaTime // 计算出飞船速度
if (vThrustShip <= 0) {
  vThrustShip = 0
}

注意这里我们增加了判断飞船可以一直减速,但不能倒退。然后再根据飞船的速度和角度,计算出飞船x、y方向上的位移

const angle = ship.rotation
ship.x += vThrustShip * deltaTime * Math.cos(angle)
ship.y += vThrustShip * deltaTime * Math.sin(angle)

总结

匀速直线运动

s = v * t + s0 // 速度 乘 时间 加 初始位置

\[s = vt\]

获取速度分量的方式

vx = 某个方向的速度 * cos(该方向的角度)
vy = 某个方向的速度 * sin(该方向的角度)

ball.x = v * Math.cos((alpha * Math.PI) / 180) * timeInSeconds + x0
ball.y = v * Math.sin((alpha * Math.PI) / 180) * timeInSeconds + y0

加速度

s = v0 * t + (1 / 2) * a * t ^ 2
\[x = \dfrac{v_0+v_t}{2}t = v_0t + \dfrac{1}{2}at^2\]

但在真正的动画开发中,经常会使用每帧的时间间隔来计算画面内物体的位移。这样做有2个好处

  1. 便于处理每帧内的实时变化,例如加速度改变、速度改变,直接得出当前帧内的位移情况,进行累加
  2. 在不同刷新率的手机上,保证物体运动速度一致

因此物体速度一般可以如下表示

x += v * deltaTime

对于速度向量来说,可以使用三角函数进行计算物体的位移

x += v * deltaTime * Math.cos(angle)
y += v * deltaTime * Math.sin(angle)

如果是加速运动,可以先算出通过加速度计算出速度,再使用速度计算出位移

v += a * deltaTime
x += v * deltaTime

最后的宇宙飞船示例中,还引入了角加速度的概念,与普通加速度类似,我们在计算的时候也是先将其转换为角速度,再转换为角度。

vRotation += aRotation * deltaTime // 计算角速度
ship.rotation += (vRotation * deltaTime * Math.PI) / 180 // 计算角度

另外在示例中我们还引入了重力加速度的概念,重力加速度无非就是在 y 方向上计算速度时再引入一个加速度变量

const gravity = 50

...

vy += gravity * deltaTime

学习了速度与加速度的概念和计算方式,后续就可以开发更多的动画了!


Comments

Content