深入理解Canvas动画原理,掌握视觉暂留、帧率概念、时间驱动动画和缓动函数。理解动画原理是创建流畅动画的基础,本章将深入探讨动画的核心概念和实现方式。
动画的本质是利用人眼的"视觉暂留"现象。
当人眼看到图像后,图像会在视网膜上短暂停留约1/16秒(约62.5毫秒)。如果在这段时间内显示下一幅略有差异的画面,大脑会将这些画面融合成连续的运动。
| 应用场景 | 帧率 | 说明 |
|---|---|---|
| 电影 | 24 FPS | 每帧显示2-3次避免闪烁 |
| 电视 | 30/60 FPS | 不同制式有差异 |
| 游戏 | 60 FPS | 追求流畅体验 |
| VR | 90+ 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
}
**帧率(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`)
帧驱动动画:每帧移动固定距离,帧率不同则速度不同。
时间驱动动画:基于时间计算移动距离,速度与帧率无关。
// 帧驱动:帧率变化导致速度变化
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)
}
按顺序播放预设的画面序列。
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)
}
平滑改变对象的属性值。
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)
}
基于物理规律的运动模拟。
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)
}
大量粒子的集体运动。
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)是指动画过程中速度的变化规律:
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
}
}