3D 物理
一个 3D 模型已经能导入、能动、能变形、能被照亮。这一页讲的是让它站在某处: 一个由 Jolt Physics 驱动的刚体世界,跑在它自己的 WebAssembly 模块里。
它与 2D 物理并存,不是取代:2D 场景保留它已经有的 求解器、单位和手感,而一个从不索取 3D 世界的项目也从不加载它。
一个实体同时带着 RigidBody3D 和一个形状时才在世界里。没有形状的刚体没有任何
广延可供碰撞,所以它干脆不被注册——而不是变成一个永远下落的隐形点。
cmds.spawn() .insert(Transform, { position: { x: 0, y: 400, z: 0 } }) .insert(RigidBody3D, { bodyType: BodyType.Dynamic }) .insert(CapsuleCollider3D, { radius: 30, halfHeight: 50 });| 字段 | 作用 |
|---|---|
bodyType |
Static 从不移动,Kinematic 由你搬动,Dynamic 由求解器解。 |
gravityScale |
这个刚体分担的重力比例。0 是失重,不是「掉得慢」。 |
linearDamping / angularDamping |
运动衰减的快慢。 |
fixedRotation |
冻结刚体被给定的朝向——让角色不会倒下的那个开关。它不会把一个一开始就歪的刚体扶正。 |
continuousCollision |
检查这一步走过的整条路径,而不只是它最后停在哪。子弹需要它;默认关闭,因为它更贵。 |
| 组件 | 广延 |
|---|---|
BoxCollider3D |
每个轴上的 halfExtents。 |
SphereCollider3D |
radius。 |
CapsuleCollider3D |
一个直立胶囊:halfHeight 是圆柱段的一半,两端各扣一个 radius 的半球,所以总高是 2 * (halfHeight + radius)。 |
用导入的几何做碰撞
Section titled “用导入的几何做碰撞”MeshCollider3D 直接拿网格自己的三角形做碰撞——地形、楼梯、房间内部,
这些正是盒子和胶囊说不出来的东西。
cmds.spawn() .insert(Transform, {}) .insert(RigidBody3D, { bodyType: BodyType.Static }) .insert(MeshCollider3D, { mesh: 'assets/models/level.esmesh' });需要会动的导入几何,就用 ConvexCollider3D:它拿同一批顶点最紧的那个凸体去碰撞,
而凸体对求解器来说和盒子一样容易。一块石头、一个倒角的箱子、一只会滚的木桶——
凡是凹陷细节不需要被摸出来的东西。
cmds.spawn() .insert(Transform, { position: { x: 0, y: 400, z: 0 } }) .insert(RigidBody3D, { bodyType: BodyType.Dynamic }) .insert(ConvexCollider3D, { mesh: 'assets/models/rock.esmesh' });视口把每个 3D 碰撞体画成线框,并跟着实体一起转——屏幕上没有任何别的东西 能说出「模型外面那个盒子有多大」。它和 2D 碰撞体 gizmo 共用同一个开关。
一个实体只会拿到一个刚体形状,按 BoxCollider3D、SphereCollider3D、
MeshCollider3D、ConvexCollider3D、CapsuleCollider3D 的顺序挑。落选的那些——被禁用的、
被这个顺序盖住的、以及所有挂在没有启用 RigidBody3D 的实体上的——画成暗淡虚线:
仍然摆得动,但一眼就看得出世界不会拿它去碰撞。CharacterController3D 的胶囊
画在它旁边,因为角色是被扫掠的而不是被求解的。
游戏跑起来的时候
Section titled “游戏跑起来的时候”编辑器的 gizmo 在 Play 时全部隐藏,所以由运行中的游戏画它自己的。
需要弄清「它为什么卡在那儿」时,把 Physics3DDebugDraw 资源打开:
app.getResource(Physics3DDebugDraw).enabled = true;| 字段 | 作用 |
|---|---|
enabled |
默认关闭——每帧每条边一根线,这是有代价的。 |
showColliders |
求解器真的建出来的每个刚体的形状。 |
showContacts |
这一步报告的每个接触点上画一个十字。 |
CharacterController3D 取代 RigidBody3D 而不是与它并存:被求解器推的刚体
和自己扫掠的角色,是「这个实体在哪」的两个答案。每步从游戏逻辑设 velocity,再把结果读回来。
const c = world.get(player, CharacterController3D);c.velocity.x = input.axis('move') * 200;if (input.justPressed('jump') && c.isOnFloor) c.velocity.y = 500;| 字段 | 作用 |
|---|---|
velocity |
期望速度,世界单位/秒。y 为正就是跳。 |
stepHeight |
能迈上去而不是被挡住的最高台阶。0 就什么都迈不上。 |
snapDown |
过坡顶时向下够多远以贴住地面。0 是关掉。 |
maxSlope |
能站住的最陡地面;比这更陡就会滑下去。 |
pushForce |
撞上动态刚体时能推多用力。0 什么都推不动。 |
isOnFloor / floorNormal / realVelocity |
输出,步进之后读。 |
门、绳桥、升降台、布娃娃:它们都不是「一个刚体加一个形状」, 而是两个刚体外加一条「它们之间可以怎么动」的规则。 关节挂在声明它的那个实体上,并指名另一个实体。
cmds.spawn() .insert(Transform, { position: { x: 100, y: 0, z: 0 } }) .insert(RigidBody3D, {}) .insert(BoxCollider3D, { halfExtents: { x: 50, y: 100, z: 5 } }) .insert(HingeJoint3D, { connectedEntity: frame, anchor: { x: -50, y: 0, z: 0 }, // 门自己的那条边 axis: { x: 0, y: 1, z: 0 }, // 竖直 enableLimit: true, lowerAngle: 0, upperAngle: Math.PI / 2, });| 组件 | 规则 |
|---|---|
PointJoint3D |
共享一个点,三轴自由转动。 |
HingeJoint3D |
一根转轴,可选限位与马达。 |
SliderJoint3D |
一条滑轨,可选限位与马达。 |
DistanceJoint3D |
保持一段距离:只给 maxLength 就是绳,两个长度相等就是杆,给了 frequency 就是弹簧。 |
FixedJoint3D |
完全不给自由度——两者作为一个整体运动。 |
anchor 与 axis 写在声明关节的那个实体自己的局部空间里,用世界单位,
在关节建立的那一刻按它的变换解算成世界量。那个姿态同时也是所有限位的零点:
一扇摆成关闭状态的门,angle 就是 0,开到 upperAngle。
motorSpeed 随时可以由游戏逻辑改写——变了就会重新驱动世界。反方向上,
HingeJoint3D.angle 与 SliderJoint3D.translation 每步写回,
所以游戏可以直接问「门开了多少」,不必自己再积分一遍。
问世界「那里有什么」
Section titled “问世界「那里有什么」”Physics3D 资源用世界单位回答空间问题。
const q = app.getResource(Physics3D);
// 这条线上有东西吗?const hit = q.raycast(muzzle, { x: 0, y: 0, z: -2000 }, ENEMIES);
// 这个东西「过得去」吗?射线是无限细的,会从一个运动刚体根本挤不过去的缝里穿过——// 这正是扫掠形状回答的那个不同的问题。const blocked = q.sphereCast(from, 30, travel, WORLD);
// 在往这里放东西之前,这里已经有什么了?const occupants = q.overlapSphere(spawnPoint, 50);| 查询 | 回答 |
|---|---|
raycast |
一条线上最近的刚体。 |
sphereCast |
一个运动的球最先撞上的刚体。 |
overlapSphere / overlapBox |
已经在某个体积里的所有刚体。 |
每个刚体属于十六层中的一层,由项目声明哪些层之间能互相听见。两边都得同意—— 其中一边拒绝就足以让两个刚体互不相碰,所以一条规则只需要写一次就成立。
app.addPlugin(physics3dPlugin(url, { layerMasks: [ 0xFFFF, // 0: 全部 0xFFFF, // 1: 世界 ~(1 << 3), // 2: 子弹 —— 不打自己人 0xFFFF, // 3: 那支队伍 ],}));每一步都会把「谁碰到了谁」发布到 Physics3DEvents 资源里。它按固定步长排空,
所以读它的系统必须跑在固定步里——从 Update 里读只会看到上一步碰巧留下的东西。
const events = app.getResource(Physics3DEvents);for (const hit of events.contactEnters) { // 两个实体,以及它们相遇的位置 spawnSparks(hit.pointX, hit.pointY, hit.pointZ);}for (const { sensorEntity, visitorEntity } of events.sensorEnters) { if (sensorEntity === goalZone) score(visitorEntity);}| 通道 | 带着什么 |
|---|---|
contactEnters |
两个实体、接触法线和接触点。 |
contactExits |
只有这一对——原因见下。 |
sensorEnters / sensorExits |
传感器在前,然后是访客。 |
带 isSensor 的碰撞体报告重叠但不拦任何东西:访客直接穿过去,并且出现在
sensorEnters 而不是 contactEnters 里。
一个场景在哪个世界里
Section titled “一个场景在哪个世界里”没有东西阻止一个实体同时带两套组件,但也没有东西会把它们合并:两个世界互相看不见, 一个 2D 碰撞体永远拦不住一个 3D 刚体。一个场景选一套。