ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

planck.js 版本演进全解析:从 1.1.5 到 1.5.0 的架构升级与序列化改进

planck.js 版本演进全解析:从 1.1.5 到 1.5.0 的架构升级与序列化改进
  • 游戏开发

【免费下载链接】planck.js

2D JavaScript Physics Engine

项目地址:https://gitcode.com/gh_mirrors/pl/planck.js
点击查看免费下载

planck.js 是一款面向跨平台 HTML5 游戏开发的 2D JavaScript/TypeScript 物理引擎(MIT 许可),其 CHANGELOG.md 完整记录了从 1.1.5 到 1.5.0 共九个版本的核心演进。本文以该变更记录为主线,结合仓库源码逐一还原每一次变更背后的实现细节:新增的add-body/add-fixture/add-joint世界事件、world.queueUpdate()延迟更新机制、序列化体系的反复修正、Vec2 数学工具的重构,以及 Testbed 与 DataDriver 的架构升级。读完本文,你将能精确掌握各版本间 API 与行为的差异,在升级 planck.js 时快速定位受影响代码,并理解引擎内部"世界锁定"、事件发布、序列化还原等底层设计。

一、版本总览与演进主线

当前仓库 package.json 中的版本号为1.5.0,与 CHANGELOG 最新条目一致。将各版本变更按主题归类,可以看到三条清晰的演进主线:

主题涉及版本核心变更
世界事件系统1.5.0新增add-body、add-fixture、add-joint事件发布
模拟流程控制1.2.0新增world.queueUpdate()延迟更新队列
序列化体系1.5.0、1.4.3、1.4.1、1.4.0body/edge/distance-joint 序列化字段收敛与 bug 修复
数学工具1.1.6、1.4.3静态Vec2.normalize、clampVec2参数类型收紧、addVec2修复
测试台(Testbed)1.4.0、1.4.2、1.3.0、1.1.5Testbed/TestbedInterface 拆分、渲染重写、绘制与交互修复
数据驱动1.3.0新增实验性 DataDriver

下文按"功能主题"而非"版本号"组织章节,便于开发者在升级时按模块核对行为差异。

二、世界事件系统:新增 add-body / add-fixture / add-joint(1.5.0)

2.1 变更内容

1.5.0 的核心 Minor Change 是6ede4f3: Publish add-body, add-fixture, add-joint events,即 World 开始对外发布"实体被创建"的事件,与早已存在的remove-body、remove-fixture、remove-joint事件形成完整的创建/销毁事件闭环。

2.2 源码级实现

在 World.ts 中,publish是全部事件的分发入口(publish 实现),它遍历_listeners[name]数组并逐一调用监听器,返回监听器数量:

publish(name: string, arg1?: any, arg2?: any, arg3?: any): number { const listeners = this._listeners && this._listeners[name]; if (!listeners || !listeners.length) { return 0; } for (let l = 0; l < listeners.length; l++) { listeners[l].call(this, arg1, arg2, arg3); } return listeners.length; }

新事件的触发点分别位于:

  • add-body:在World.createBody内部将新 body 挂入m_bodyList后触发(World.ts);
  • add-joint:在World.createJoint完成关节双向边(edge)链接后触发(World.ts);
  • add-fixture:在Body.createFixture中创建Fixture实例并挂载后,由 body 转发到所属世界触发(Body.ts)。

对应的on/off类型重载也已补齐(World.ts):

/** Listener is called when a body is added. */ on(name: "add-body", listener: (body: Body) => void): World; /** Listener is called when a joint is added. */ on(name: "add-joint", listener: (joint: Joint) => void): World; /** Listener is called when a fixture is added. */ on(name: "add-fixture", listener: (fixture: Fixture) => void): World;

2.3 使用示例

import { World } from "planck"; const world = new World({ gravity: { x: 0, y: -10 } }); world.on("add-body", (body) => { console.log("new body added:", body.getPosition()); }); world.on("add-fixture", (fixture) => { console.log("new fixture:", fixture.getShape().getType()); }); world.on("add-joint", (joint) => { console.log("new joint created"); }); const body = world.createBody({ type: "dynamic", position: { x: 0, y: 5 } });

2.4 与既有事件体系的配合

