跳转到内容

物理

Estella 的物理跑在 C++/WebAssembly 内核里,由 Box2D v3 驱动。你给实体加一个 RigidBody 和一个碰撞体,然后用力移动它、用射线查询世界、响应碰撞、用关节连接刚体。物理以固定时间步 推进,与帧率解耦,得到稳定的模拟。

物理是一个可选的侧模块。在编辑器和 Web 运行时里,当场景使用物理组件时会自动加载。在自定义 应用里,自己加插件(重力默认 { x: 0, y: -9.81 }):

import { PhysicsPlugin } from 'esengine/physics';
app.addPlugin(new PhysicsPlugin('physics.js', { gravity: { x: 0, y: -9.81 } }));

Box2D 以模拟;引擎其余部分以世界像素工作。二者的换算系数是当前的 每单位像素数(PPU)——Canvas.pixelsPerUnit,默认每米 100 像素。物理 API 的 每一部分都固定站在这条边界的某一侧:

表面 单位
碰撞体组件尺寸——halfExtentsradiusoffset…… 米。
面向求解器的方法——applyForce / applyImpulsesetLinearVelocity / getLinearVelocitysetGravity…… (m/s、m/s²)。
空间查询——raycastshapeCast*overlapCircle / overlapAABBmoveCharacter 世界像素,按当前 PPU 缩放。
CharacterController.velocityMotorJoint.linearVelocity 世界像素/秒。

physics.getPixelsPerUnit() 返回当前 PPU(每帧从 Canvas 推送),换算永远不用写死 100:

const ppu = physics.getPixelsPerUnit(); // world pixels per meter
const speedPx = physics.getLinearVelocity(e).x * ppu; // m/s → px/s
physics.applyImpulse(e, { x: 0, y: jumpPx / ppu }); // px/s → m/s

混用两个空间——把像素传给 applyForce,或把米传给 raycast——是最常见的物理 bug。 拿不准时记住:组件与求解器 = 米;查询与角色控制器 = 像素。

编辑器视口里的碰撞体 gizmo——选中的 Ground 轮廓加墙体与斜坡

视口里的碰撞体 gizmo:选中的 Ground 的盒形轮廓(琥珀色),以及墙体与斜坡。用视口的物理显示开关切换。

加一个 RigidBody 再加一个碰撞体形状。碰撞体尺寸以米计,不是像素——把像素尺寸除以你的 每单位像素数(见单位)。从一个取 Commands() 的系统里生成:

import {
defineSystem, Commands, Transform,
RigidBody, BodyType, CircleCollider,
} from 'esengine';
const PPU = 100; // = Canvas.pixelsPerUnit; read the live value via physics.getPixelsPerUnit()
const spawnBall = defineSystem([Commands()], (cmds) => {
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 250, z: 0 } })
.insert(RigidBody, { bodyType: BodyType.Dynamic, gravityScale: 1 })
.insert(CircleCollider, { radius: 24 / PPU, density: 1, friction: 0.3, restitution: 0.4 });
});
属性 类型 默认 说明
bodyType BodyType Dynamic Static(不动)、Kinematic(由代码移动)、Dynamic(被模拟)。
gravityScale number 1 世界重力的倍率(0 = 漂浮)。
linearDamping number 0 线速度阻尼。
angularDamping number 0 角速度阻尼。
fixedRotation boolean false 锁定旋转(角色常用)。
bullet boolean false 对快速细小刚体做连续碰撞(防穿透)。
enabled boolean true 关掉即从模拟中移除。

所有碰撞体共享这些字段,各自再加自己的形状参数。尺寸以米计。

共享属性 类型 默认 说明
offset Vec2 {0, 0} 相对实体原点的局部偏移。
density number 1 单位面积质量(决定刚体质量)。
friction number 0.3 表面摩擦,0..1+。
restitution number 0 弹性,0..1。
isSensor boolean false 检测重叠但无物理响应(触发器)。
categoryBits number 0x0001 该碰撞体所在的碰撞层。
maskBits number 0xFFFF 它与哪些层碰撞。
enabled boolean true 关掉该形状。
组件 形状参数
BoxCollider halfExtents({0.5, 0.5})、radius(圆角,0.05)。
CircleCollider radius(0.5)。
CapsuleCollider radiushalfHeight
SegmentCollider point1point2(单边)。
PolygonCollider vertices: Vec2[]radius(圆角)。
ChainCollider points: Vec2[]isLoop(静态边链;无 density/isSensor)。

项目设置 → 物理——碰撞层名字与碰撞矩阵

项目设置 → 物理:给 16 个碰撞层命名,再在矩阵里切换哪些层两两相撞(下方还有求解器调参)。

