跳转到内容

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)

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 共用同一个开关。

一个实体只会拿到一个刚体形状,按 BoxCollider3DSphereCollider3DMeshCollider3DConvexCollider3DCapsuleCollider3D 的顺序挑。落选的那些——被禁用的、 被这个顺序盖住的、以及所有挂在没有启用 RigidBody3D 的实体上的——画成暗淡虚线: 仍然摆得动,但一眼就看得出世界不会拿它去碰撞。CharacterController3D 的胶囊 画在它旁边,因为角色是被扫掠的而不是被求解的。

编辑器的 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 完全不给自由度——两者作为一个整体运动。

anchoraxis 写在声明关节的那个实体自己的局部空间里,用世界单位, 在关节建立的那一刻按它的变换解算成世界量。那个姿态同时也是所有限位的零点: 一扇摆成关闭状态的门,angle 就是 0,开到 upperAngle

motorSpeed 随时可以由游戏逻辑改写——变了就会重新驱动世界。反方向上, HingeJoint3D.angleSliderJoint3D.translation 每步写回, 所以游戏可以直接问「门开了多少」,不必自己再积分一遍。

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 里。

没有东西阻止一个实体同时带两套组件,但也没有东西会把它们合并:两个世界互相看不见, 一个 2D 碰撞体永远拦不住一个 3D 刚体。一个场景选一套。