@ludra/physics 0.9.0 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +5 -0
- package/dist/index.js +10 -1
- package/dist/rapier3d.d.ts +83 -0
- package/dist/rapier3d.js +608 -0
- package/package.json +3 -2
- package/src/index.ts +12 -2
- package/src/rapier3d.ts +680 -0
package/src/rapier3d.ts
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import RAPIER, {
|
|
2
|
+
type Collider,
|
|
3
|
+
type ColliderDesc,
|
|
4
|
+
type ImpulseJoint,
|
|
5
|
+
type KinematicCharacterController,
|
|
6
|
+
type RigidBody,
|
|
7
|
+
type World as RapierWorld,
|
|
8
|
+
} from "@dimforge/rapier3d-deterministic-compat";
|
|
9
|
+
import { dmath, FIXED_DT, registerPhysics, type Body, type Entity, type PhysicsEngine, type PhysicsHost, type PhysicsSettings, type RayHit, type RayOptions } from "@ludra/core";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ludra's 3D worlds use Rapier's own conventions: metres, +y up, right-handed (+z toward
|
|
13
|
+
* the viewer). Nothing is mirrored, unlike 2D. Rotations are degrees in Ludra, applied
|
|
14
|
+
* y (turn), then x (tilt), then z (roll), and quaternions in Rapier.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface Vec3 {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
z: number;
|
|
21
|
+
}
|
|
22
|
+
interface Quat extends Vec3 {
|
|
23
|
+
w: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEGREES = Math.PI / 180;
|
|
27
|
+
/** How firmly a joint motor drives toward its target speed (it is capped by motorForce). */
|
|
28
|
+
const MOTOR_FACTOR = 1000;
|
|
29
|
+
/** Impacts slower than this (m/s) don't count as a bounce. */
|
|
30
|
+
const MIN_BOUNCE_SPEED = 0.8;
|
|
31
|
+
/** A contact whose normal points this much up (toward the body) is ground under it. */
|
|
32
|
+
const GROUND_NORMAL = 0.7;
|
|
33
|
+
/** Players fall no faster than this, in m/s. */
|
|
34
|
+
const MAX_FALL_SPEED = 50;
|
|
35
|
+
/** The gap a character controller keeps from obstacles, in metres. */
|
|
36
|
+
const CONTROLLER_OFFSET = 0.01;
|
|
37
|
+
/** Players stay on the ground walking down slopes and off bumps up to this far, in metres. */
|
|
38
|
+
const SNAP_TO_GROUND = 0.2;
|
|
39
|
+
/**
|
|
40
|
+
* Where a standing player is kept above the ground: a little over the controller's own gap, which
|
|
41
|
+
* it may otherwise measure a hair short on kinematic platforms and refuse to move.
|
|
42
|
+
*/
|
|
43
|
+
const GROUND_GAP = 1.2 * CONTROLLER_OFFSET;
|
|
44
|
+
/** How far inside the feet the look-down for the ground starts, in metres. */
|
|
45
|
+
const FEET_PROBE = 0.05;
|
|
46
|
+
/** A standing player that loses contact still counts as grounded for this many steps. */
|
|
47
|
+
const COYOTE_STEPS = 3;
|
|
48
|
+
|
|
49
|
+
/** A Ludra entity, its Rapier body and collider, and the transform last written to it. */
|
|
50
|
+
interface Link {
|
|
51
|
+
entity: Entity;
|
|
52
|
+
body: RigidBody;
|
|
53
|
+
collider: Collider;
|
|
54
|
+
/** The position and rotation (degrees) last exchanged with Rapier, to notice gameplay's changes. */
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
z: number;
|
|
58
|
+
rx: number;
|
|
59
|
+
ry: number;
|
|
60
|
+
rz: number;
|
|
61
|
+
/** The rotation last exchanged, as Rapier's quaternion: unchanged means no Euler round trip. */
|
|
62
|
+
q: Quat;
|
|
63
|
+
/** The velocity last read from Rapier: gameplay changing it is written back, anything else (raw API calls) is left alone. */
|
|
64
|
+
v: Vec3;
|
|
65
|
+
controller?: KinematicCharacterController;
|
|
66
|
+
/** How far a kinematic mover moved in the last step, so players standing on it ride along. */
|
|
67
|
+
d: Vec3;
|
|
68
|
+
teleported: boolean;
|
|
69
|
+
airborne: number;
|
|
70
|
+
joints: (ImpulseJoint | undefined)[];
|
|
71
|
+
anchors: RigidBody[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let loading: Promise<void> | undefined;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Loads Rapier 3D's WebAssembly and registers the "rapier3d" engine, which every scene with
|
|
78
|
+
* `"dimensions": 3` runs on. Safe to call more than once.
|
|
79
|
+
*/
|
|
80
|
+
export function loadRapier3DPhysics(): Promise<void> {
|
|
81
|
+
loading ??= RAPIER.init().then(() => registerPhysics("rapier3d", (settings) => new Rapier3DPhysics(settings)));
|
|
82
|
+
return loading;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cleanup = new FinalizationRegistry<RapierWorld>((world) => world.free());
|
|
86
|
+
|
|
87
|
+
/** Degrees (applied y, then x, then z) to a quaternion. */
|
|
88
|
+
export function quaternionFromDegrees(x: number, y: number, z: number): Quat {
|
|
89
|
+
const half = (a: number) => (a * DEGREES) / 2;
|
|
90
|
+
// dmath, not Math: platform trig can differ in the last bit (see dmath.ts).
|
|
91
|
+
const qy = { x: 0, y: dmath.sin(half(y)), z: 0, w: dmath.cos(half(y)) };
|
|
92
|
+
const qx = { x: dmath.sin(half(x)), y: 0, z: 0, w: dmath.cos(half(x)) };
|
|
93
|
+
const qz = { x: 0, y: 0, z: dmath.sin(half(z)), w: dmath.cos(half(z)) };
|
|
94
|
+
return multiply(multiply(qy, qx), qz);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A quaternion back to degrees (y, then x, then z), as three.js's "YXZ" order reads it. */
|
|
98
|
+
export function degreesFromQuaternion(q: Quat): Vec3 {
|
|
99
|
+
const { x, y, z, w } = q;
|
|
100
|
+
const m13 = 2 * (x * z + y * w);
|
|
101
|
+
const m21 = 2 * (x * y + z * w);
|
|
102
|
+
const m22 = 1 - 2 * (x * x + z * z);
|
|
103
|
+
const m23 = 2 * (y * z - x * w);
|
|
104
|
+
const m31 = 2 * (x * z - y * w);
|
|
105
|
+
const m33 = 1 - 2 * (x * x + y * y);
|
|
106
|
+
const m11 = 1 - 2 * (y * y + z * z);
|
|
107
|
+
const tilt = dmath.asin(Math.max(-1, Math.min(1, -m23)));
|
|
108
|
+
// Looking straight up or down, turn and roll are the same thing: put it all in turn.
|
|
109
|
+
const gimbal = Math.abs(m23) > 0.9999999;
|
|
110
|
+
return {
|
|
111
|
+
x: tilt / DEGREES,
|
|
112
|
+
y: (gimbal ? dmath.atan2(-m31, m11) : dmath.atan2(m13, m33)) / DEGREES,
|
|
113
|
+
z: gimbal ? 0 : dmath.atan2(m21, m22) / DEGREES,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function multiply(a: Quat, b: Quat): Quat {
|
|
118
|
+
return {
|
|
119
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
|
|
120
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
121
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
122
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function conjugate(q: Quat): Quat {
|
|
127
|
+
return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** v rotated by q. */
|
|
131
|
+
function rotate(q: Quat, v: Vec3): Vec3 {
|
|
132
|
+
const p = multiply(multiply(q, { ...v, w: 0 }), conjugate(q));
|
|
133
|
+
return { x: p.x, y: p.y, z: p.z };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A quaternion as Rapier stores it (32-bit floats), so an unchanged rotation compares equal. */
|
|
137
|
+
function stored(q: Quat): Quat {
|
|
138
|
+
return { x: Math.fround(q.x), y: Math.fround(q.y), z: Math.fround(q.z), w: Math.fround(q.w) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const sameQuat = (a: Quat, b: Quat) => a.x === b.x && a.y === b.y && a.z === b.z && a.w === b.w;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Ludra's 3D physics engine: Rapier 3D's deterministic build, so a simulation is bit-for-bit
|
|
145
|
+
* the same on every machine and in the browser. Bodies rotate in 3D, can be boxes, spheres,
|
|
146
|
+
* capsules, cylinders or convex hulls, and have friction and mass; players walk through
|
|
147
|
+
* Rapier's character controller.
|
|
148
|
+
*/
|
|
149
|
+
export class Rapier3DPhysics implements PhysicsEngine {
|
|
150
|
+
readonly name = "rapier3d";
|
|
151
|
+
readonly world: RapierWorld;
|
|
152
|
+
private readonly events = new RAPIER.EventQueue(true);
|
|
153
|
+
private readonly links = new Map<Entity, Link>();
|
|
154
|
+
private readonly byCollider = new Map<number, Link>();
|
|
155
|
+
private pendingJoints: { entity: Entity; index: number }[] = [];
|
|
156
|
+
|
|
157
|
+
constructor(settings: PhysicsSettings) {
|
|
158
|
+
this.world = new RAPIER.World({ x: 0, y: -settings.gravity, z: 0 });
|
|
159
|
+
this.world.timestep = FIXED_DT;
|
|
160
|
+
cleanup.register(this, this.world);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
add(entity: Entity): void {
|
|
164
|
+
const b = entity.body;
|
|
165
|
+
if (!b) return;
|
|
166
|
+
const t = entity.transform;
|
|
167
|
+
const player = isPlayer(entity);
|
|
168
|
+
// A character controller starting in contact can't keep its gap: lift it clear first.
|
|
169
|
+
if (player) t.y += CONTROLLER_OFFSET;
|
|
170
|
+
const desc =
|
|
171
|
+
b.type === "dynamic" && !player ? RAPIER.RigidBodyDesc.dynamic() : b.type === "static" ? RAPIER.RigidBodyDesc.fixed() : RAPIER.RigidBodyDesc.kinematicPositionBased();
|
|
172
|
+
const q = quaternionFromDegrees(t.rotationX, t.rotationY, t.rotation);
|
|
173
|
+
desc.setTranslation(t.x, t.y, t.z).setRotation(q);
|
|
174
|
+
if (b.type === "dynamic" && !player) {
|
|
175
|
+
desc.setGravityScale(b.gravityScale).setLinvel(b.vx, b.vy, b.vz).setCcdEnabled(b.bullet);
|
|
176
|
+
if (b.fixedRotation) desc.lockRotations();
|
|
177
|
+
}
|
|
178
|
+
const body = this.world.createRigidBody(desc);
|
|
179
|
+
const collider = this.world.createCollider(
|
|
180
|
+
shape(b)
|
|
181
|
+
.setSensor(b.sensor)
|
|
182
|
+
.setRestitution(b.bounce)
|
|
183
|
+
.setRestitutionCombineRule(RAPIER.CoefficientCombineRule.Max)
|
|
184
|
+
.setFriction(b.friction)
|
|
185
|
+
.setDensity(b.density)
|
|
186
|
+
.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)
|
|
187
|
+
.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL),
|
|
188
|
+
body,
|
|
189
|
+
);
|
|
190
|
+
const link: Link = {
|
|
191
|
+
entity,
|
|
192
|
+
body,
|
|
193
|
+
collider,
|
|
194
|
+
x: t.x,
|
|
195
|
+
y: t.y,
|
|
196
|
+
z: t.z,
|
|
197
|
+
rx: t.rotationX,
|
|
198
|
+
ry: t.rotationY,
|
|
199
|
+
rz: t.rotation,
|
|
200
|
+
q: stored(q),
|
|
201
|
+
v: { x: b.vx, y: b.vy, z: b.vz },
|
|
202
|
+
d: { x: 0, y: 0, z: 0 },
|
|
203
|
+
teleported: false,
|
|
204
|
+
airborne: Infinity,
|
|
205
|
+
joints: [],
|
|
206
|
+
anchors: [],
|
|
207
|
+
};
|
|
208
|
+
if (player) link.controller = this.createController(entity);
|
|
209
|
+
entity.joints?.forEach((_, index) => this.pendingJoints.push({ entity, index }));
|
|
210
|
+
this.links.set(entity, link);
|
|
211
|
+
this.byCollider.set(collider.handle, link);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
remove(entity: Entity): void {
|
|
215
|
+
const link = this.links.get(entity);
|
|
216
|
+
if (!link) return;
|
|
217
|
+
this.byCollider.delete(link.collider.handle);
|
|
218
|
+
this.links.delete(entity);
|
|
219
|
+
if (link.controller) this.world.removeCharacterController(link.controller);
|
|
220
|
+
this.world.removeRigidBody(link.body);
|
|
221
|
+
for (const anchor of link.anchors) this.world.removeRigidBody(anchor);
|
|
222
|
+
this.pendingJoints = this.pendingJoints.filter((pending) => pending.entity !== entity);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Creates the joints whose two ends now both exist. */
|
|
226
|
+
private connectJoints(host: PhysicsHost): void {
|
|
227
|
+
if (this.pendingJoints.length === 0) return;
|
|
228
|
+
const waiting: { entity: Entity; index: number }[] = [];
|
|
229
|
+
for (const pending of this.pendingJoints) {
|
|
230
|
+
const { entity, index } = pending;
|
|
231
|
+
const link = this.links.get(entity);
|
|
232
|
+
const spec = entity.joints![index]!;
|
|
233
|
+
const target = spec.to ? host.entities.find((e) => e.id === spec.to) : undefined;
|
|
234
|
+
const other = target ? this.links.get(target) : undefined;
|
|
235
|
+
if (!link || (spec.to && !other)) {
|
|
236
|
+
waiting.push(pending);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const ownAnchor = vec(spec.anchor);
|
|
240
|
+
const here = worldPoint(link.body, ownAnchor);
|
|
241
|
+
// Hinges, sliders, welds and ball joints join both bodies at that same point; ropes and
|
|
242
|
+
// springs run from there to their other end (toAnchor).
|
|
243
|
+
const sameSpot = spec.type !== "rope" && spec.type !== "spring";
|
|
244
|
+
let base: RigidBody;
|
|
245
|
+
let there: Vec3;
|
|
246
|
+
if (other) {
|
|
247
|
+
base = other.body;
|
|
248
|
+
there = sameSpot ? here : worldPoint(other.body, vec(spec.toAnchor));
|
|
249
|
+
} else {
|
|
250
|
+
const t = entity.transform;
|
|
251
|
+
const offset = vec(spec.toAnchor);
|
|
252
|
+
there = sameSpot ? here : { x: t.x + offset.x, y: t.y + offset.y, z: t.z + offset.z };
|
|
253
|
+
base = this.world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(there.x, there.y, there.z));
|
|
254
|
+
link.anchors.push(base);
|
|
255
|
+
}
|
|
256
|
+
const baseAnchor = localPoint(base, there);
|
|
257
|
+
// The axis is given in the world; each body measures it in its own frame.
|
|
258
|
+
const axis = jointAxis(spec.axis, spec.type);
|
|
259
|
+
const baseQ = base.rotation();
|
|
260
|
+
const ownQ = link.body.rotation();
|
|
261
|
+
const length = spec.length || length3(there.x - here.x, there.y - here.y, there.z - here.z);
|
|
262
|
+
const data =
|
|
263
|
+
spec.type === "prismatic"
|
|
264
|
+
? RAPIER.JointData.prismatic(baseAnchor, ownAnchor, rotate(conjugate(baseQ), axis))
|
|
265
|
+
: spec.type === "fixed"
|
|
266
|
+
? // Welded as they are now: frame1 · base = own.
|
|
267
|
+
RAPIER.JointData.fixed(baseAnchor, multiply(conjugate(baseQ), ownQ), ownAnchor, { x: 0, y: 0, z: 0, w: 1 })
|
|
268
|
+
: spec.type === "rope"
|
|
269
|
+
? RAPIER.JointData.rope(length, baseAnchor, ownAnchor)
|
|
270
|
+
: spec.type === "spring"
|
|
271
|
+
? RAPIER.JointData.spring(length, spec.stiffness, spec.damping, baseAnchor, ownAnchor)
|
|
272
|
+
: spec.type === "spherical"
|
|
273
|
+
? RAPIER.JointData.spherical(baseAnchor, ownAnchor)
|
|
274
|
+
: RAPIER.JointData.revoluteWithAxes(baseAnchor, ownAnchor, rotate(conjugate(baseQ), axis), rotate(conjugate(ownQ), axis));
|
|
275
|
+
const joint = this.world.createImpulseJoint(data, base, link.body, true);
|
|
276
|
+
joint.setContactsEnabled(spec.collide);
|
|
277
|
+
if (spec.type === "revolute" || spec.type === "prismatic") {
|
|
278
|
+
const unit = joint as unknown as {
|
|
279
|
+
setLimits(min: number, max: number): void;
|
|
280
|
+
setMotorMaxForce(force: number): void;
|
|
281
|
+
configureMotorVelocity(velocity: number, factor: number): void;
|
|
282
|
+
};
|
|
283
|
+
// Angles follow the right-hand rule around the axis; positions run along it.
|
|
284
|
+
const scale = spec.type === "revolute" ? DEGREES : 1;
|
|
285
|
+
if (spec.min < spec.max) unit.setLimits(spec.min * scale, spec.max * scale);
|
|
286
|
+
if (spec.motorForce > 0) {
|
|
287
|
+
unit.setMotorMaxForce(spec.motorForce);
|
|
288
|
+
unit.configureMotorVelocity(spec.motorSpeed * scale, MOTOR_FACTOR);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
link.joints[index] = joint;
|
|
292
|
+
}
|
|
293
|
+
this.pendingJoints = waiting;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private createController(entity: Entity): KinematicCharacterController {
|
|
297
|
+
const b = entity.body!;
|
|
298
|
+
const c = entity.character;
|
|
299
|
+
const controller = this.world.createCharacterController(CONTROLLER_OFFSET);
|
|
300
|
+
controller.setSlideEnabled(true);
|
|
301
|
+
const push = c?.push ?? 1;
|
|
302
|
+
controller.setApplyImpulsesToDynamicBodies(push > 0);
|
|
303
|
+
if (push > 0) controller.setCharacterMass(b.density * volume(b) * push);
|
|
304
|
+
if (c) {
|
|
305
|
+
controller.setMaxSlopeClimbAngle(c.maxSlope * DEGREES);
|
|
306
|
+
controller.setMinSlopeSlideAngle(c.maxSlope * DEGREES);
|
|
307
|
+
controller.enableSnapToGround(Math.max(SNAP_TO_GROUND, c.stepHeight));
|
|
308
|
+
if (c.stepHeight > 0) controller.enableAutostep(c.stepHeight, b.width / 2, false);
|
|
309
|
+
}
|
|
310
|
+
return controller;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
step(host: PhysicsHost): void {
|
|
314
|
+
this.connectJoints(host);
|
|
315
|
+
for (const link of this.links.values()) {
|
|
316
|
+
const { entity: e, body } = link;
|
|
317
|
+
const b = e.body!;
|
|
318
|
+
const t = e.transform;
|
|
319
|
+
const moved = t.x !== link.x || t.y !== link.y || t.z !== link.z;
|
|
320
|
+
const turned = t.rotationX !== link.rx || t.rotationY !== link.ry || t.rotation !== link.rz;
|
|
321
|
+
if (turned) link.q = stored(quaternionFromDegrees(t.rotationX, t.rotationY, t.rotation));
|
|
322
|
+
// Players turn to face where they walk: that's not a teleport.
|
|
323
|
+
link.teleported = moved || (turned && !link.controller);
|
|
324
|
+
if (link.controller) {
|
|
325
|
+
if (turned) body.setNextKinematicRotation(link.q);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (link.teleported) {
|
|
329
|
+
body.setTranslation(t, true);
|
|
330
|
+
body.setRotation(link.q, true);
|
|
331
|
+
}
|
|
332
|
+
if (b.type === "dynamic") {
|
|
333
|
+
// Only when gameplay changed it: a velocity set through world.physics.raw stays.
|
|
334
|
+
if (b.vx !== link.v.x || b.vy !== link.v.y || b.vz !== link.v.z) body.setLinvel({ x: b.vx, y: b.vy, z: b.vz }, true);
|
|
335
|
+
} else if (b.type === "kinematic") {
|
|
336
|
+
const before = { x: t.x, y: t.y, z: t.z };
|
|
337
|
+
t.x += b.vx * FIXED_DT;
|
|
338
|
+
t.y += b.vy * FIXED_DT;
|
|
339
|
+
t.z += b.vz * FIXED_DT;
|
|
340
|
+
if (e.patrol) clampToPatrol3D(e);
|
|
341
|
+
body.setNextKinematicTranslation(t);
|
|
342
|
+
if (turned) body.setNextKinematicRotation(link.q);
|
|
343
|
+
link.d = { x: t.x - before.x, y: t.y - before.y, z: t.z - before.z };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const link of this.links.values()) if (link.controller) this.moveCharacter(link, host);
|
|
347
|
+
|
|
348
|
+
this.world.step(this.events);
|
|
349
|
+
|
|
350
|
+
for (const link of this.links.values()) {
|
|
351
|
+
const { entity: e, body } = link;
|
|
352
|
+
const b = e.body!;
|
|
353
|
+
const t = e.transform;
|
|
354
|
+
// Only dynamic bodies are moved and turned by physics. Kinematic bodies and characters are
|
|
355
|
+
// exactly where gameplay put them; reading them back would round them to Rapier's 32-bit floats.
|
|
356
|
+
if (b.type === "dynamic" && !link.controller) {
|
|
357
|
+
const p = body.translation();
|
|
358
|
+
t.x = p.x;
|
|
359
|
+
t.y = p.y;
|
|
360
|
+
t.z = p.z;
|
|
361
|
+
const q = body.rotation();
|
|
362
|
+
// Only a body that really turned goes through quaternion → degrees, so set rotations stay exact.
|
|
363
|
+
if (!sameQuat(q, link.q)) {
|
|
364
|
+
const r = degreesFromQuaternion(q);
|
|
365
|
+
t.rotationX = r.x;
|
|
366
|
+
t.rotationY = r.y;
|
|
367
|
+
t.rotation = r.z;
|
|
368
|
+
link.q = { x: q.x, y: q.y, z: q.z, w: q.w };
|
|
369
|
+
}
|
|
370
|
+
const v = body.linvel();
|
|
371
|
+
link.v = { x: v.x, y: v.y, z: v.z };
|
|
372
|
+
b.vx = v.x;
|
|
373
|
+
b.vy = v.y;
|
|
374
|
+
b.vz = v.z;
|
|
375
|
+
}
|
|
376
|
+
link.x = t.x;
|
|
377
|
+
link.y = t.y;
|
|
378
|
+
link.z = t.z;
|
|
379
|
+
link.rx = t.rotationX;
|
|
380
|
+
link.ry = t.rotationY;
|
|
381
|
+
link.rz = t.rotation;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const link of this.links.values()) {
|
|
385
|
+
if (link.entity.body!.type === "dynamic" && !link.controller) this.updateGround(link, host);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
this.events.drainCollisionEvents((handle1, handle2, started) => {
|
|
389
|
+
if (!started) return;
|
|
390
|
+
const a = this.byCollider.get(handle1);
|
|
391
|
+
const c = this.byCollider.get(handle2);
|
|
392
|
+
if (!a || !c || a.collider.isSensor() || c.collider.isSensor()) return;
|
|
393
|
+
for (const [self, other] of [
|
|
394
|
+
[a, c],
|
|
395
|
+
[c, a],
|
|
396
|
+
] as const) {
|
|
397
|
+
const b = self.entity.body!;
|
|
398
|
+
if (b.type !== "dynamic" || b.bounce <= 0 || length3(b.vx, b.vy, b.vz) < MIN_BOUNCE_SPEED) continue;
|
|
399
|
+
host.emit({ type: "bounce", entity: self.entity.id, other: other.entity.id, ...at(self.entity) });
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Moves a player by its velocity (plus gravity, and the platform it stands on) through the
|
|
406
|
+
* character controller, which resolves slopes, steps and walls.
|
|
407
|
+
*/
|
|
408
|
+
private moveCharacter(link: Link, host: PhysicsHost): void {
|
|
409
|
+
const { entity: e, body, collider } = link;
|
|
410
|
+
const controller = link.controller!;
|
|
411
|
+
const b = e.body!;
|
|
412
|
+
const t = e.transform;
|
|
413
|
+
const falls = !!e.character;
|
|
414
|
+
const wasGrounded = b.grounded;
|
|
415
|
+
if (link.teleported) {
|
|
416
|
+
// A respawn: start from rest where gameplay put it, just clear of the ground.
|
|
417
|
+
t.y += CONTROLLER_OFFSET;
|
|
418
|
+
body.setTranslation(t, true);
|
|
419
|
+
body.setNextKinematicTranslation(t);
|
|
420
|
+
body.setNextKinematicRotation(link.q);
|
|
421
|
+
b.grounded = false;
|
|
422
|
+
b.standingOn = undefined;
|
|
423
|
+
link.airborne = Infinity;
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Gravity only while not touching the ground (or jumping off), as in 2D.
|
|
427
|
+
if (falls) b.vy = link.airborne === 0 && b.vy <= 0 ? 0 : Math.max(b.vy - host.gravity * b.gravityScale * FIXED_DT, -MAX_FALL_SPEED);
|
|
428
|
+
const own = { x: b.vx * FIXED_DT, y: b.vy * FIXED_DT, z: b.vz * FIXED_DT };
|
|
429
|
+
// Ride along with a moving platform: the controller doesn't carry a standing character, and
|
|
430
|
+
// sliding it across the platform's top through the controller catches on it now and then,
|
|
431
|
+
// so the rider moves with the platform as one piece and the controller resolves only its own walk.
|
|
432
|
+
const carrier = b.standingOn ? this.links.get(b.standingOn) : undefined;
|
|
433
|
+
const carry = carrier?.d ?? { x: 0, y: 0, z: 0 };
|
|
434
|
+
controller.computeColliderMovement(
|
|
435
|
+
collider,
|
|
436
|
+
own,
|
|
437
|
+
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
|
|
438
|
+
undefined,
|
|
439
|
+
(other) => other.handle !== collider.handle,
|
|
440
|
+
);
|
|
441
|
+
const moved = controller.computedMovement();
|
|
442
|
+
t.x += moved.x + carry.x;
|
|
443
|
+
t.y += moved.y + carry.y;
|
|
444
|
+
t.z += moved.z + carry.z;
|
|
445
|
+
body.setNextKinematicTranslation(t);
|
|
446
|
+
|
|
447
|
+
const touching = falls && controller.computedGrounded();
|
|
448
|
+
link.airborne = touching ? 0 : link.airborne + 1;
|
|
449
|
+
b.grounded = touching || (falls && wasGrounded && b.vy <= 0 && link.airborne <= COYOTE_STEPS);
|
|
450
|
+
const previous = b.standingOn;
|
|
451
|
+
b.standingOn = undefined;
|
|
452
|
+
let ceiling = false;
|
|
453
|
+
for (let i = 0; i < controller.numComputedCollisions(); i++) {
|
|
454
|
+
const hit = controller.computedCollision(i);
|
|
455
|
+
if (!hit) continue;
|
|
456
|
+
// Normals point at the player: up from the ground, down from a ceiling.
|
|
457
|
+
if (hit.normal1.y > GROUND_NORMAL && b.grounded && hit.collider) b.standingOn = this.byCollider.get(hit.collider.handle)?.entity;
|
|
458
|
+
if (hit.normal1.y < -GROUND_NORMAL) ceiling = true;
|
|
459
|
+
}
|
|
460
|
+
if (b.grounded) {
|
|
461
|
+
// Snapping onto the ground reports no contact, so look down from the feet for what's there.
|
|
462
|
+
const feetY = t.y - b.height / 2;
|
|
463
|
+
const below = this.world.castRay(
|
|
464
|
+
new RAPIER.Ray({ x: t.x, y: feetY + FEET_PROBE, z: t.z }, { x: 0, y: -1, z: 0 }),
|
|
465
|
+
FEET_PROBE + CONTROLLER_OFFSET + SNAP_TO_GROUND,
|
|
466
|
+
true,
|
|
467
|
+
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
|
|
468
|
+
undefined,
|
|
469
|
+
collider,
|
|
470
|
+
);
|
|
471
|
+
const ground = below ? this.byCollider.get(below.collider.handle)?.entity : undefined;
|
|
472
|
+
b.standingOn = ground ?? b.standingOn ?? previous;
|
|
473
|
+
// Keep the controller's gap above the ground. Landing can sink a hair into it, and from there
|
|
474
|
+
// every step on a kinematic platform counts as blocked.
|
|
475
|
+
const gap = below ? below.timeOfImpact - FEET_PROBE : Infinity;
|
|
476
|
+
if (gap < GROUND_GAP) {
|
|
477
|
+
t.y += GROUND_GAP - gap;
|
|
478
|
+
body.setNextKinematicTranslation(t);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (touching && b.vy < 0) b.vy = 0;
|
|
482
|
+
// Bumped its head: stop rising (a wall it slides up doesn't count).
|
|
483
|
+
if (own.y > 0 && moved.y < own.y - 1e-6 && ceiling) b.vy = 0;
|
|
484
|
+
if (b.grounded && !wasGrounded) host.emit({ type: "land", entity: e.id, ...feet(e) });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** A body is grounded when a solid touches it from below; it then stands on that solid. */
|
|
488
|
+
private updateGround(link: Link, host: PhysicsHost): void {
|
|
489
|
+
const b = link.entity.body!;
|
|
490
|
+
const wasGrounded = b.grounded;
|
|
491
|
+
b.grounded = false;
|
|
492
|
+
b.standingOn = undefined;
|
|
493
|
+
this.world.contactPairsWith(link.collider, (other) => {
|
|
494
|
+
if (b.grounded || other.isSensor()) return;
|
|
495
|
+
this.world.contactPair(link.collider, other, (manifold, flipped) => {
|
|
496
|
+
if (b.grounded || manifold.numContacts() === 0) return;
|
|
497
|
+
// The normal points from the first collider to the second: a solid below has one pointing down.
|
|
498
|
+
const down = flipped ? manifold.normal().y : -manifold.normal().y;
|
|
499
|
+
if (down > GROUND_NORMAL) {
|
|
500
|
+
b.grounded = true;
|
|
501
|
+
b.standingOn = this.byCollider.get(other.handle)?.entity;
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
if (b.grounded && !wasGrounded) host.emit({ type: "land", entity: link.entity.id, ...feet(link.entity) });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
mass(e: Entity): number {
|
|
509
|
+
return this.links.get(e)?.collider.mass() ?? 0;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
applyImpulse(e: Entity, x: number, y: number, at?: { x: number; y: number; z?: number }, z = 0): void {
|
|
513
|
+
const link = this.links.get(e);
|
|
514
|
+
const b = e.body;
|
|
515
|
+
if (!link || !b || b.type === "static") return;
|
|
516
|
+
if (link.controller) {
|
|
517
|
+
const mass = link.collider.mass() || 1;
|
|
518
|
+
b.vx += x / mass;
|
|
519
|
+
b.vy += y / mass;
|
|
520
|
+
b.vz += z / mass;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (b.type !== "dynamic") return;
|
|
524
|
+
const impulse = { x, y, z };
|
|
525
|
+
if (at) link.body.applyImpulseAtPoint(impulse, { x: at.x, y: at.y, z: at.z ?? e.transform.z }, true);
|
|
526
|
+
else link.body.applyImpulse(impulse, true);
|
|
527
|
+
const v = link.body.linvel();
|
|
528
|
+
b.vx = v.x;
|
|
529
|
+
b.vy = v.y;
|
|
530
|
+
b.vz = v.z;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Spin around z, in degrees per second (the axis the screen faces). */
|
|
534
|
+
angularVelocity(e: Entity): number {
|
|
535
|
+
const body = this.links.get(e)?.body;
|
|
536
|
+
return body ? body.angvel().z / DEGREES : 0;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
setAngularVelocity(e: Entity, value: number): void {
|
|
540
|
+
const link = this.links.get(e);
|
|
541
|
+
if (!link || e.body?.type !== "dynamic" || link.controller) return;
|
|
542
|
+
const w = link.body.angvel();
|
|
543
|
+
link.body.setAngvel({ x: w.x, y: w.y, z: value * DEGREES }, true);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
spin(e: Entity): { x: number; y: number; z: number } {
|
|
547
|
+
const w = this.links.get(e)?.body.angvel();
|
|
548
|
+
return w ? { x: w.x / DEGREES, y: w.y / DEGREES, z: w.z / DEGREES } : { x: 0, y: 0, z: 0 };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
setSpin(e: Entity, value: { x: number; y: number; z: number }): void {
|
|
552
|
+
const link = this.links.get(e);
|
|
553
|
+
if (link && e.body?.type === "dynamic" && !link.controller) link.body.setAngvel({ x: value.x * DEGREES, y: value.y * DEGREES, z: value.z * DEGREES }, true);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
raycast(_host: PhysicsHost, from: { x: number; y: number; z?: number }, to: { x: number; y: number; z?: number }, options: RayOptions = {}): RayHit | undefined {
|
|
557
|
+
const origin = { x: from.x, y: from.y, z: from.z ?? 0 };
|
|
558
|
+
const dir = { x: to.x - from.x, y: to.y - from.y, z: (to.z ?? 0) - origin.z };
|
|
559
|
+
const length = length3(dir.x, dir.y, dir.z);
|
|
560
|
+
if (length === 0) return undefined;
|
|
561
|
+
const ignored = new Set((options.ignore ?? []).map((e) => this.links.get(e)?.collider.handle));
|
|
562
|
+
const hit = this.world.castRayAndGetNormal(
|
|
563
|
+
new RAPIER.Ray(origin, dir),
|
|
564
|
+
1,
|
|
565
|
+
true,
|
|
566
|
+
options.sensors ? undefined : RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
|
|
567
|
+
undefined,
|
|
568
|
+
undefined,
|
|
569
|
+
undefined,
|
|
570
|
+
(collider) => this.byCollider.has(collider.handle) && !ignored.has(collider.handle) && !collider.containsPoint(origin),
|
|
571
|
+
);
|
|
572
|
+
if (!hit) return undefined;
|
|
573
|
+
const link = this.byCollider.get(hit.collider.handle)!;
|
|
574
|
+
const t = hit.timeOfImpact;
|
|
575
|
+
return {
|
|
576
|
+
entity: link.entity,
|
|
577
|
+
x: origin.x + dir.x * t,
|
|
578
|
+
y: origin.y + dir.y * t,
|
|
579
|
+
z: origin.z + dir.z * t,
|
|
580
|
+
distance: t * length,
|
|
581
|
+
normal: { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z },
|
|
582
|
+
} as RayHit;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
raw(resolve: (target: unknown) => Entity | undefined): unknown {
|
|
586
|
+
const linkOf = (target: unknown) => {
|
|
587
|
+
const e = resolve(target);
|
|
588
|
+
return e ? this.links.get(e) : undefined;
|
|
589
|
+
};
|
|
590
|
+
return {
|
|
591
|
+
RAPIER,
|
|
592
|
+
world: this.world,
|
|
593
|
+
body: (target: unknown) => linkOf(target)?.body,
|
|
594
|
+
collider: (target: unknown) => linkOf(target)?.collider,
|
|
595
|
+
joint: (target: unknown, index = 0) => linkOf(target)?.joints[index],
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
overlaps(a: Entity, b: Entity, margin = 0): boolean {
|
|
600
|
+
const la = this.links.get(a);
|
|
601
|
+
const lb = this.links.get(b);
|
|
602
|
+
if (!la || !lb) return false;
|
|
603
|
+
if (la.collider.isSensor() || lb.collider.isSensor()) return this.world.intersectionPair(la.collider, lb.collider);
|
|
604
|
+
let hit = false;
|
|
605
|
+
this.world.contactPair(la.collider, lb.collider, (manifold) => {
|
|
606
|
+
for (let i = 0; i < manifold.numContacts(); i++) if (manifold.contactDist(i) < margin) hit = true;
|
|
607
|
+
});
|
|
608
|
+
return hit;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** A vector's length from + * and sqrt only (Math.hypot is implementation-defined). */
|
|
613
|
+
function length3(x: number, y: number, z: number): number {
|
|
614
|
+
return Math.sqrt(x * x + y * y + z * z);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function vec(v: { x: number; y: number; z?: number }): Vec3 {
|
|
618
|
+
return { x: v.x, y: v.y, z: v.z ?? 0 };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** The joint's direction in the world: a vector in 3D, or the default for its type. */
|
|
622
|
+
function jointAxis(axis: number | { x?: number; y?: number; z?: number }, type: string): Vec3 {
|
|
623
|
+
const v = typeof axis === "object" ? { x: axis.x ?? 0, y: axis.y ?? 0, z: axis.z ?? 0 } : type === "prismatic" ? { x: 1, y: 0, z: 0 } : { x: 0, y: 0, z: 1 };
|
|
624
|
+
const length = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) || 1;
|
|
625
|
+
return { x: v.x / length, y: v.y / length, z: v.z / length };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** A point given as an offset from a body's center (in its own, turning frame), in the world. */
|
|
629
|
+
function worldPoint(body: RigidBody, offset: Vec3): Vec3 {
|
|
630
|
+
const p = body.translation();
|
|
631
|
+
const r = rotate(body.rotation(), offset);
|
|
632
|
+
return { x: p.x + r.x, y: p.y + r.y, z: p.z + r.z };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** A world point in a body's own frame. */
|
|
636
|
+
function localPoint(body: RigidBody, point: Vec3): Vec3 {
|
|
637
|
+
const p = body.translation();
|
|
638
|
+
return rotate(conjugate(body.rotation()), { x: point.x - p.x, y: point.y - p.y, z: point.z - p.z });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function at(e: Entity): Vec3 {
|
|
642
|
+
return { x: e.transform.x, y: e.transform.y, z: e.transform.z };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Where a body stands: the bottom of it (+y is up). */
|
|
646
|
+
function feet(e: Entity): Vec3 {
|
|
647
|
+
return { x: e.transform.x, y: e.transform.y - (e.body?.height ?? 0) / 2, z: e.transform.z };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function clampToPatrol3D(e: Entity): void {
|
|
651
|
+
const p = e.patrol!;
|
|
652
|
+
const t = e.transform;
|
|
653
|
+
if (p.axis === "x") t.x = Math.min(p.originX + p.distance, Math.max(p.originX, t.x));
|
|
654
|
+
else if (p.axis === "z") t.z = Math.min(p.originZ + p.distance, Math.max(p.originZ, t.z));
|
|
655
|
+
else t.y = Math.min(p.originY + p.distance, Math.max(p.originY, t.y));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function isPlayer(e: Entity): boolean {
|
|
659
|
+
return !!e.character && !!e.body && e.body.type !== "static";
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function depth(b: Body): number {
|
|
663
|
+
return b.depth || b.width;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function volume(b: Body): number {
|
|
667
|
+
return b.width * b.height * depth(b);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function shape(b: Body): ColliderDesc {
|
|
671
|
+
if (b.shape === "sphere") return RAPIER.ColliderDesc.ball(b.width / 2);
|
|
672
|
+
// Capsules and cylinders stand upright: height is end to end, width the diameter.
|
|
673
|
+
if (b.shape === "capsule") return RAPIER.ColliderDesc.capsule(Math.max(0, b.height - b.width) / 2, b.width / 2);
|
|
674
|
+
if (b.shape === "cylinder") return RAPIER.ColliderDesc.cylinder(b.height / 2, b.width / 2);
|
|
675
|
+
if (b.shape === "convex") {
|
|
676
|
+
const points = new Float32Array(b.points.flatMap(([x, y, z]) => [x!, y!, z ?? 0]));
|
|
677
|
+
return RAPIER.ColliderDesc.convexHull(points) ?? RAPIER.ColliderDesc.cuboid(b.width / 2, b.height / 2, depth(b) / 2);
|
|
678
|
+
}
|
|
679
|
+
return RAPIER.ColliderDesc.cuboid(b.width / 2, b.height / 2, depth(b) / 2);
|
|
680
|
+
}
|