categoryBits(刚体在哪一层)和 maskBits(它与什么碰撞)是 16 位过滤器。仅当彼此的 category 都在对方的 mask 里,两个刚体才碰撞:

const LAYER_PLAYER = 0x0001;
const LAYER_ENEMY = 0x0002;
const LAYER_GROUND = 0x0008;
.insert(CircleCollider, {
radius: 16 / PPU,
categoryBits: LAYER_PLAYER,
maskBits: LAYER_ENEMY | LAYER_GROUND, // ignores other players
});

碰撞层是唯一的交互开关——碰撞、hit 事件、以及传感器检测都用它。碰撞体无需单独开启就能 被传感器检测到:只要它的层通过了传感器的过滤,就会被检测。想让某物触发某传感器,用层排除它 (而不是某个 per-collider 开关);想在拥挤场景里降低传感器开销,把传感器和它的目标放到专门的层, 用 mask 缩小每个传感器要考虑的范围。

Physics 资源——一个 PhysicsAPI 实例——来驱动刚体。这些方法直接和求解器对话, 所以全部以计(m/s、m/s²——见单位)。每个方法都取世界实体:

import { defineSystem, Query, Res, RigidBody } from 'esengine';
import { Physics } from 'esengine/physics';
const move = defineSystem([Query(RigidBody), Res(Physics)], (q, physics) => {
for (const [entity] of q) {
physics.applyForce(entity, { x: 0, y: 20 }); // continuous force
physics.applyImpulse(entity, { x: 0, y: 5 }); // instant kick (jump)
physics.setLinearVelocity(entity, { x: 3, y: 0 });
}
});
方法 说明
applyForce(e, v) / applyImpulse(e, v) 持续力 / 瞬时冲量。
applyTorque(e, t) / applyAngularImpulse(e, i) 旋转力 / 冲量。
setLinearVelocity(e, v) / getLinearVelocity(e) 设置 / 读取线速度。
setAngularVelocity(e, w) / getAngularVelocity(e) 设置 / 读取角速度。
setGravity(v) / getGravity() 世界重力。
getMass(e) / getInertia(e) / getCenterOfMass(e) / getMassData(e) 质量属性。
setAwake(e, awake) / isAwake(e) 睡眠刚体控制。

getMassData(e) 把质量属性打包成一个 MassData:

MassData 字段 类型 说明
mass number 刚体质量(由碰撞体 density × 面积得出)。
inertia number 绕质心的转动惯量。
centerOfMass Vec2 质心,在刚体的局部坐标系里,以像素计(× PPU)。

查询取像素空间参数,并按当前 PPU 自动缩放。每个都接受一个可选的 maskBits 按层过滤:

// Raycast — every hit along the ray, nearest first.
const hits = physics.raycast(origin, direction, 200);
for (const hit of hits) {
// hit.entity, hit.point, hit.normal
}
const nearby = physics.overlapCircle(center, 64); // Entity[]
const inBox = physics.overlapAABB(min, max); // Entity[]
const swept = physics.shapeCastCircle(/* … */); // sweep a shape
方法 返回 说明
raycast(origin, dir, dist, maskBits?) 命中列表 射线上所有命中,最近优先。
overlapCircle(center, radius, maskBits?) Entity[] 与圆重叠的碰撞体。
overlapAABB(min, max, maskBits?) Entity[] 与盒重叠的碰撞体。
shapeCastCircle / shapeCastBox / shapeCastCapsule 命中 扫掠一个形状并得到它会撞到什么。

各种 cast 都返回 RaycastHit(ShapeCastHit 是同一个形状):

RaycastHit 字段 类型 说明
entity Entity 被命中碰撞体所属的实体。
point Vec2 命中点,世界像素。
normal Vec2 命中点处的表面法线(单位向量)。
fraction number 命中处占投射距离的比例,0..1——结果按它排序,最近优先。

PhysicsEvents 资源携带这一帧的接触。给碰撞体设 isSensor: true 即可得到 enter/exit 事件 而没有物理响应(触发区):

import { defineSystem, Res } from 'esengine';
import { PhysicsEvents } from 'esengine/physics';
const onHit = defineSystem([Res(PhysicsEvents)], (events) => {
for (const c of events.collisionEnters) { /* c.entityA, c.entityB, c.normalX/Y, c.contactX/Y */ }
for (const x of events.collisionExits) { /* x.entityA, x.entityB */ }
for (const h of events.collisionHits) { /* h.approachSpeed, h.pointX/Y, h.normalX/Y */ }
for (const s of events.sensorEnters) { /* s.sensorEntity, s.visitorEntity */ }
for (const s of events.sensorExits) { /* … */ }
});
事件列表 触发时机
collisionEnters / collisionExits 两个实心碰撞体开始 / 停止接触。
collisionHits 高速冲击(携带 approachSpeed)。
sensorEnters / sensorExits 刚体进入 / 离开一个传感碰撞体。

