Pixi.js 游戏开发教程 / 动画基础(Ticker)
动画基础(Ticker)
概述
动画是游戏和交互式应用的灵魂。Pixi.js 通过 Ticker 提供稳定的帧更新机制,配合补间动画、帧动画等技术,实现流畅的视觉效果。本章将深入讲解 Ticker 原理、动画实现方式,以及游戏循环的最佳实践。
Ticker 原理
Ticker 基于 requestAnimationFrame (rAF) 实现,与浏览器的刷新率同步(通常 60fps)。
浏览器刷新周期(~16.67ms @ 60fps)
│
├─ requestAnimationFrame 触发
│ ├─ Ticker.update()
│ │ ├─ 计算 deltaTime
│ │ ├─ 执行 ticker.add() 注册的回调
│ │ └─ 触发 renderer.render()
│ └─ 等待下一帧
└─ 重复
import { Application, Ticker } from 'pixi.js';
const app = new Application();
await app.init({ width: 800, height: 600, background: '#1a1a2e' });
document.body.appendChild(app.canvas);
// 查看 Ticker 状态
console.log('FPS:', app.ticker.FPS);
console.log('deltaTime:', app.ticker.deltaTime);
console.log('deltaMS:', app.ticker.deltaMS);
ticker.add 回调
// 基本用法
app.ticker.add((ticker) => {
const delta = ticker.deltaTime; // 归一化帧间隔
const ms = ticker.deltaMS; // 毫秒
sprite.rotation += 0.01 * delta;
});
// 带优先级(priority 越小越先执行)
app.ticker.add(() => { console.log('优先级 0'); }, undefined, 0);
app.ticker.add(() => { console.log('优先级 1'); }, undefined, 1);
// 通过对象添加
const myUpdater = {
update(ticker: Ticker) {
// 更新逻辑
},
};
app.ticker.add(myUpdater);
// 移除
app.ticker.remove(myUpdater);
回调执行顺序
ticker.add(callback, undefined, UPDATE_PRIORITY.LOW) // -10
ticker.add(callback, undefined, UPDATE_PRIORITY.NORMAL) // 0 ← 默认
ticker.add(callback, undefined, UPDATE_PRIORITY.HIGH) // 10
ticker.add(callback, undefined, UPDATE_PRIORITY.UTILITY) // 20
ticker.add(callback, undefined, UPDATE_PRIORITY.INTERACTIVE)// 50
| 优先级常量 | 值 | 典型用途 |
|---|---|---|
INTERACTIVE | 50 | 输入处理 |
HIGH | 10 | 粒子系统 |
NORMAL | 0 | 一般游戏逻辑 |
LOW | -10 | 网络同步 |
UTILITY | 20 | 统计、调试 |
deltaTime 使用
deltaTime 是帧间隔的归一化值,以 60fps 为基准(60fps 时 deltaTime ≈ 1)。
// ❌ 错误 —— 不同帧率下速度不一致
sprite.x += 5;
// ✅ 正确 —— 帧率无关的移动
sprite.x += 5 * ticker.deltaTime;
// 计算实际每秒移动像素
const SPEED = 300; // 每秒 300 像素
const pixelsPerFrame = SPEED / 60; // 60fps 时每帧 5 像素
sprite.x += pixelsPerFrame * ticker.deltaTime;
deltaTime 与 deltaMS 对比
| 属性 | 60fps | 30fps | 说明 |
|---|---|---|---|
| deltaTime | ~1.0 | ~2.0 | 归一化值(60fps 为 1) |
| deltaMS | ~16.67 | ~33.33 | 实际毫秒间隔 |
| FPS | 60 | 30 | 当前帧率 |
⚠️ 注意: deltaTime 可能出现异常大的值(如切标签页后恢复),建议设置上限:
app.ticker.add((ticker) => {
const delta = Math.min(ticker.deltaTime, 5); // 最大 5 帧
update(delta);
});
帧率控制
// 限制最大帧率
app.ticker.maxFPS = 30;
// 暂停/恢复
app.ticker.stop();
app.ticker.start();
// 设置速度倍率(影响 deltaTime)
app.ticker.speed = 2; // 2 倍速
补间动画(Tween)
Pixi.js 不内置 Tween 系统,但可以轻松实现:
基础 Tween 实现
interface TweenConfig {
target: any;
props: Record<string, number>;
duration: number; // 毫秒
easing?: (t: number) => number;
onUpdate?: () => void;
onComplete?: () => void;
}
class Tween {
private target: any;
private startValues: Record<string, number> = {};
private endValues: Record<string, number>;
private duration: number;
private elapsed: number = 0;
private easing: (t: number) => number;
private onUpdate?: () => void;
private onComplete?: () => void;
public finished: boolean = false;
constructor(config: TweenConfig) {
this.target = config.target;
this.endValues = config.props;
this.duration = config.duration;
this.easing = config.easing || ((t) => t); // 线性
this.onUpdate = config.onUpdate;
this.onComplete = config.onComplete;
// 记录起始值
for (const key in this.endValues) {
this.startValues[key] = this.target[key];
}
}
update(deltaMS: number): boolean {
if (this.finished) return true;
this.elapsed += deltaMS;
const progress = Math.min(this.elapsed / this.duration, 1);
const easedProgress = this.easing(progress);
for (const key in this.endValues) {
const start = this.startValues[key];
const end = this.endValues[key];
this.target[key] = start + (end - start) * easedProgress;
}
this.onUpdate?.();
if (progress >= 1) {
this.finished = true;
this.onComplete?.();
return true;
}
return false;
}
}
// Tween 管理器
class TweenManager {
private tweens: Tween[] = [];
add(config: TweenConfig): Tween {
const tween = new Tween(config);
this.tweens.push(tween);
return tween;
}
update(deltaMS: number) {
this.tweens = this.tweens.filter((t) => !t.update(deltaMS));
}
}
// 使用
const tweenManager = new TweenManager();
app.ticker.add((ticker) => {
tweenManager.update(ticker.deltaMS);
});
// 移动动画
tweenManager.add({
target: sprite,
props: { x: 600, y: 400 },
duration: 1000,
easing: easeInOutQuad,
onComplete: () => console.log('移动完成'),
});
缓动函数
// 常用缓动函数
const easing = {
// 线性
linear: (t: number) => t,
// 二次
easeInQuad: (t: number) => t * t,
easeOutQuad: (t: number) => t * (2 - t),
easeInOutQuad: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
// 三次
easeInCubic: (t: number) => t * t * t,
easeOutCubic: (t: number) => (--t) * t * t + 1,
easeInOutCubic: (t: number) =>
t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
// 弹性
easeOutElastic: (t: number) => {
const p = 0.3;
return Math.pow(2, -10 * t) * Math.sin((t - p / 4) * (2 * Math.PI) / p) + 1;
},
// 弹跳
easeOutBounce: (t: number) => {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
},
};
缓动效果对比
| 函数 | 曲线特征 | 适用场景 |
|---|---|---|
linear | 匀速 | 滚动、旋转 |
easeInQuad | 慢 → 快 | 物体启动 |
easeOutQuad | 快 → 慢 | 物体停止 |
easeInOutQuad | 慢 → 快 → 慢 | 平滑过渡 |
easeOutElastic | 弹性振荡 | 强调效果、弹窗 |
easeOutBounce | 弹跳 | 落地效果 |
状态机动画
游戏中的角色动画通常使用有限状态机(FSM)管理:
type AnimState = 'idle' | 'run' | 'jump';
class AnimFSM {
private current: AnimState = 'idle';
private states: Record<AnimState, () => void>;
constructor(states: Record<AnimState, () => void>) {
this.states = states;
}
set(state: AnimState) {
if (this.current === state) return;
this.current = state;
this.states[state]();
}
}
// 使用:根据输入切换状态
const fsm = new AnimFSM({
idle: () => sprite.texture = idleTexture,
run: () => sprite.texture = runTexture,
jump: () => sprite.texture = jumpTexture,
});
app.ticker.add(() => {
if (keys.has('ArrowRight')) fsm.set('run');
else if (keys.has('Space')) fsm.set('jump');
else fsm.set('idle');
});
Sprite 帧动画
import { Assets, AnimatedSprite } from 'pixi.js';
// 加载精灵表
const sheet = await Assets.load('/image/hero-sheet.json');
// 获取动画帧
const frames = [
sheet.textures['walk-0'],
sheet.textures['walk-1'],
sheet.textures['walk-2'],
sheet.textures['walk-3'],
];
// 创建帧动画
const animSprite = new AnimatedSprite(frames);
animSprite.anchor.set(0.5);
animSprite.position.set(400, 300);
animSprite.animationSpeed = 0.15; // 每帧推进 0.15 帧
animSprite.loop = true;
animSprite.play();
app.stage.addChild(animSprite);
物理步进
对于需要物理模拟的游戏,使用固定时间步长:
const PHYSICS_STEP = 1 / 60; // 60Hz 物理更新
let accumulator = 0;
app.ticker.add((ticker) => {
accumulator += ticker.deltaMS / 1000;
// 固定步长物理更新
while (accumulator >= PHYSICS_STEP) {
physicsStep(PHYSICS_STEP);
accumulator -= PHYSICS_STEP;
}
// 插值渲染
const alpha = accumulator / PHYSICS_STEP;
renderInterpolated(alpha);
});
Ticker 与游戏循环
标准游戏循环模板
class GameLoop {
app: Application;
private systems: Array<(delta: number) => void> = [];
constructor(app: Application) {
this.app = app;
}
addSystem(updateFn: (delta: number) => void) {
this.systems.push(updateFn);
}
start() {
this.app.ticker.add((ticker) => {
const delta = ticker.deltaTime;
for (const system of this.systems) {
system(delta);
}
});
}
}
// 使用
const gameLoop = new GameLoop(app);
gameLoop.addSystem((d) => inputSystem.update(d));
gameLoop.addSystem((d) => physicsSystem.update(d));
gameLoop.addSystem((d) => aiSystem.update(d));
gameLoop.addSystem((d) => renderSystem.update(d));
gameLoop.start();
游戏开发场景
场景:弹幕旋转发射
const bullets: Sprite[] = [];
let spawnTimer = 0;
const BULLET_SPEED = 3;
const BULLET_INTERVAL = 3;
app.ticker.add((ticker) => {
const delta = ticker.deltaTime;
spawnTimer += delta;
if (spawnTimer > BULLET_INTERVAL) {
spawnTimer = 0;
const bullet = new Sprite(bulletTexture);
bullet.anchor.set(0.5);
bullet.position.set(400, 300);
bullet.rotation = Math.random() * Math.PI * 2;
app.stage.addChild(bullet);
bullets.push(bullet);
}
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += Math.cos(b.rotation) * BULLET_SPEED * delta;
b.y += Math.sin(b.rotation) * BULLET_SPEED * delta;
if (b.x < -20 || b.x > 820 || b.y < -20 || b.y > 620) {
b.destroy();
bullets.splice(i, 1);
}
}
});
⚠️ 常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 动画速度不一致 | 未使用 deltaTime | 始终乘以 deltaTime |
| 切标签页后动画跳跃 | deltaTime 过大 | 限制 deltaTime 上限 |
| 帧率不稳定 | update 回调中做重计算 | 将重计算分散到多帧 |
| Ticker 回调未移除 | 组件销毁时未清理 | 在 destroy 时 remove ticker 回调 |
| 固定步长物理抖动 | 插值缺失 | 使用 accumulator 插值渲染 |
💡 进阶提示
Ticker 节流: 对不需要每帧更新的系统降低频率:
let uiTimer = 0; app.ticker.add((ticker) => { uiTimer += ticker.deltaTime; if (uiTimer >= 3) { // 每 3 帧更新一次 UI uiTimer = 0; updateUI(); } });rAF 节流 vs Ticker: 低频率更新(如网络心跳)直接用
setInterval,游戏逻辑始终用 ticker。
扩展阅读
- Pixi.js Ticker API
- Game Loop Pattern
- Fix Your Timestep!
- Easing Functions Cheat Sheet
- requestAnimationFrame 深入理解