5.1 内置物理与 Box2D 后端
站内 game/ 顶层有一批讲物理概念的文章(碰撞检测、约束、迭代求解),它们回答的是「物理引擎一般怎么做」。这一节反过来:Cocos 3.8 到底提供了哪几套 2D 物理后端、它们各自能做什么、你的《方块冲刺》该挂哪一套。结论先行——Cocos 的 2D 物理有两条完全不同的实现路径,选错了会出现「回调有、动力学没有」这类看起来像 bug 的表现。
5.1.1 两套后端:builtin 与 box2d
Cocos Creator 的 2D 物理并不是只有「一个引擎」,而是一个可插拔的后端选择器。cc.d.ts 里 PhysicsSystem2D 上有一组静态 getter,直接暴露了编译进当前构建的后端:
import { PhysicsSystem2D } from 'cc';
console.log(PhysicsSystem2D.PHYSICS_NONE); // 未启用任何 2D 物理
console.log(PhysicsSystem2D.PHYSICS_BUILTIN); // 内置(纯 TypeScript)后端
console.log(PhysicsSystem2D.PHYSICS_BOX2D); // Box2D(JS/asm.js)后端
console.log(PhysicsSystem2D.PHYSICS_BOX2D_WASM); // Box2D WASM 后端
这四个布尔量是构建期决定的,不是运行时随便切。它们在 cc.d.ts 中位于 physics2d 模块的 PhysicsSystem2D 类上(@cocos/creator-types@3.8.8,engine/cc.d.ts 第 47344 行起),对应的源码文件是 cocos/physics-2d/framework/physics-system.ts(tag 3.8.8)。
两者的本质区别:
| 维度 | builtin | box2d / box2d-wasm |
|---|---|---|
| 实现语言 | 纯 TypeScript | C++ 编译(asm.js 或 WASM) |
| 源码入口 | cocos/physics-2d/builtin/intersection-2d.ts | cocos/physics-2d/box2d/(外部库封装) |
| 碰撞检测 | 形状相交 + 简单响应 | 完整 Broadphase / Narrowphase |
| 动力学 | 无(不做力、冲量、摩擦求解) | 完整刚体动力学 |
| 关节 Joint | 不支持 | 支持(DISTANCE/SPRING/WHEEL/MOUSE/FIXED/SLIDER/RELATIVE/HINGE) |
| 体积 | 极小 | 明显更大(WASM 版本又要多一个 .wasm) |
一句话记忆:builtin 只回答「碰没碰」,box2d 才回答「碰了之后怎么动」。
5.1.2 从 cc.d.ts 确认后端与切换接口
后端选择器由 physics2d.selector 暴露,类型是 IPhysicsSelector:
import { selector } from 'cc';
// 当前正在使用的后端 id 与封装对象
console.log(selector.id);
// 注册后端(系统在初始化前使用最后注册的那个)
// selector.register(id, wrapper);
// 运行时切到注册表里的某个后端
// selector.switchTo(id);
IPhysicsSelector 的完整形状(engine/cc.d.ts 第 71434 行起)包含 id / wrapper / backend / physicsWorld 四个只读字段和 register / switchTo 两个方法,源码路径为 cocos/physics-2d/framework/physics-selector.ts(tag 3.8.8)。
注意文档原话:注册要在初始化之前完成,初始化之后再注册的后端需要手动 switchTo。这就是为什么「运行时动态换后端」在真机上往往不生效——物理世界已经用旧后端构建过一轮了。
后端的选择最终落在项目设置里,而不是脚本里。Cocos Creator 3.8 把各模块的设置写到工程的 settings/v2/packages/ 目录下,物理相关字段随面板一起落盘。下面这份 JSON 是字段示意(键名以你在编辑器里看到的为准),用来交代面板上到底有哪些可调项。
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。在编辑器中打开「项目设置 → 物理 → 2D 物理」,会看到后端选择与重力、迭代次数等字段。
{
"physics2d": {
"enable": true,
"engine": "box2d",
"gravity": { "x": 0, "y": -10 },
"allowSleep": true,
"velocityIterations": 8,
"positionIterations": 3,
"maxSubSteps": 4,
"fixedTimeStep": 0.0166667
}
}
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。上面这些值必须通过项目设置面板修改——直接手改落盘文件,编辑器不会把它当作合法配置,下次打开工程会被面板里的值覆盖。这里的
engine字段就是 builtin / box2d / box2d-wasm 的开关,和 §5.1.1 那四个静态 getter 是同一件事的两种视图。
5.1.3 像素与米:PHYSICS_2D_PTM_RATIO
2D 物理里最常见的直觉错误,是把「像素」当成物理世界的长度单位。Box2D 的求解器是按米调的,cc.d.ts 里给了明确的换算常量:
import { PHYSICS_2D_PTM_RATIO } from 'cc';
console.log(PHYSICS_2D_PTM_RATIO); // 32
它的含义是 32 像素 = 1 米(PTM = Pixel To Meter)。这个常量在 engine/cc.d.ts 中是一个顶层 export const,值为 32(第 47340 行附近)。
为什么是 32 而不是 1?Box2D 的默认参数(速度阈值、容差、休眠线性阈值)都是围绕「米量级的物体」调的。如果你直接拿像素当单位,一个 100 像素宽的方块在物理世界里就是「100 米宽」,重力 -10 在它身上几乎看不出来,而且 Box2D 的浮点容差会被量级放大。所以引擎在把你传入的节点坐标送进 Box2D 之前,会先除以 32,回来时再乘 32。builtin 后端不做这个换算(它直接在像素空间做相交),这正是两套后端「手感不同」的根源之一。
5.1.4 刚体类型与碰撞体家族
RigidBody2D 是挂动力学的组件,Collider2D 是挂形状的组件。刚体类型由 ERigidBody2DType 给出(engine/cc.d.ts 第 47225 行):
import { _decorator, Component, RigidBody2D, ERigidBody2DType } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('BlockDashPlatform')
export class BlockDashPlatform extends Component {
@property(RigidBody2D)
body: RigidBody2D | null = null;
start() {
const rb = this.body;
if (!rb) return;
// Static:零质量、零速度,手动移动;平台、地面用它
rb.type = ERigidBody2DType.Static;
// 其它取值:Kinematic(1) / Dynamic(2) / Animated(3)
rb.gravityScale = 1;
rb.fixedRotation = true;
rb.allowSleep = true;
}
}
四个取值的语义差别值得记牢:
Static:零质量、零速度,不参与动力学积分。地形、平台用。Kinematic:零质量,但速度由你设定,会推别人、不被别人推。移动平台用它。Dynamic:有质量,受力、受冲量、受重力。主角、掉落物用它。Animated:Kinematic的扩展,专为「由动画驱动位置」设计,避免动画和物理互相打架。
碰撞体是另一条线。Collider2D 是抽象基类,具体形状有三个:BoxCollider2D(size)、CircleCollider2D(radius)、PolygonCollider2D(points)。共同属性在基类上:offset(相对节点原点的偏移)、density、friction、restitution、sensor、group、tag。
一个只对 Box2D 成立的坑:改完碰撞体的形状/偏移后,要调用 apply() 才会重新生成 Box2D 的 fixture。cc.d.ts 里 Collider2D.apply() 的注释写得很直白:
// 如果物理引擎是 box2d, 需要调用此函数来应用当前 collider 中的修改,
// 调用此函数会重新生成 box2d 的夹具。
builtin 后端没有 fixture 概念,改了就是改了,不需要 apply()。这也是「同一段代码换后端行为变了」的常见来源。
5.1.5 后端差异对游戏表现的影响
把差异落到《方块冲刺》的具体场景:
- 收集物用
sensor。金币、道具只需要「碰到就触发」,不要物理响应。两套后端都支持sensor = true,都只产生回调、不产生碰撞力。这一块 builtin 就够。 - 跑酷的地面与平台。如果用
Static+sensor,builtin 能撑;一旦你要让主角被弹回、被推动、被重力拉住,就必须 Box2D。 - 可破坏的箱子。需要冲量(
getImpulse())、摩擦、恢复系数,只有 Box2D 给。 - 大量静态触发区(检查点、边界)。builtin 的相交检测开销更低,且不引入 WASM 体积。
所以「builtin vs box2d」不是性能取舍,而是能力取舍:先问「需不需要动力学」,再问「能不能接受体积」。
把这张对照再压实一层,列出每个能力在两套后端上的真实可用性:
| 能力 | builtin | box2d | 备注 |
|---|---|---|---|
BEGIN_CONTACT / END_CONTACT 回调 | 支持 | 支持 | 两套都能收到「碰/离」 |
sensor 触发区 | 支持 | 支持 | 不收碰撞力 |
冲量 getImpulse() | 不支持 | 支持 | 仅在 POST_SOLVE 中有效 |
applyForce / applyLinearImpulse | 无效 | 有效 | 力需要求解器 |
| 摩擦 / 恢复系数生效 | 不生效 | 生效 | friction / restitution 仅 box2d 有意义 |
Joint2D 关节 | 不支持 | 支持 | 见 EJoint2DType |
bullet(防穿透) | 不适用 | 支持 | 仅对 Dynamic 刚体生效 |
allowSleep 休眠 | 不适用 | 支持 | 降低静态堆叠开销 |
一个具体的误判场景:有同学把 restitution = 1 写在地面上,期望主角跳起来弹一下,结果 builtin 下毫无反应,于是开始怀疑自己的代码。这不是代码问题,是后端不实现恢复系数求解。 换成 box2d 立刻就有弹性了。记住这张表,能省掉大量无谓的排查。
5.1.6 L1:把单位换算与 AABB 相交写成可测代码
下面这段是纯 TypeScript,不依赖 cc,直接可以在 Node/vitest 里跑。它复刻了 PHYSICS_2D_PTM_RATIO 的换算语义和 builtin 后端的核心相交判定,用来验证「换后端时行为为什么变」:
// mini2d/physics/ptm.ts —— 与引擎无关,可直接单测
export const PTM_RATIO = 32;
/** 像素坐标 -> 物理(米)坐标 */
export function pxToMeter(px: number): number {
return px / PTM_RATIO;
}
/** 物理(米)坐标 -> 像素坐标 */
export function meterToPx(m: number): number {
return m * PTM_RATIO;
}
export interface AABB {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/** builtin 后端最小核心:轴对齐包围盒相交 */
export function aabbOverlap(a: AABB, b: AABB): boolean {
return a.minX <= b.maxX && a.maxX >= b.minX
&& a.minY <= b.maxY && a.maxY >= b.minY;
}
配套测试(vitest)证明两点:换算可逆、相交边界闭合:
import { describe, it, expect } from 'vitest';
import { pxToMeter, meterToPx, aabbOverlap, PTM_RATIO } from '../mini2d/physics/ptm';
describe('ptm', () => {
it('换算可逆', () => {
expect(meterToPx(pxToMeter(320))).toBe(320);
expect(pxToMeter(PTM_RATIO)).toBe(1);
});
it('边界相接算相交', () => {
const a = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
const b = { minX: 10, minY: 10, maxX: 20, maxY: 20 };
expect(aabbOverlap(a, b)).toBe(true);
});
});
「边界相接算相交」这条断言,正是 builtin 与 Box2D 在贴边场景下给出不同结果的缩影:AABB 用闭区间,Box2D 用带容差的流形,两者在「刚好挨着」时判定可能相反。理解了这点,你就不会再把「换后端后多了一次碰撞回调」当成引擎 bug。
5.1.7 工程决策
给《方块冲刺》的选型结论:
- 默认用 Box2D(非 WASM)。它是功能完整的默认项,体积可接受,调试绘制最成熟(5.2 会讲)。
- 只在「纯触发区 + 体积敏感」的小游戏里退到 builtin。例如一个纯收集类小游戏,没有任何动力学需求,用 builtin 能省下可观的包体。
- 不要在运行时切后端。如 §5.1.2 所述,切换要在初始化前完成;运行中切换物理世界等于把已有刚体的状态全部丢弃。
- 单位换算是硬约束。任何手写的物理数值(速度、力、冲量)都要在脑子里过一遍「这是像素还是米」,混用会让参数在不同后端下表现天差地别。
还有一条容易忽略的发布约束:box2d-wasm 会额外产出一个 .wasm 文件。微信小游戏的包体与首次加载都有硬限制,如果你的物理只是轻量触发,用 WASM 版反而增加首屏负担。默认优先 asm.js 版 box2d,只有在你实测到物理求解确实是热点、且平台允许额外资源时,才考虑 WASM 版。
从工程视角回看这一节:所谓「选后端」,本质是在能力、体积、可调试性三者之间排优先级。功能不缺就选功能;功能不缺时选体积;两者都不敏感时选可调试性最好的那个。
下一节把镜头拉近到可观测性:物理出问题时,怎么用调试绘制把内置世界「画出来」,以及怎么定位物理这一层到底吃掉了多少帧预算。
阅读导航:上一节:目录 · 下一节:5.2 物理调试与性能 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。