各关节类型及其约束——Revolute、Distance、Prismatic、Weld、Wheel、Motor

每种关节约束什么:Revolute 铰链(绕销钉转动)、Distance 弹簧/连杆、Prismatic 滑轨(单轴滑动)、刚性 Weld 焊接、Wheel 车轮(悬挂 + 自由旋转),以及 Motor 马达(驱向目标速度)。

连接两个刚体。加一个关节组件(RevoluteJointDistanceJointPrismaticJointWeldJointWheelJointMotorJoint),或命令式创建一个并驱动它的马达 / 限位:

// A hinge with a motor (a powered wheel or swinging door).
physics.createRevoluteJoint(bodyA, bodyB, anchorA, anchorB, {
enableMotor: true, motorSpeed: 6, maxMotorTorque: 200,
enableLimit: true, lowerAngle: -1.0, upperAngle: 1.0,
});
physics.setRevoluteMotorSpeed(bodyA, 10);
physics.setRevoluteLimits(bodyA, -0.5, 0.5);
const angle = physics.getRevoluteAngle(bodyA);
physics.destroyJoint(bodyA);

每种关节都有一套平行的控制方法:

关节 控制方法
Revolute(铰链) setRevoluteMotorSpeedsetRevoluteMaxMotorTorqueenableRevoluteMotorenableRevoluteLimitsetRevoluteLimitsgetRevoluteAnglegetRevoluteMotorTorque
Distance(弹簧) setDistanceJointLengthgetDistanceJoint*LengthenableDistanceJointSpring/Limit/MotorsetDistanceJointLimitssetDistanceJointMotorSpeed……
Prismatic(滑轨) getPrismaticJointTranslation/SpeedenablePrismaticJointSpring/Limit/MotorsetPrismaticJointLimits/MotorSpeed/MaxMotorForce……
Wheel(车轮) enableWheelJointSpring/Limit/MotorsetWheelJointLimits/MotorSpeed/MaxMotorTorquegetWheelJointMotorTorque
Motor(马达) setMotorJointLinearVelocitysetMotorJointAngularVelocitysetMotorJointMaxVelocityForcesetMotorJointMaxVelocityTorque

destroyJoint(bodyA) 销毁任何关节。

马达关节驱动刚体 B 相对刚体 A 达到目标速度(或弹簧保持的偏移)——传送带、动力平台, 或任何要靠自身动力移动的东西:

// 传送带:把箱子沿 +x 匀速驱动。
world.insert(crate, MotorJoint, {
connectedEntity: anchor,
linearVelocity: { x: 200, y: 0 }, // 世界像素/秒
maxVelocityForce: 500, // 可用于达到该速度的力(牛顿)
});
physics.setMotorJointLinearVelocity(crate, { x: -200, y: 0 }); // 运行时反向

场景创作的关节会在视口画出 gizmo:两个锚点之间的虚线连线,锚点圆点可以拖拽—— 拖动即写入 anchorA / anchorB(各自刚体的局部坐标)。棱柱/轮式关节还会画出滑动轴, 拖它的端点即可重新指向;马达关节沿目标线速度画出箭头。整个家族(碰撞体、单向箭头、 关节)由视口显示标记里的物理项统一开关。

给静态(或运动学)碰撞体加 OneWayPlatform,使其单面——刚体从一个方向落上去、从其它 方向穿过。这就是经典的“从下往上跳穿平台”行为,建在 Box2D 的 pre-solve 回调上(没有单向平台时 零开销):

import { OneWayPlatform } from 'esengine/physics';
world.spawn(
Transform, { position: { x: 0, y: 0 } },
RigidBody, { bodyType: BodyType.Static },
BoxCollider, { halfExtents: { x: 2, y: 0.2 } },
OneWayPlatform, { normal: { x: 0, y: 1 } }, // 实心顶面:从上落下、从下穿过
);

normal 是世界空间里的实心侧方向;{ x: 0, y: 1 }(默认)= “实心顶面”。设 enabled: false 让平台恢复为普通实心体。在编辑器里,单向平台的碰撞体 gizmo 会沿实心侧法线多画一支箭头, 朝向一眼可辨。

法线朝上的单向平台,从上方来的物体落在上面

想让玩家用指针拖动刚体,用拖拽关节 API。Box2D v3 没有专门的鼠标关节,这是它的官方替代——一根 把刚体拉向移动目标的弹簧。指针按下时创建(在光标处抓住刚体)、移动时更新目标、抬起时销毁:

// 指针按下——在世界光标处抓住刚体:
physics.createMouseJoint(entity, cursorWorld); // maxForce 按刚体自动取值
// 指针移动:
physics.setMouseTarget(cursorWorld);
// 指针抬起:
physics.destroyMouseJoint();