在 1.5.0 之前,World 已提供begin-contact、end-contact、pre-solve、post-solve以及remove-body、remove-joint、remove-fixture(World.ts)。需要特别注意的是,官方对这些回调的通用警告是:不要在回调内部创建或销毁世界实体("You cannot create/destroy world entities inside these callbacks"),因为此时世界可能处于模拟步骤(step)的锁定状态,相关修改需要借助下一节介绍的queueUpdate延迟执行。销毁类事件的触发点可参考 World.ts(destroyBody 级联发布 remove-joint/remove-fixture)与 Body.ts(destroyFixture 发布 remove-fixture)。

三、world.queueUpdate():延迟到模拟步骤之后执行更新(1.2.0)

3.1 变更内容

1.2.0 新增world.queueUpdate()(commitf0127f4),用于"在当前的模拟步(simulation step)结束后执行回调";同版本补丁97bb79e又对其实现进行了改进。这是 planck.js 为解决"世界锁定期间无法修改物理实体"这一经典问题提供的第一方方案。

3.2 为什么需要它:世界锁定机制

在 World.ts 中,step()一开始就设置this.m_locked = true(step 入口),直到模拟、TOI 处理、清力全部完成才置回false(step 收尾)。在锁定期间,几乎所有修改类 API 都会被拒绝,例如:

  • Body.setType、setActive、setTransform、setMassData、createFixture、destroyFixture(Body.ts 等处的isWorldLocked()断言);
  • World.createBody、destroyBody、createJoint、destroyJoint、shiftOrigin(World.ts)。

这些方法的注释均明确提示:"Use queueUpdate to schedule a function to be called after the step."

3.3 实现细节

queueUpdate的实现非常简洁(World.ts):

/** * Queue a function to be called after ongoing simulation step. * If no simulation is in progress call it immediately. */ queueUpdate(callback: (world: World) => unknown): void { if (!this.isLocked()) { callback(this); } else { this.m_step_callback.push(callback); } }

关键设计点:

  1. 未锁定时立即执行:如果世界不在模拟中,回调直接同步调用,避免不必要的延迟;
  2. 锁定时入队:回调被推入m_step_callback数组(初始化于 World.ts);
  3. step 结束后统一排空:在step()解锁(m_locked = false)之后、发布post-step事件之前,用while ((callback = this.m_step_callback.shift())) callback(this);按 FIFO 顺序执行全部回调(World.ts)。

3.4 典型用法

在接触回调中安全地销毁实体:

world.on("begin-contact", (contact) => { const bodyA = contact.getFixtureA().getBody(); world.queueUpdate((world) => { if (!bodyA.isDestroyed()) { world.destroyBody(bodyA); } }); });

3.5 与 pre-step / post-step 事件的关系

step()的完整事件时序为:发布pre-step(World.ts)→ 锁定世界 → 更新接触、求解、TOI → 解锁 → 排空m_step_callback→ 发布post-step(World.ts)。因此queueUpdate的回调总是发生在世界可安全修改、且post-step监听器执行之前。

四、序列化体系的多轮修正(1.5.0 / 1.4.3 / 1.4.1 / 1.4.0)

序列化是 planck.js 官方文档化能力之一,可通过 serializer/index.ts 导出的Serializer.toJson(world)/Serializer.fromJson(json)将整个世界(body、fixture、joint、shape)保存为 JSON 并还原。CHANGELOG 中有一组密集的序列化相关变更,反映出 1.5.x 阶段对序列化输出纯净度的严格要求。

4.1 为 Body 序列化补充 fixedRotation(1.5.0,9415379)

此前Body._serialize()输出type、bullet、position、angle、linearVelocity、angularVelocity和fixtures(Body.ts),但缺少fixedRotation标志,导致"禁止旋转"的刚体在保存/加载后丢失该属性。修复后序列化输出包含:

return { type: this.m_type, bullet: this.m_bulletFlag, fixedRotation: this.m_fixedRotationFlag, position: this.m_xf.p, angle: this.m_xf.q.getAngle(), linearVelocity: this.m_linearVelocity, angularVelocity: this.m_angularVelocity, fixtures, // ... };

对应的 JSON Schema 也同步增加了该字段:schema.json 中fixedRotation: { "type": "boolean" }。fixedRotation的默认值为false(Body.ts),在质量数据重算时其作用体现在:若m_fixedRotationFlag为真,则惯性张量m_invI置零(Body.ts),引擎将完全忽略刚体的旋转自由度。

4.2 移除 distance-joint 序列化的临时字段(1.5.0,7791ea1)

DistanceJoint._serialize()的输出收敛为:type、bodyA、bodyB、collideConnected、frequencyHz、dampingRatio、localAnchorA、localAnchorB、length(DistanceJoint.ts)。其中:

  • frequencyHz为质量-弹簧-阻尼系统的频率(0 表示禁用软约束);
  • dampingRatio为阻尼比(0 = 无阻尼,1 = 临界阻尼);
  • length为约束距离,默认按两个 localAnchor 的世界距离自动计算(DistanceJoint.ts)。

这些字段全部属于"重建关节所需的最小完整信息",而临时中间变量(如求解器用的质量矩阵等)不再被写入 JSON,从而显著减小存档体积并避免还原时的脏数据。

4.3 EdgeShape 序列化移除 hasVertex0 / hasVertex3(1.5.0,86c9079)

修改前EdgeShape._serialize()会输出vertex0、vertex3及其布尔标志hasVertex0、hasVertex3(此前 schema 中仍有这两个字段,见 schema.json);修改后只保留四个顶点坐标(EdgeShape.ts):

_serialize(): object { return { type: this.m_type, vertex1: this.m_vertex1, vertex2: this.m_vertex2, vertex0: this.m_vertex0, vertex3: this.m_vertex3, }; }

反序列化端则根据顶点是否存在(非零向量)来恢复相邻顶点:_deserialize中调用setPrevVertex(data.vertex0)/setNextVertex(data.vertex3),而setNextVertex/setPrevVertex内部会依据传入向量自动设置m_hasVertex0/m_hasVertex3(EdgeShape.ts)。vertex0/vertex3是链形状(ChainShape)用于平滑碰撞(ghost collision)的可选相邻顶点,ChainShape 在生成子边时会按需填充它们(ChainShape.ts)。

4.4 Polygon / Chain 反序列化改用 Vec2Value(1.5.0,4fc10a7)

PolygonShape._deserialize与ChainShape._deserialize在构造顶点数组时,将顶点显式声明为Vec2Value类型并逐一push(PolygonShape.ts、ChainShape.ts):

static _deserialize(data: any, fixture: any, restore: any): PolygonShape { const vertices: Vec2Value[] = []; if (data.vertices) { for (let i = 0; i < data.vertices.length; i++) { vertices.push(data.vertices[i]); } } // ... }

该改动让反序列化直接复用引擎中广泛使用的Vec2Value({ x, y }结构接口),避免多余的包装对象,使序列化往返(round-trip)在类型层面保持一致。

4.5 序列化 API 标记调整与往返测试(1.4.0,e1a2717)

1.4.0 将各_serialize/_deserialize的文档标记从@internal改为@hidden,意味着这些方法虽不进入公开 API 文档,但属于序列化机制对外稳定契约的一部分(Serializer 依赖它们完成toJson/fromJson)。仓库内的往返一致性测试位于 serializer/test/Serialize.test.ts:它构建包含 CircleShape、BoxShape、DistanceJoint 的世界,执行Serializer.toJson(world)后再Serializer.fromJson(json),断言两次序列化结果深度相等且逐行一致,可作为验证上述字段改动正确性的回归基准。

4.6 修复 addVec2(1.4.3,0bba3b2)

1.4.3 修复了addVec2的 bug。该函数位于 common/Matrix.ts:

export function addVec2(out: Vec2Value, v: Vec2Value, w: Vec2Value): Vec2Value { out.x = v.x + w.x; out.y = v.y + w.y; return out; }

addVec2是矩阵/向量批处理接口的一部分,被多边形质心计算等热路径调用(例如 PolygonShape.ts 中matrix.addVec2(massData.center, center, s))。这类函数在后续的 Vec2.ts 重构中逐渐被面向对象的Vec2方法替代,但修复补丁保证了从 Matrix 导入的函数族在过渡期行为正确。

五、Vec2 数学工具重构(1.1.6)

5.1 新增静态 Vec2.normalize(f31114b)

1.1.6 为Vec2增加了静态normalize(v: Vec2Value): Vec2,与实例方法v.normalize()并存(Vec2.ts):

/** Convert this vector into a unit vector. @returns old length */ normalize(): number { const length = this.length(); if (length < EPSILON) { return 0.0; } const invLength = 1.0 / length; this.x *= invLength; this.y *= invLength; return length; } /** Returns a new unit vector from the provided vector. @returns new unit vector */ static normalize(v: Vec2Value): Vec2 { const length = Vec2.lengthOf(v); if (length < EPSILON) { return Vec2.zero(); } const invLength = 1.0 / length; return Vec2.neo(v.x * invLength, v.y * invLength); }

区别在于:实例方法就地归一化并返回旧长度,静态版本返回新的单位向量且不修改入参,零向量时返回Vec2.zero()。静态版本适合以Vec2Value形式传入裸对象(如从 JSON 解析的顶点)而无需先构造Vec2实例的场景。内部实现中,DistanceJoint、RopeJoint、FrictionJoint的求解器以及多边形法线计算大量依赖归一化操作(如 DistanceJoint.ts 的u.normalize()同时取得长度与方向)。

5.2 clampVec2 参数收紧为 Vec2Value(bee0e16)

同版本将clampVec2的签名从旧式对象改为Vec2Value(Vec2.ts):

static clampVec2(v: Vec2Value, min?: Vec2Value, max?: Vec2Value): Vec2Value { return { x: clamp(v.x, min?.x, max?.x), y: clamp(v.y, min?.y, max?.y), }; }

该改动是"全仓库统一使用Vec2Value结构接口"进程的一部分:凡以纯数据结构传入、由引擎内部复制的向量参数,一律采用Vec2Value,减少对Vec2类实例的隐式依赖。Math.test.ts中已有对normalize的单元测试(v.setNum(3, 4)后归一化期望得到(3/5, 4/5),见test/Math.test.ts),可作为该系列改动的验证依据。

六、Testbed 架构重构(1.4.0 / 1.4.2 / 1.3.0 / 1.1.5)

6.1 拆分 TestbedInterface 与 Testbed 类(1.4.0,0dcc98b)

1.4.0 将原先混在一起的"测试台配置与渲染接口"拆分为两部分(util/Testbed.ts):

  • Testbed类:负责挂载与启动,仅提供两个静态方法:
    • Testbed.mount(options):挂载测试台,返回TestbedInterface;
    • Testbed.start(world):挂载后立即开始模拟与渲染(Testbed.ts)。
  • TestbedInterface:描述测试台对外可配置的全部能力,包括width、height、x、y(视口偏移)、hz(模拟步频率)、speed(模拟速度,默认 1)、background、mouseForce、activeKeys,以及step/keydown/keyup回调与drawPoint/drawCircle/drawEdge/drawPolygon/drawChain/drawAABB等绘制方法(Testbed.ts)。

拆分后,Testbed.mount()与testbed.start(world)可分离调用,便于在启动前定制参数。仓库保留的testbed()函数(标记@deprecated)则继续以回调风格兼容旧用法(Testbed.ts)。实际可运行的测试台入口见 testbed/index.ts 与 testbed/StageTestbed.ts。

6.2 Testbed 渲染重写(1.3.0,bb9bb87)与绘制修复(1.4.2,9343ffc)

1.3.0 对 Testbed 的渲染管线做了整体重写(Testbed rendering rewrite),与之配套的绘制与视图组件位于 testbed/world-view(BodyComponent、WorldComponent、各 Shape/Joint 组件等)。1.4.2 的补丁9343ffc(Fix testbed drawing)修正了重写过程中遗留的绘制问题。渲染重写后,测试台对形状、关节的绘制职责由各 world-view 组件承担,用户自定义绘制仍可通过TestbedInterface.drawPoint等原始绘制方法完成。

6.3 mouseForce 为 0 时不响应指针交互(1.3.0,ce1c486)

TestbedInterface.mouseForce控制鼠标拖动施加到刚体上的力(Testbed.ts)。ce1c486的语义是:当mouseForce === 0时,测试台完全不处理指针交互,避免在鼠标悬停/点击时创建无意义的 MouseJoint 或产生无效拖拽。

6.4 取消隐藏 style 字段类型(1.1.5,fbd0021)

Style接口(stroke/fill/lineWidth,Testbed.ts)从@hidden改为公开类型导出,使自定义渲染可以安全引用 stroke/fill 颜色与线宽类型,属于测试台渲染 API 正式化的第一步,为 1.3.0 的渲染重写铺路。

七、DataDriver:实验性数据驱动实体管理(1.3.0)

1.3.0 新增DataDriver(56193e7,标注为 experimental / demo use-case),用于"基于游戏对象创建、更新、销毁物理实体"。其接口定义与实现位于 util/DataDriver.ts:

export interface DataDriverListener<D, R> { enter: (d: D) => R | null; exit: (d: D, ref: R) => void; update: (d: D, ref: R) => void; }

核心机制:

  1. 构造:new DataDriver(keyFn, listener),其中keyFn从数据对象中提取唯一 ID,listener提供enter(创建物理实体,返回引用ref)、exit(销毁实体)、update(更新实体)三个回调;
  2. update(data):接收一个数据数组,内部用_map/_xmap双表做差集对比,自动识别进入集合的项(_entered)与离开集合的项(_exited)(DataDriver.ts);
  3. 生命周期顺序:先对退出的数据调用exit(并从_refMap删除引用),再对进入的数据调用enter(把返回值存入_refMap),最后对仍在集合中的项调用update(DataDriver.ts);
  4. 查询:ref(d)按 key 返回与该数据关联的物理实体引用(DataDriver.ts)。

这种"以数据驱动物理实体"的模式天然适合与 ECS 或响应式游戏状态配合:游戏对象数组变化时,物理世界中的 body/fixture/joint 自动同步增删改。由于标注为实验性 API(@experimental @hidden),其接口在未来版本可能调整。

八、其他补丁与使用建议

  • 修复 AABB.rayCast(1.4.0,3446820):修正了AABB.rayCast(output, input)的射线检测实现(collision/AABB.ts)。该方法采用《Real-time Collision Detection》(p179)的 slab 法:计算射线参数tmin/tmax,当tmax < tmin或tmax < 0时返回false。该修复直接影响到World.rayCast对静态/动态物体的命中判定,相关用法见 docs/world/ray-cast.md。
  • 修复 shape name 别名导出(1.4.1,d12d0cc):确保形状的别名导出(如 BoxShape.ts 的export { BoxShape as Box })在打包产物中名称正确,避免import { Box } from "planck"在构建后被 tree-shaking 或重命名破坏。
  • 移除 Edge 序列化布尔字段、收敛 distance-joint 字段(1.5.0):升级到 1.5.0 后,由旧版本Serializer.toJson生成的存档中若包含hasVertex0/hasVertex3等字段,新版fromJson会忽略多余字段(schema 中additionalProperties: false仅用于校验场景),推荐按 serializer/test/Serialize.test.ts 的往返测试模式在升级后重新校验存档。

九、如何验证与运行

仓库提供完整的验证链路(package.json 的 scripts 字段):

  • 单元测试:npm test(基于 vitest,覆盖 Math、World、Collision、序列化往返等);
  • 类型检查:npm run test:types(基于 tsd 检查dist/planck.d.ts与dist/planck-with-testbed.d.ts);
  • 全量预检:npm run preflight(lint + 类型检查 + 单测);
  • 测试台体验:npm run testbed启动 vite 开发服务器,example/目录下存放 60+ 可运行示例(如 example/Boxes.ts、example/ContinuousTest.ts),可用作事件、序列化、关节等特性的交互验证。

十、升级清单速查

目标版本升级时需要检查的代码
1.5.0序列化存档兼容性(Edge 无 hasVertex0/3 字段、distance-joint 字段收敛);可选用新增的add-body/add-fixture/add-joint事件
1.4.xTestbed.mount()+start()新用法;TestbedInterface类型导入变化;AABB.rayCast行为修正
1.3.0Testbed 渲染重写后的自定义绘制适配;mouseForce === 0时指针交互被禁用
1.2.0可在回调中改用world.queueUpdate()替代"锁内修改"的 hack
1.1.xVec2.normalize静态方法、clampVec2的Vec2Value参数类型

以上所有变更均可在当前仓库对应源码中逐一核对,CHANGELOG 本身(CHANGELOG.md)是追踪 planck.js 演进最权威的入口。

  • 游戏开发

【免费下载链接】planck.js

2D JavaScript Physics Engine

项目地址:https://gitcode.com/gh_mirrors/pl/planck.js
点击查看免费下载
上一篇:Easydict 回译语言方向保留机制解析:Auto 检测、双 Auto 与偏好传播的完整实现
下一篇:Jspreadsheet v3 事件系统完全指南:事件绑定、回调签名与表格脚本定制

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进