我不知道的 Pixi.js(09)— 物理引擎的集成实践
Pixi.js 是渲染引擎,不是物理引擎——它能把东西画出来,但不会让东西"掉下去"。要实现重力、碰撞、弹跳这些物理行为,需要集成第三方物理引擎。这篇文章以 Matter.js 为例,拆解集成过程中的几个核心问题:坐标如何同步、时间步进怎么对齐、碰撞事件如何转化为视觉反馈。
Pixi.js 是渲染引擎,不是物理引擎——它能把东西画出来,但不会让东西”掉下去”。要实现重力、碰撞、弹跳这些物理行为,需要集成第三方物理引擎。这篇文章以 Matter.js 为例,拆解集成过程中的几个核心问题:坐标如何同步、时间步进怎么对齐、碰撞事件如何转化为视觉反馈。
一、两个独立的世界
集成物理引擎的第一个认知点:Pixi.js 的场景树和物理引擎的世界是两套完全独立的数据结构。
Pixi.js 有一棵场景树,里面是 Container、Sprite、Graphics。Matter.js 有一个 World,里面是 Body(刚体)。两者互相不知道对方的存在。
import { Application, Sprite } from 'pixi.js';
import Matter from 'matter-js';
const app = new Application();
await app.init({ width: 800, height: 600 });
// 物理世界
const engine = Matter.Engine.create();
const ball = Matter.Bodies.circle(400, 100, 30, { restitution: 0.8 });
const ground = Matter.Bodies.rectangle(400, 580, 800, 40, { isStatic: true });
Matter.Composite.add(engine.world, [ball, ground]);
// 渲染世界
const ballSprite = Sprite.from('ball.png');
ballSprite.anchor.set(0.5);
const groundSprite = Sprite.from('ground.png');
groundSprite.anchor.set(0.5);
groundSprite.position.set(400, 580);
app.stage.addChild(groundSprite, ballSprite);
到这一步,物理世界里有一个会掉落的球和一个静态地面,渲染世界里有两个不会动的 Sprite。接下来的问题是:怎么让 Sprite 跟着物理体动。
二、坐标同步:每帧读取物理体的位置
最直接的同步方式是在 Ticker 回调中,每帧把物理体的 position 和 angle 复制到对应 Sprite 上:
app.ticker.add(() => {
// 推进物理模拟
Matter.Engine.update(engine, app.ticker.deltaMS);
// 同步位置和旋转
ballSprite.position.set(ball.position.x, ball.position.y);
ballSprite.rotation = ball.angle;
});
这段代码能工作,但有一个隐含的假设:Pixi.js 的坐标系和 Matter.js 的坐标系方向一致。两者的原点都在左上角,x 轴向右为正,y 轴向下为正——碰巧一致。但如果你的 Pixi.js 场景做了缩放或平移(比如上一篇的多分辨率适配),同步就会出问题。
换句话说,如果 app.stage.scale 不是 (1, 1),物理体的 (400, 100) 和 Sprite 的 (400, 100) 在屏幕上的位置就不同了。解决方案是在物理层和渲染层之间加一个缩放系数:
const PHYSICS_SCALE = 1; // 如果 stage 没缩放,scale = 1
app.ticker.add(() => {
Matter.Engine.update(engine, app.ticker.deltaMS);
ballSprite.position.set(ball.position.x * PHYSICS_SCALE, ball.position.y * PHYSICS_SCALE);
ballSprite.rotation = ball.angle;
});
三、固定时间步进 vs 可变时间步进
上面的代码用 app.ticker.deltaMS 作为物理引擎的时间步长。这是”可变时间步进”——每帧的物理计算量随帧率波动。
问题是,物理模拟对时间步长很敏感。帧率不稳定时,可变步进会导致物理行为不确定——同样的初始条件,跑出不同的结果。
更稳定的做法是固定时间步进(Fixed Timestep):
const FIXED_STEP = 1000 / 60; // 固定 60 次/秒
let accumulator = 0;
app.ticker.add(() => {
accumulator += app.ticker.deltaMS;
// 每帧可能执行 0 次或多次物理步进
while (accumulator >= FIXED_STEP) {
Matter.Engine.update(engine, FIXED_STEP);
accumulator -= FIXED_STEP;
}
// 同步渲染(使用最新的物理状态)
ballSprite.position.set(ball.position.x, ball.position.y);
ballSprite.rotation = ball.angle;
});
说白了,固定步进把物理模拟从帧率中解耦了:物理始终以 60 步/秒的速度推进,无论实际帧率是 30 还是 144。如果某帧耗时久(比如 33ms),就多执行两次物理步进补回来。
这里有一个很多人会忽略的细节——固定步进的 while 循环在帧率极低时会积累大量步进。比如某帧卡了 500ms,循环需要执行 30 次。这会进一步拖慢这一帧,形成恶性循环。加一个上限保护:
const MAX_STEPS = 5;
let steps = 0;
while (accumulator >= FIXED_STEP && steps < MAX_STEPS) {
Matter.Engine.update(engine, FIXED_STEP);
accumulator -= FIXED_STEP;
steps++;
}
// 超过 MAX_STEPS 后丢弃剩余 accumulator,接受物理的短暂"慢动作"
if (steps >= MAX_STEPS) accumulator = 0;
四、碰撞事件:从物理回调到视觉反馈
Matter.js 通过事件系统通知碰撞发生。但碰撞回调中只有物理体(Body),没有 Sprite。需要建立物理体到 Sprite 的映射。
一种方式是用 Body 的 plugin 属性存储关联的 Sprite:
const ball = Matter.Bodies.circle(400, 100, 30);
ball.plugin = { sprite: ballSprite };
Matter.Events.on(engine, 'collisionStart', (event) => {
event.pairs.forEach((pair) => {
const spriteA = pair.bodyA.plugin?.sprite;
const spriteB = pair.bodyB.plugin?.sprite;
if (spriteA) spriteA.tint = 0xff0000; // 碰撞时变红
if (spriteB) spriteB.tint = 0xff0000;
// 0.1 秒后恢复
setTimeout(() => {
if (spriteA) spriteA.tint = 0xffffff;
if (spriteB) spriteB.tint = 0xffffff;
}, 100);
});
});
另一种更常见的模式是用 Map 维护双向映射:
const bodyToSprite = new Map();
function createPhysicsSprite(x, y, radius, texture) {
const body = Matter.Bodies.circle(x, y, radius);
const sprite = new Sprite(texture);
sprite.anchor.set(0.5);
bodyToSprite.set(body, sprite);
Matter.Composite.add(engine.world, body);
app.stage.addChild(sprite);
return { body, sprite };
}
function destroyPhysicsSprite({ body, sprite }) {
Matter.Composite.remove(engine.world, body);
sprite.removeFromParent();
bodyToSprite.delete(body);
}
问题的关键在于——销毁时要同时清理物理体和 Sprite。只删 Sprite 不删 Body,物理体还在世界中占用计算资源;只删 Body 不删 Sprite,画面上会残留”幽灵对象”。
五、Matter.js 之外的选择
Matter.js 是最流行的 2D 物理引擎之一,但不是唯一选择:
- Planck.js:Box2D 的 JavaScript 移植,API 更接近 Box2D,物理精度更高,适合需要精确碰撞的场景
- Rapier:用 Rust 写的 WASM 物理引擎,性能远超纯 JS 实现,适合物体数量多(1000+)的场景
- p2.js:轻量级 2D 物理引擎,约束系统丰富
无论用哪个引擎,集成模式都是一样的:创建物理世界 → 每帧步进 → 同步坐标到 Pixi.js 对象 → 监听碰撞事件做视觉反馈。
总结
Pixi.js 和物理引擎是两个独立的世界,集成的核心是三步同步:坐标同步(每帧从物理体读取位置/旋转写入 Sprite)、时间同步(固定步进优于可变步进,避免帧率波动影响物理行为)、事件同步(建立 Body → Sprite 映射,把碰撞事件转化为视觉反馈)。销毁时两边都要清理,防止幽灵对象或资源泄漏。
本系列其他文章:
- 上一篇:多分辨率与自适应渲染
- 下一篇:插件与扩展生态
延伸阅读: