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