动画原理

深入理解Canvas动画原理,掌握视觉暂留、帧率概念、时间驱动动画和缓动函数。理解动画原理是创建流畅动画的基础,本章将深入探讨动画的核心概念和实现方式。

视觉暂留

动画的本质是利用人眼的"视觉暂留"现象。

什么是视觉暂留

当人眼看到图像后,图像会在视网膜上短暂停留约1/16秒(约62.5毫秒)。如果在这段时间内显示下一幅略有差异的画面,大脑会将这些画面融合成连续的运动。

视觉暂留的应用

应用场景帧率说明
电影24 FPS每帧显示2-3次避免闪烁
电视30/60 FPS不同制式有差异
游戏60 FPS追求流畅体验
VR90+ FPS避免晕动症

临界帧率

const FRAME_RATES = {
  MIN_SMOOTH: 12,
  NORMAL: 24,
  SMOOTH: 30,
  FLUID: 60,
  HIGH_END: 120
}

帧与帧率

帧的概念

**帧(Frame)**是动画中的单幅画面。每一帧都是某一时刻的静态图像。

let currentFrame = 0
const totalFrames = 60

function getFrame(frameIndex) {
  return frameIndex % totalFrames
}

帧率(FPS)

**帧率(Frames Per Second)**表示每秒显示的帧数。

FPS体验说明
< 12卡顿动画不连贯
12-24可接受基本动画
24-30流畅标准动画
30-60非常流畅游戏标准
> 60极致流畅高刷新率显示

帧间隔计算

const FPS = 60
const FRAME_INTERVAL = 1000 / FPS

console.log(`${FPS} FPS 的帧间隔: ${FRAME_INTERVAL.toFixed(2)}ms`)

时间驱动动画

帧驱动 vs 时间驱动

帧驱动动画:每帧移动固定距离,帧率不同则速度不同。

时间驱动动画:基于时间计算移动距离,速度与帧率无关。

帧驱动的问题

// 帧驱动:帧率变化导致速度变化
let x = 0

function animate() {
  x += 5  // 每帧移动5像素
  draw()
  requestAnimationFrame(animate)
}

时间驱动的实现

let lastTime = 0
let x = 0
const speed = 100  // 像素/秒

function animate(currentTime) {
  if (lastTime === 0) lastTime = currentTime
  
  const deltaTime = (currentTime - lastTime) / 1000  // 转换为秒
  lastTime = currentTime
  
  x += speed * deltaTime  // 基于时间计算位移
  
  draw()
  requestAnimationFrame(animate)
}

时间驱动演示

时间驱动动画(速度恒定)

动画类型

1. 帧动画

按顺序播放预设的画面序列。

const frames = ['frame1.png', 'frame2.png', 'frame3.png', 'frame4.png']
let currentFrame = 0

function animate() {
  const img = new Image()
  img.src = frames[currentFrame]
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.drawImage(img, 0, 0)
  
  currentFrame = (currentFrame + 1) % frames.length
  setTimeout(animate, 100)
}

2. 属性动画

平滑改变对象的属性值。

const obj = {
  x: 0,
  y: 0,
  alpha: 1,
  scale: 1,
  rotation: 0
}

function animate() {
  obj.x += 2
  obj.rotation += 0.02
  obj.alpha = Math.max(0, obj.alpha - 0.005)
  
  draw(obj)
  requestAnimationFrame(animate)
}

3. 物理动画

基于物理规律的运动模拟。

const ball = {
  x: 100,
  y: 50,
  vx: 5,
  vy: 0,
  gravity: 0.5,
  bounce: 0.8
}

function animate() {
  ball.vy += ball.gravity
  ball.x += ball.vx
  ball.y += ball.vy
  
  if (ball.y > canvas.height - 20) {
    ball.y = canvas.height - 20
    ball.vy *= -ball.bounce
  }
  
  draw(ball)
  requestAnimationFrame(animate)
}

4. 粒子动画

大量粒子的集体运动。

class Particle {
  constructor(x, y) {
    this.x = x
    this.y = y
    this.vx = (Math.random() - 0.5) * 4
    this.vy = (Math.random() - 0.5) * 4
    this.life = 1
    this.decay = 0.02
  }
  
  update() {
    this.x += this.vx
    this.y += this.vy
    this.life -= this.decay
  }
  