同一时刻只有一个拖拽在生效。传 { hertz, dampingRatio, maxForce } 调弹簧的刚度与抓力。

想让玩家沿着表面行走、而不是像动态刚体那样翻滚,加一个 CharacterController。每步从玩法设它 的 velocity;控制器用 Box2D 的原生运动学 mover(b2World_CollideMover + b2SolvePlanes)推进一步——把身体碰撞成接触平面,再解出一次去穿插的滑动,所以贴地的角色能 干净地移动和起跳,而不是粘在地面上。它把结果写回,并报告碰到了什么:

import { defineSystem, Query, Mut, CharacterController } from 'esengine';
const drive = defineSystem([Query(Mut(CharacterController))], (q) => {
for (const [entity, cc] of q) {
cc.velocity = { x: input.moveX * 240, y: cc.velocity.y - 900 * dt }; // gravity
if (cc.isOnFloor && jumpPressed) cc.velocity.y = 480;
}
});

给实体加一个碰撞体(mover 形状由它推导——盒子会内接成沿长轴的胶囊);若希望别的刚体能撞到角色, 再加一个运动学(Kinematic) RigidBody。mover 会自动排除角色自身的刚体,所以它的 maskBits 不必刻意避开自身的碰撞层。用固定步长的系统驱动 velocity 让运动与帧率无关;跳跃读 isOnFloor 边沿(会正确投递到 FixedUpdate——见 输入)。

属性 类型 默认 说明
velocity Vec2 {0, 0} 期望速度(世界像素/秒,每步设置)。
up Vec2 {0, 1} 用于地面/天花板分类的上方向。
floorMaxAngle number 0.785 相对 up 的最大可行走坡度,弧度(45°)。
skinWidth number 1 与表面保持的间隙(像素),避免贴壁抖动。
maxSlides number 4 每次移动的最大脱离滑动迭代次数(下限为 1)。
snapLength number 0 向下探测长度(像素),把角色吸附在下行的台阶/斜坡上;0 表示关闭。上升时跳过,所以跳跃仍能离地。
slideOnCeiling boolean true false 时,撞到天花板会取消向上速度,而不是沿其滑动。
maskBits number 0xFFFF 阻挡角色的层。
isOnFloor / isOnWall / isOnCeiling boolean 输出:这次移动碰到了什么。
floorNormal Vec2 输出:最后接触地面的法线。
realVelocity Vec2 输出:碰撞后的实际位移 / dt。

物理插件会安装一个 PhysicsDebugDraw 资源——一个游戏内叠加层,通过 Draw API 描出碰撞体轮廓、速度向量和接触点。 它默认关闭;在任何系统里翻转 enabled,就能看见求解器眼中的世界:

import { defineSystem, Res, Input } from 'esengine';
import { PhysicsDebugDraw } from 'esengine/physics';
const toggleDebug = defineSystem([Res(Input), Res(PhysicsDebugDraw)], (input, dbg) => {
if (input.isKeyPressed('F1')) dbg.enabled = !dbg.enabled;
});
PhysicsDebugDrawConfig 字段 默认 绘制内容
enabled false 整个叠加层的总开关。
showColliders true 碰撞体轮廓,按刚体类型着色:蓝 = 静态,绿 = 动态,青 = 运动学;传感器为黄色。
showVelocity false 每个动态刚体沿线速度画一支红色箭头。
showContacts false 本帧新开始的每个接触点画一个红点。

叠加层在每个刚体的实时变换处以世界像素绘制(按当前 PPU 缩放),你看到的正是查询 认定的碰撞体位置。setupPhysicsDebugDraw(app, Physics, PhysicsEvents) 就是 PhysicsPlugin 替你跑的接线——插入该资源并注册绘制回调;只有在绕开插件的自定义宿主里, 才需要自己调它(或每帧调底层的 drawPhysicsDebug(app, Physics, PhysicsEvents))。

  • 固定时间步:每帧施加持续力、读 PhysicsEvents,因为物理独立于帧 delta 做子步。
  • 快速细小刚体(子弹、薄平台)用 bullet: true 防穿透。
  • 角色用 fixedRotation: true 免得翻倒。
  • 碰撞体与求解器用米,查询和角色控制器用像素——混用是最常见的 bug;用 physics.getPixelsPerUnit() 换算(见单位)。
  • 触发器用传感器:isSensor: true 给出 enter/exit 事件而无物理推挤。
  • 用层过滤(categoryBits / maskBits),而不是在事件处理里判断实体类型。
  • 场景 —— 物理组件随场景序列化。
  • 数学辅助 —— 速度与方向的向量运算。