  draw(ctx) {
    ctx.globalAlpha = this.life
    ctx.fillStyle = '#e74c3c'
    ctx.beginPath()
    ctx.arc(this.x, this.y, 5, 0, Math.PI * 2)
    ctx.fill()
    ctx.globalAlpha = 1
  }
}

const particles = []

function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  
  particles.push(new Particle(200, 100))
  
  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update()
    particles[i].draw(ctx)
    
    if (particles[i].life <= 0) {
      particles.splice(i, 1)
    }
  }
  
  requestAnimationFrame(animate)
}

缓动函数

缓动函数控制动画的速度变化,让动画更自然。

什么是缓动

缓动(Easing)是指动画过程中速度的变化规律:

  • 缓入(Ease In):开始慢,结束快
  • 缓出(Ease Out):开始快,结束慢
  • 缓入缓出(Ease In Out):两端慢,中间快

常用缓动函数

const easing = {
  linear: t => t,
  
  easeInQuad: t => t * t,
  easeOutQuad: t => t * (2 - t),
  easeInOutQuad: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
  
  easeInCubic: t => t * t * t,
  easeOutCubic: t => (--t) * t * t + 1,
  easeInOutCubic: t => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
  
  easeInQuart: t => t * t * t * t,
  easeOutQuart: t => 1 - (--t) * t * t * t,
  easeInOutQuart: t => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t,
  
  easeInExpo: t => t === 0 ? 0 : Math.pow(2, 10 * (t - 1)),
  easeOutExpo: t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
  easeInOutExpo: t => {
    if (t === 0) return 0
    if (t === 1) return 1
    if (t < 0.5) return Math.pow(2, 20 * t - 10) / 2
    return (2 - Math.pow(2, -20 * t + 10)) / 2
  }
}

弹跳缓动

const bounce = {
  easeOut: t => {
    const n1 = 7.5625
    const d1 = 2.75
    
    if (t < 1 / d1) {
      return n1 * t * t
    } else if (t < 2 / d1) {
      return n1 * (t -= 1.5 / d1) * t + 0.75
    } else if (t < 2.5 / d1) {
      return n1 * (t -= 2.25 / d1) * t + 0.9375
    } else {
      return n1 * (t -= 2.625 / d1) * t + 0.984375
    }
  },
  
  easeIn: t => 1 - bounce.easeOut(1 - t),
  
  easeInOut: t => t < 0.5
    ? (1 - bounce.easeOut(1 - 2 * t)) / 2
    : (1 + bounce.easeOut(2 * t - 1)) / 2
}

弹性缓动

const elastic = {
  easeOut: t => {
    if (t === 0) return 0
    if (t === 1) return 1
    return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI) / 3) + 1
  },
  
  easeIn: t => {
    if (t === 0) return 0
    if (t === 1) return 1
    return -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * (2 * Math.PI) / 3)
  }
}

缓动函数演示

缓动函数对比

使用缓动函数

基本用法

function animate(startValue, endValue, duration, easingFn, onUpdate) {
  const startTime = performance.now()
  
  function tick(currentTime) {
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1)
    const easedProgress = easingFn(progress)
    
    const currentValue = startValue + (endValue - startValue) * easedProgress
    onUpdate(currentValue)
    
    if (progress < 1) {
      requestAnimationFrame(tick)
    }
  }
  
  requestAnimationFrame(tick)
}

animate(0, 400, 1000, easing.easeOutQuad, value => {
  ball.x = value
})

缓动动画演示

缓动动画示例

注意事项

时间精度

// 使用 performance.now() 获取高精度时间
const now = performance.now()

// 避免使用 Date.now() 做精确计算
const lowPrecision = Date.now()

帧率波动

// 处理帧率波动
let lastTime = 0
const maxDeltaTime = 100  // 最大时间差限制

function animate(currentTime) {
  let deltaTime = currentTime - lastTime
  deltaTime = Math.min(deltaTime, maxDeltaTime)  // 限制最大值
  lastTime = currentTime
  
  // 使用 deltaTime 更新
  requestAnimationFrame(animate)
}

动画循环管理

// 避免多个动画循环冲突
let animationId = null

function startAnimation() {
  if (animationId) cancelAnimationFrame(animationId)
  animationId = requestAnimationFrame(animate)
}

function stopAnimation() {
  if (animationId) {
    cancelAnimationFrame(animationId)
    animationId = null
  }
}