@forgeax/engine-physics-rapier3d 0.1.2
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/LICENSE +202 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/physics-rapier3d.unit.test.d.ts +2 -0
- package/dist/__tests__/physics-rapier3d.unit.test.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +977 -0
- package/dist/index.mjs.map +1 -0
- package/dist/rapier-physics-world-3d.d.ts +296 -0
- package/dist/rapier-physics-world-3d.d.ts.map +1 -0
- package/dist/wasm-loader.d.ts +22 -0
- package/dist/wasm-loader.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/__tests__/physics-rapier3d.unit.test.ts +2362 -0
- package/src/index.ts +13 -0
- package/src/rapier-physics-world-3d.ts +1499 -0
- package/src/wasm-loader.ts +66 -0
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
import { componentDefinition, Disabled, FixedTime, FixedUpdate } from '@forgeax/engine-ecs';
|
|
2
|
+
// @forgeax/engine-physics-rapier3d — RapierPhysicsWorld3D class and three-phase
|
|
3
|
+
// tick systems (syncBackend / stepSimulation / writeback).
|
|
4
|
+
//
|
|
5
|
+
// RapierPhysicsWorld3D implements the PhysicsWorld interface from
|
|
6
|
+
// @forgeax/engine-physics and holds a Rapier 3D World instance as its
|
|
7
|
+
// simulation backend.
|
|
8
|
+
//
|
|
9
|
+
// Three-phase pipeline (plan-strategy D-1):
|
|
10
|
+
// 1. syncBackend: apply pending teleports, update kinematic positions.
|
|
11
|
+
// 2. stepSimulation: call rapierWorld.step(eventQueue).
|
|
12
|
+
// 3. writeback: read Rapier body positions (dynamic only).
|
|
13
|
+
//
|
|
14
|
+
// Entity-to-body mapping (plan-strategy D-7): Rapier RigidBody.userData holds
|
|
15
|
+
// the ECS entity raw value for reverse lookup in collision events.
|
|
16
|
+
//
|
|
17
|
+
// Despawn cleanup (plan-strategy D-5): removeEntity() removes the Rapier body
|
|
18
|
+
// and colliders from the physics world.
|
|
19
|
+
|
|
20
|
+
import type { Component, EntityHandle, SystemHandle, World } from '@forgeax/engine-ecs';
|
|
21
|
+
import { defineSystem } from '@forgeax/engine-ecs';
|
|
22
|
+
import { createWorldProjection, type WorldProjection } from '@forgeax/engine-ecs/projection';
|
|
23
|
+
import { mat4, quat, type Vec3, vec3 } from '@forgeax/engine-math';
|
|
24
|
+
import type { PhysicsWorld, RaycastHit } from '@forgeax/engine-physics';
|
|
25
|
+
import {
|
|
26
|
+
CharacterController,
|
|
27
|
+
Collider,
|
|
28
|
+
CollidingEntities,
|
|
29
|
+
colliderShapeFromF32,
|
|
30
|
+
PHYSICS_ERROR_HINTS,
|
|
31
|
+
PhysicsError,
|
|
32
|
+
PhysicsSet,
|
|
33
|
+
RIGID_BODY_TYPE_STATIC,
|
|
34
|
+
RigidBody,
|
|
35
|
+
registerPhysicsComponents,
|
|
36
|
+
rigidBodyTypeFromF32,
|
|
37
|
+
} from '@forgeax/engine-physics';
|
|
38
|
+
import { ChildOf } from '@forgeax/engine-scene';
|
|
39
|
+
import type { Rapier3DModule } from './wasm-loader';
|
|
40
|
+
|
|
41
|
+
interface Rapier3DKinematicControllerState {
|
|
42
|
+
readonly entity: number;
|
|
43
|
+
readonly offset: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-entity physics record — tracks the Rapier body handle for
|
|
48
|
+
* each ECS entity.
|
|
49
|
+
*/
|
|
50
|
+
interface PhysicsEntityRecord {
|
|
51
|
+
bodyHandle: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PhysicsTransform3D {
|
|
55
|
+
readonly position: { readonly x: number; readonly y: number; readonly z: number };
|
|
56
|
+
readonly rotation: {
|
|
57
|
+
readonly x: number;
|
|
58
|
+
readonly y: number;
|
|
59
|
+
readonly z: number;
|
|
60
|
+
readonly w: number;
|
|
61
|
+
};
|
|
62
|
+
readonly scale: { readonly x: number; readonly y: number; readonly z: number };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PhysicsCollider3D {
|
|
66
|
+
readonly shape: number;
|
|
67
|
+
readonly halfExtents: readonly [number, number, number];
|
|
68
|
+
readonly radius: number;
|
|
69
|
+
readonly halfHeight: number;
|
|
70
|
+
readonly friction: number;
|
|
71
|
+
readonly restitution: number;
|
|
72
|
+
readonly density: number;
|
|
73
|
+
readonly isSensor: number;
|
|
74
|
+
readonly collisionGroups: number;
|
|
75
|
+
readonly solverGroups: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PhysicsSyncQueryRow {
|
|
79
|
+
readonly entity: EntityHandle;
|
|
80
|
+
has(component: Component): boolean;
|
|
81
|
+
get(component: Component): Record<string, unknown> | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface PhysicsSyncQuery extends Iterable<PhysicsSyncQueryRow> {
|
|
85
|
+
at(entity: EntityHandle): PhysicsSyncQueryRow | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface PhysicsSyncState {
|
|
89
|
+
readonly world: World;
|
|
90
|
+
readonly transformComponent: Component;
|
|
91
|
+
readonly query: PhysicsSyncQuery;
|
|
92
|
+
readonly projection: WorldProjection;
|
|
93
|
+
initialized: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface PhysicsSyncDescriptor {
|
|
97
|
+
readonly entity: EntityHandle;
|
|
98
|
+
readonly transform: PhysicsTransform3D;
|
|
99
|
+
readonly rigidBody: {
|
|
100
|
+
readonly type: number;
|
|
101
|
+
readonly mass: number;
|
|
102
|
+
readonly linearDamping: number;
|
|
103
|
+
readonly angularDamping: number;
|
|
104
|
+
readonly gravityScale: number;
|
|
105
|
+
readonly ccdEnabled: number;
|
|
106
|
+
};
|
|
107
|
+
readonly collider: PhysicsCollider3D;
|
|
108
|
+
readonly hasCharacterController: boolean;
|
|
109
|
+
readonly characterControllerOffset: number | undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface PhysicsEntityDelta {
|
|
113
|
+
transformChanged: boolean;
|
|
114
|
+
colliderChanged: boolean;
|
|
115
|
+
rigidBodyChanged: boolean;
|
|
116
|
+
characterControllerChanged: boolean;
|
|
117
|
+
characterControllerRemoved: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface Rapier3DCollisionEvent {
|
|
121
|
+
readonly type: 'started' | 'stopped';
|
|
122
|
+
readonly entityA: number;
|
|
123
|
+
readonly entityB: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamically loaded module
|
|
127
|
+
type RapierWorld = any;
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamically loaded module
|
|
129
|
+
type RapierEventQueue = any;
|
|
130
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamically loaded module
|
|
131
|
+
type RapierRigidBody = any;
|
|
132
|
+
|
|
133
|
+
/** CharacterController tuning fields read per moveAndSlide call (degrees + world units). */
|
|
134
|
+
interface CharacterControllerTuning {
|
|
135
|
+
offset: number;
|
|
136
|
+
maxSlopeClimbDeg: number;
|
|
137
|
+
minSlopeSlideDeg: number;
|
|
138
|
+
autoStepMaxHeight: number;
|
|
139
|
+
autoStepMinWidth: number;
|
|
140
|
+
snapToGroundDist: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Re-apply all KCC setters from the component tuning every call (plan-strategy
|
|
147
|
+
* D-7: full reset, no dirty tracking). Degrees -> radians for the two slope
|
|
148
|
+
* setters; offset / autostep / snap pass through as world units. A zero value
|
|
149
|
+
* for auto-step / snap calls `disable*()` rather than `enable*(0)`.
|
|
150
|
+
*/
|
|
151
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
|
|
152
|
+
function applyKccTuning(ctrl: any, cc: CharacterControllerTuning): void {
|
|
153
|
+
ctrl.setMaxSlopeClimbAngle(cc.maxSlopeClimbDeg * DEG_TO_RAD);
|
|
154
|
+
ctrl.setMinSlopeSlideAngle(cc.minSlopeSlideDeg * DEG_TO_RAD);
|
|
155
|
+
ctrl.setSlideEnabled(true);
|
|
156
|
+
if (cc.autoStepMaxHeight === 0) {
|
|
157
|
+
ctrl.disableAutostep();
|
|
158
|
+
} else {
|
|
159
|
+
ctrl.enableAutostep(cc.autoStepMaxHeight, cc.autoStepMinWidth, false); // D-7: includeDynamicBodies=false
|
|
160
|
+
}
|
|
161
|
+
if (cc.snapToGroundDist === 0) {
|
|
162
|
+
ctrl.disableSnapToGround();
|
|
163
|
+
} else {
|
|
164
|
+
ctrl.enableSnapToGround(cc.snapToGroundDist);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Map a Rapier RigidBodyType enum value to the engine's string union for the
|
|
170
|
+
* `controller-requires-kinematic` error detail.
|
|
171
|
+
*/
|
|
172
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier module enum from dynamic module
|
|
173
|
+
function rapierBodyTypeToString(rapier: any, bodyType: number): string {
|
|
174
|
+
if (bodyType === rapier.RigidBodyType.Dynamic) return 'dynamic';
|
|
175
|
+
if (bodyType === rapier.RigidBodyType.Fixed) return 'static';
|
|
176
|
+
return 'kinematic';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* RapierPhysicsWorld3D — Rapier 3D WASM backend implementing the PhysicsWorld
|
|
181
|
+
* interface.
|
|
182
|
+
*/
|
|
183
|
+
export class RapierPhysicsWorld3D implements PhysicsWorld {
|
|
184
|
+
/** Rapier 3D World instance owning all bodies, colliders, and pipeline. */
|
|
185
|
+
raw: RapierWorld;
|
|
186
|
+
|
|
187
|
+
private readonly rapierModule: Rapier3DModule;
|
|
188
|
+
|
|
189
|
+
/** Entity (raw number) -> PhysicsEntityRecord mapping. */
|
|
190
|
+
private readonly entityMap = new Map<number, PhysicsEntityRecord>();
|
|
191
|
+
|
|
192
|
+
/** Pending teleports: entity -> target position, applied on next sync. */
|
|
193
|
+
private readonly pendingTeleports = new Map<number, { x: number; y: number; z: number }>();
|
|
194
|
+
|
|
195
|
+
/** Event queue for collision events. */
|
|
196
|
+
private eventQueue: RapierEventQueue;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Active overlap set per entity, maintained by draining the event queue each
|
|
200
|
+
* step. `started` events add the pair both ways; `stopped` events remove it.
|
|
201
|
+
* Read out into each entity's `CollidingEntities` component by
|
|
202
|
+
* `writebackCollidingEntities`. Covers both solid contacts and sensor
|
|
203
|
+
* intersections (Rapier emits CollisionEvent for both).
|
|
204
|
+
*/
|
|
205
|
+
private readonly collisionPairs = new Map<number, Set<number>>();
|
|
206
|
+
|
|
207
|
+
private readonly pendingCollisionEvents: Rapier3DCollisionEvent[] = [];
|
|
208
|
+
|
|
209
|
+
private readonly collisionEventHistory: Rapier3DCollisionEvent[] = [];
|
|
210
|
+
|
|
211
|
+
private currentGravity: { x: number; y: number; z: number };
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Lazily-built Rapier KinematicCharacterController per character entity
|
|
215
|
+
* (plan-strategy D-1/D-3). `moveAndSlide` creates one on first call; the
|
|
216
|
+
* `Collider.onRemove` hook (registerPhysicsSystems) clears it on despawn.
|
|
217
|
+
* Public so AC-11 despawn tests can assert `kccCache.size === 0`.
|
|
218
|
+
*/
|
|
219
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
|
|
220
|
+
readonly kccCache = new Map<number, any>();
|
|
221
|
+
|
|
222
|
+
private readonly kccOffsets = new Map<number, number>();
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* ECS World + components wired in by `registerPhysicsSystems`, so
|
|
226
|
+
* `moveAndSlide` can read CharacterController tuning and write Transform +
|
|
227
|
+
* grounded back. Undefined until systems are registered — the input-validation
|
|
228
|
+
* error paths (body / collider) fire before these are read, so direct
|
|
229
|
+
* `pw.moveAndSlide()` calls in error tests need no World.
|
|
230
|
+
*/
|
|
231
|
+
private moveContext:
|
|
232
|
+
| { world: World; transform: Component; characterController: Component }
|
|
233
|
+
| undefined;
|
|
234
|
+
|
|
235
|
+
/** Persistent ECS query + projection cursor for incremental backend sync. */
|
|
236
|
+
private syncState: PhysicsSyncState | undefined;
|
|
237
|
+
|
|
238
|
+
private disposed = false;
|
|
239
|
+
|
|
240
|
+
constructor(rapier: Rapier3DModule) {
|
|
241
|
+
this.rapierModule = rapier;
|
|
242
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World constructor is a class exported from a namespace module
|
|
243
|
+
this.raw = new (rapier as any).World({ x: 0, y: -9.81, z: 0 }) as RapierWorld;
|
|
244
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier EventQueue constructor comes from a namespace module
|
|
245
|
+
this.eventQueue = new (rapier as any).EventQueue(true) as RapierEventQueue;
|
|
246
|
+
this.currentGravity = { x: 0, y: -9.81, z: 0 };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── PhysicsWorld interface ────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
setGravity(gravity: Vec3): void {
|
|
252
|
+
this.assertActive('setGravity');
|
|
253
|
+
const x = gravity[0] ?? 0;
|
|
254
|
+
const y = gravity[1] ?? 0;
|
|
255
|
+
const z = gravity[2] ?? 0;
|
|
256
|
+
this.raw.gravity = { x, y, z };
|
|
257
|
+
this.currentGravity = { x, y, z };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
getGravity(): Vec3 {
|
|
261
|
+
const { x, y, z } = this.currentGravity;
|
|
262
|
+
return vec3.create(x, y, z);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
raycast(
|
|
266
|
+
origin: Vec3,
|
|
267
|
+
direction: Vec3,
|
|
268
|
+
maxDist: number,
|
|
269
|
+
filterMask?: number,
|
|
270
|
+
): RaycastHit | undefined {
|
|
271
|
+
this.assertActive('raycast');
|
|
272
|
+
const RAPIER = this.rapierModule;
|
|
273
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier Ray constructor comes from a namespace module
|
|
274
|
+
const RayCtor = (RAPIER as any).Ray as new (
|
|
275
|
+
origin: { x: number; y: number; z: number },
|
|
276
|
+
dir: { x: number; y: number; z: number },
|
|
277
|
+
) => { pointAt(t: number): { x: number; y: number; z: number } };
|
|
278
|
+
const ray = new RayCtor(
|
|
279
|
+
{ x: origin[0] ?? 0, y: origin[1] ?? 0, z: origin[2] ?? 0 },
|
|
280
|
+
{ x: direction[0] ?? 0, y: direction[1] ?? 0, z: direction[2] ?? 0 },
|
|
281
|
+
);
|
|
282
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World castRayAndGetNormal
|
|
283
|
+
const hit = (this.raw as any).castRayAndGetNormal(
|
|
284
|
+
ray,
|
|
285
|
+
maxDist,
|
|
286
|
+
true,
|
|
287
|
+
undefined,
|
|
288
|
+
filterMask,
|
|
289
|
+
) as {
|
|
290
|
+
collider: { parent(): { userData: number } | null };
|
|
291
|
+
timeOfImpact: number;
|
|
292
|
+
normal: { x: number; y: number; z: number };
|
|
293
|
+
} | null;
|
|
294
|
+
|
|
295
|
+
if (hit === null) return undefined;
|
|
296
|
+
|
|
297
|
+
const point = ray.pointAt(hit.timeOfImpact);
|
|
298
|
+
// `hit.collider.parent()` already returns the owning RigidBody OBJECT (compat
|
|
299
|
+
// build), whose userData holds the ECS entity — read it directly, mirroring
|
|
300
|
+
// `colliderHandleToEntity` (the proven CollidingEntities path). The prior code
|
|
301
|
+
// treated the object as a body HANDLE and re-resolved it via `bodies.get(...)`,
|
|
302
|
+
// which returned a DIFFERENT body → raycast reported the wrong entity.
|
|
303
|
+
const colliderParentBody = hit.collider.parent();
|
|
304
|
+
const entity = colliderParentBody !== null ? colliderParentBody.userData : 0;
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
entity,
|
|
308
|
+
point: vec3.create(point.x, point.y, point.z),
|
|
309
|
+
normal: vec3.create(hit.normal.x, hit.normal.y, hit.normal.z),
|
|
310
|
+
timeOfImpact: hit.timeOfImpact,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
teleport(entity: number, position: Vec3): void {
|
|
315
|
+
this.assertActive('teleport');
|
|
316
|
+
this.pendingTeleports.set(entity, {
|
|
317
|
+
x: position[0] ?? 0,
|
|
318
|
+
y: position[1] ?? 0,
|
|
319
|
+
z: position[2] ?? 0,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
step(deltaTime: number): void {
|
|
324
|
+
this.assertActive('step');
|
|
325
|
+
void deltaTime;
|
|
326
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.step
|
|
327
|
+
(this.raw as any).step(this.eventQueue);
|
|
328
|
+
this.drainRapierCollisionEvents();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Drain the Rapier event queue into `collisionPairs`. Each event names two
|
|
333
|
+
* collider handles + a `started` flag; we resolve each collider to its owning
|
|
334
|
+
* entity (collider.parent() -> body.userData) and add/remove the symmetric
|
|
335
|
+
* pair. This is what populates `CollidingEntities` for sensor pickup + contact
|
|
336
|
+
* queries (the queue is otherwise drained-on-overflow and never observed).
|
|
337
|
+
*/
|
|
338
|
+
private drainRapierCollisionEvents(): void {
|
|
339
|
+
this.eventQueue.drainCollisionEvents((handle1: number, handle2: number, started: boolean) => {
|
|
340
|
+
const a = this.colliderHandleToEntity(handle1);
|
|
341
|
+
const b = this.colliderHandleToEntity(handle2);
|
|
342
|
+
if (a === undefined || b === undefined) return;
|
|
343
|
+
const changed = started ? this.addPair(a, b) : this.removePair(a, b);
|
|
344
|
+
if (!changed) return;
|
|
345
|
+
this.pushCollisionEvent({
|
|
346
|
+
type: started ? 'started' : 'stopped',
|
|
347
|
+
entityA: a,
|
|
348
|
+
entityB: b,
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Resolve a Rapier collider handle to its owning ECS entity, or undefined. */
|
|
354
|
+
private colliderHandleToEntity(colliderHandle: number): number | undefined {
|
|
355
|
+
// getCollider(handle).parent() returns the owning RigidBody (compat build),
|
|
356
|
+
// whose userData holds the ECS entity raw value (set in ensureBody).
|
|
357
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.getCollider from dynamic module
|
|
358
|
+
const collider = (this.raw as any).getCollider(colliderHandle) as {
|
|
359
|
+
parent(): { userData: number } | null;
|
|
360
|
+
} | null;
|
|
361
|
+
if (collider === null || collider === undefined) return undefined;
|
|
362
|
+
const body = collider.parent();
|
|
363
|
+
if (body === null || body === undefined) return undefined;
|
|
364
|
+
return body.userData;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private addPair(a: number, b: number): boolean {
|
|
368
|
+
let setA = this.collisionPairs.get(a);
|
|
369
|
+
if (!setA) {
|
|
370
|
+
setA = new Set<number>();
|
|
371
|
+
this.collisionPairs.set(a, setA);
|
|
372
|
+
}
|
|
373
|
+
if (setA.has(b)) return false;
|
|
374
|
+
setA.add(b);
|
|
375
|
+
let setB = this.collisionPairs.get(b);
|
|
376
|
+
if (!setB) {
|
|
377
|
+
setB = new Set<number>();
|
|
378
|
+
this.collisionPairs.set(b, setB);
|
|
379
|
+
}
|
|
380
|
+
setB.add(a);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private removePair(a: number, b: number): boolean {
|
|
385
|
+
const removedA = this.collisionPairs.get(a)?.delete(b) ?? false;
|
|
386
|
+
const removedB = this.collisionPairs.get(b)?.delete(a) ?? false;
|
|
387
|
+
return removedA || removedB;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private pushCollisionEvent(event: Rapier3DCollisionEvent): void {
|
|
391
|
+
const ordered =
|
|
392
|
+
event.entityA <= event.entityB
|
|
393
|
+
? event
|
|
394
|
+
: { ...event, entityA: event.entityB, entityB: event.entityA };
|
|
395
|
+
this.pendingCollisionEvents.push(ordered);
|
|
396
|
+
this.collisionEventHistory.push(ordered);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Write the current overlap set into each entity's `CollidingEntities`
|
|
401
|
+
* component (entities that carry it). Called by the PhysicsCollisionSync
|
|
402
|
+
* system after writeback. Entities with no current overlaps get an empty set,
|
|
403
|
+
* so a Core that the player has left clears correctly. Only entities that own
|
|
404
|
+
* a CollidingEntities component are written (others are skipped).
|
|
405
|
+
*/
|
|
406
|
+
writebackCollidingEntities(world: World, collidingComponent: Component): void {
|
|
407
|
+
for (const [entity, others] of this.collisionPairs) {
|
|
408
|
+
const handle = entity as EntityHandle;
|
|
409
|
+
if (!world.get(handle, collidingComponent).ok) continue;
|
|
410
|
+
world.set(handle, collidingComponent, { entities: [...others] });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
drainCollisionEvents(): Rapier3DCollisionEvent[] {
|
|
415
|
+
return this.pendingCollisionEvents.splice(0);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
getCollisionPairs(): Map<number, Set<number>> {
|
|
419
|
+
return new Map([...this.collisionPairs].map(([entity, others]) => [entity, new Set(others)]));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
getCollisionEventHistory(): readonly Rapier3DCollisionEvent[] {
|
|
423
|
+
return [...this.collisionEventHistory];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
getPendingTeleports(): readonly [
|
|
427
|
+
number,
|
|
428
|
+
{ readonly x: number; readonly y: number; readonly z: number },
|
|
429
|
+
][] {
|
|
430
|
+
return [...this.pendingTeleports].map(([entity, target]) => [entity, { ...target }]);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
getKinematicControllerStates(): readonly Rapier3DKinematicControllerState[] {
|
|
434
|
+
return [...this.kccOffsets]
|
|
435
|
+
.sort(([first], [second]) => first - second)
|
|
436
|
+
.map(([entity, offset]) => ({ entity, offset }));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
dispose(): void {
|
|
440
|
+
if (this.disposed) return;
|
|
441
|
+
this.syncState = undefined;
|
|
442
|
+
this.moveContext = undefined;
|
|
443
|
+
if (typeof this.raw.free === 'function') this.raw.free();
|
|
444
|
+
if (typeof this.eventQueue.free === 'function') this.eventQueue.free();
|
|
445
|
+
this.entityMap.clear();
|
|
446
|
+
this.pendingTeleports.clear();
|
|
447
|
+
this.collisionPairs.clear();
|
|
448
|
+
this.pendingCollisionEvents.length = 0;
|
|
449
|
+
this.collisionEventHistory.length = 0;
|
|
450
|
+
this.kccCache.clear();
|
|
451
|
+
this.kccOffsets.clear();
|
|
452
|
+
this.disposed = true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
getBodyCount(): number {
|
|
456
|
+
return this.entityMap.size;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
hasBody(entity: number): boolean {
|
|
460
|
+
return this.entityMap.has(entity);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Wire the ECS World + Transform / CharacterController components needed by
|
|
465
|
+
* `moveAndSlide` to read tuning and write back pose + grounded. Called once by
|
|
466
|
+
* `registerPhysicsSystems` (plan-strategy D-1/D-7).
|
|
467
|
+
*/
|
|
468
|
+
setMoveContext(world: World, transform: Component, characterController: Component): void {
|
|
469
|
+
this.assertActive('setMoveContext');
|
|
470
|
+
this.moveContext = { world, transform, characterController };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Release the persistent ECS readers owned by one system registration. */
|
|
474
|
+
clearEcsContext(world: World): void {
|
|
475
|
+
if (this.syncState?.world === world) this.syncState = undefined;
|
|
476
|
+
if (this.moveContext?.world === world) this.moveContext = undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
moveAndSlide(entity: number, desiredDelta: Vec3): Vec3 {
|
|
480
|
+
this.assertActive('moveAndSlide');
|
|
481
|
+
return this.computeMove(entity, desiredDelta);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private assertActive(operation: string): void {
|
|
485
|
+
if (this.disposed) {
|
|
486
|
+
throw new Error(`RapierPhysicsWorld3D.${operation} cannot run on a disposed instance`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Shared moveAndSlide core (plan-strategy D-1/D-2/D-4/D-6/D-7).
|
|
492
|
+
*
|
|
493
|
+
* The three Fail-Fast entry checks (body / collider / kinematic) throw
|
|
494
|
+
* structured PhysicsError before the World is read, so error-path tests can
|
|
495
|
+
* call this without registered systems.
|
|
496
|
+
*/
|
|
497
|
+
private computeMove(entity: number, desiredDelta: Vec3): Vec3 {
|
|
498
|
+
// ── Fail-Fast entry checks (charter P3) ──
|
|
499
|
+
const record = this.entityMap.get(entity);
|
|
500
|
+
if (!record) {
|
|
501
|
+
throw new PhysicsError({
|
|
502
|
+
code: 'body-not-found',
|
|
503
|
+
expected: 'a registered Rapier body for this entity',
|
|
504
|
+
hint: PHYSICS_ERROR_HINTS['body-not-found'],
|
|
505
|
+
detail: { code: 'body-not-found', entity },
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
|
|
509
|
+
const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
|
|
510
|
+
if (!body) {
|
|
511
|
+
throw new PhysicsError({
|
|
512
|
+
code: 'body-not-found',
|
|
513
|
+
expected: 'a registered Rapier body for this entity',
|
|
514
|
+
hint: PHYSICS_ERROR_HINTS['body-not-found'],
|
|
515
|
+
detail: { code: 'body-not-found', entity },
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
if (body.numColliders() === 0) {
|
|
519
|
+
// D-2: body exists but carries no collider — more precise than body-not-found.
|
|
520
|
+
throw new PhysicsError({
|
|
521
|
+
code: 'collider-not-found',
|
|
522
|
+
expected: 'a Collider attached to this entity body',
|
|
523
|
+
hint: PHYSICS_ERROR_HINTS['collider-not-found'],
|
|
524
|
+
detail: { code: 'collider-not-found', entity },
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
const RAPIER = this.rapierModule;
|
|
528
|
+
if (body.bodyType() !== RAPIER.RigidBodyType.KinematicPositionBased) {
|
|
529
|
+
throw new PhysicsError({
|
|
530
|
+
code: 'controller-requires-kinematic',
|
|
531
|
+
expected: "RigidBody.type === 'kinematic'",
|
|
532
|
+
hint: PHYSICS_ERROR_HINTS['controller-requires-kinematic'],
|
|
533
|
+
detail: {
|
|
534
|
+
code: 'controller-requires-kinematic',
|
|
535
|
+
entity,
|
|
536
|
+
bodyType: rapierBodyTypeToString(RAPIER, body.bodyType()),
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const collider = body.collider(0); // D-4: zero-schema reverse lookup
|
|
542
|
+
|
|
543
|
+
// ── Read CharacterController tuning + lazily build/configure the KCC ──
|
|
544
|
+
const cc = this.readCharacterController(entity);
|
|
545
|
+
const ctrl = this.ensureKcc(entity, cc.offset);
|
|
546
|
+
applyKccTuning(ctrl, cc);
|
|
547
|
+
|
|
548
|
+
// ── Step 1: solve collisions (D-1 self-exclude predicate) ──
|
|
549
|
+
// Rapier's filter predicate returns true to INCLUDE a collider as a
|
|
550
|
+
// potential obstacle, false to skip it; this excludes the character's own
|
|
551
|
+
// collider so it never collides with itself. EXCLUDE_SENSORS makes the KCC
|
|
552
|
+
// treat sensor colliders as non-solid (their purpose is overlap detection,
|
|
553
|
+
// not blocking) -- without it any sensor overlapping the character (e.g. a
|
|
554
|
+
// pickup/attack trigger volume) walls the KCC and freezes it in place.
|
|
555
|
+
const delta = { x: desiredDelta[0] ?? 0, y: desiredDelta[1] ?? 0, z: desiredDelta[2] ?? 0 };
|
|
556
|
+
ctrl.computeColliderMovement(
|
|
557
|
+
collider,
|
|
558
|
+
delta,
|
|
559
|
+
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
|
|
560
|
+
undefined,
|
|
561
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier Collider in filter predicate
|
|
562
|
+
(other: any) => other.handle !== collider.handle,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// ── Step 2/3: read corrected movement + grounded ──
|
|
566
|
+
const movement = ctrl.computedMovement() as { x: number; y: number; z: number };
|
|
567
|
+
const grounded = ctrl.computedGrounded() as boolean;
|
|
568
|
+
|
|
569
|
+
// ── Write back: push the kinematic body + ECS Transform + grounded ──
|
|
570
|
+
const t = body.translation();
|
|
571
|
+
const next = { x: t.x + movement.x, y: t.y + movement.y, z: t.z + movement.z };
|
|
572
|
+
// setNextKinematicTranslation feeds the physics step pipeline; setTranslation
|
|
573
|
+
// advances the body + its collider immediately so consecutive moveAndSlide
|
|
574
|
+
// calls (without an intervening world.step) see the updated pose for the next
|
|
575
|
+
// collision solve + grounded check. The query structures are refreshed so the
|
|
576
|
+
// next computeColliderMovement reads the new position.
|
|
577
|
+
body.setNextKinematicTranslation(next);
|
|
578
|
+
body.setTranslation(next, true);
|
|
579
|
+
// setTranslation marks the body modified but does not re-place its collider
|
|
580
|
+
// in the collider set; propagate so the next computeColliderMovement
|
|
581
|
+
// shape-casts the character from its updated pose.
|
|
582
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.propagateModifiedBodyPositionsToColliders
|
|
583
|
+
(this.raw as any).propagateModifiedBodyPositionsToColliders();
|
|
584
|
+
|
|
585
|
+
const ctx = this.moveContext;
|
|
586
|
+
if (ctx) {
|
|
587
|
+
// D-6: writeback Result ignored — entry checks already guard liveness.
|
|
588
|
+
ctx.world.set(entity as EntityHandle, ctx.transform, {
|
|
589
|
+
pos: [next.x, next.y, next.z],
|
|
590
|
+
});
|
|
591
|
+
ctx.world.set(entity as EntityHandle, ctx.characterController, { grounded });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return vec3.create(movement.x, movement.y, movement.z);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Read CharacterController tuning fields for an entity from the ECS World,
|
|
599
|
+
* falling back to schema defaults when the World is not wired (defensive;
|
|
600
|
+
* the kinematic check upstream means a valid character always has the World).
|
|
601
|
+
*/
|
|
602
|
+
private readCharacterController(entity: number): CharacterControllerTuning {
|
|
603
|
+
const ctx = this.moveContext;
|
|
604
|
+
if (ctx) {
|
|
605
|
+
const r = ctx.world.get(entity as EntityHandle, ctx.characterController);
|
|
606
|
+
if (r.ok) {
|
|
607
|
+
const v = r.value as Record<string, number>;
|
|
608
|
+
return {
|
|
609
|
+
offset: v.offset as number,
|
|
610
|
+
maxSlopeClimbDeg: v.maxSlopeClimbDeg as number,
|
|
611
|
+
minSlopeSlideDeg: v.minSlopeSlideDeg as number,
|
|
612
|
+
autoStepMaxHeight: v.autoStepMaxHeight as number,
|
|
613
|
+
autoStepMinWidth: v.autoStepMinWidth as number,
|
|
614
|
+
snapToGroundDist: v.snapToGroundDist as number,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// The ECS token owns the defaults; keep the defensive no-context path on
|
|
619
|
+
// that projection so 3D cannot drift from the shared CharacterController schema.
|
|
620
|
+
return componentDefinition(CharacterController)
|
|
621
|
+
.defaults as unknown as CharacterControllerTuning;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Lazily build a Rapier KinematicCharacterController for `entity` (cached).
|
|
626
|
+
*/
|
|
627
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
|
|
628
|
+
private ensureKcc(entity: number, offset: number): any {
|
|
629
|
+
const cached = this.kccCache.get(entity);
|
|
630
|
+
if (cached) return cached;
|
|
631
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCharacterController
|
|
632
|
+
const ctrl = (this.raw as any).createCharacterController(offset);
|
|
633
|
+
this.kccCache.set(entity, ctrl);
|
|
634
|
+
this.kccOffsets.set(entity, offset);
|
|
635
|
+
return ctrl;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Remove backend rows whose Collider disappeared from the World query. */
|
|
639
|
+
pruneMissingEntities(active: ReadonlySet<number>): void {
|
|
640
|
+
for (const entity of this.entityMap.keys()) {
|
|
641
|
+
if (!active.has(entity)) this.removeEntity(entity);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private bodyForEntity(entity: number): RapierRigidBody | undefined {
|
|
646
|
+
const record = this.entityMap.get(entity);
|
|
647
|
+
if (!record) return undefined;
|
|
648
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
|
|
649
|
+
return ((this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null) ?? undefined;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private isCommittedFixedBody(entity: number): boolean {
|
|
653
|
+
return this.bodyForEntity(entity)?.bodyType() === this.rapierModule.RigidBodyType.Fixed;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private reconcileTransformlessCompatibility(entity: number, staticByEcs: boolean): void {
|
|
657
|
+
if (!staticByEcs || !this.isCommittedFixedBody(entity)) {
|
|
658
|
+
this.removeEntity(entity);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// A fixed body cannot be controller-owned. Clear stale KCC state defensively
|
|
662
|
+
// while retaining the already-committed body and collider receipt.
|
|
663
|
+
this.removeKccController(entity);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private resetForFullReconcile(transformlessStatic: ReadonlySet<number>): void {
|
|
667
|
+
// Without a descriptor/hash mirror, an overflow cannot prove which existing
|
|
668
|
+
// Transform-backed body changed. Recreate those bodies from the final ECS
|
|
669
|
+
// combination. Dynamic velocity/contact state is intentionally reset during
|
|
670
|
+
// this recovery path so stale motion type or collider data cannot survive.
|
|
671
|
+
for (const entity of [...this.entityMap.keys()]) {
|
|
672
|
+
if (transformlessStatic.has(entity) && this.isCommittedFixedBody(entity)) {
|
|
673
|
+
this.removeKccController(entity);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
this.removeEntity(entity);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private fullReconcilePhysicsState(state: PhysicsSyncState): void {
|
|
681
|
+
const descriptors: PhysicsSyncDescriptor[] = [];
|
|
682
|
+
const transformlessStatic = new Set<number>();
|
|
683
|
+
for (const row of state.query) {
|
|
684
|
+
if (!row.has(state.transformComponent)) {
|
|
685
|
+
if (physicsRowIsStatic(row)) transformlessStatic.add(row.entity);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const descriptor = readPhysicsSyncDescriptor(row, state.transformComponent);
|
|
689
|
+
if (descriptor !== undefined) descriptors.push(descriptor);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
this.resetForFullReconcile(transformlessStatic);
|
|
693
|
+
for (const descriptor of descriptors) {
|
|
694
|
+
this.ensureBody(
|
|
695
|
+
descriptor.entity,
|
|
696
|
+
descriptor.transform,
|
|
697
|
+
descriptor.rigidBody,
|
|
698
|
+
descriptor.collider,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
state.initialized = true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private reconcilePhysicsDelta(
|
|
705
|
+
state: PhysicsSyncState,
|
|
706
|
+
entity: EntityHandle,
|
|
707
|
+
delta: PhysicsEntityDelta,
|
|
708
|
+
): void {
|
|
709
|
+
const row = state.query.at(entity);
|
|
710
|
+
if (row === undefined) {
|
|
711
|
+
this.removeEntity(entity);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (!row.has(state.transformComponent)) {
|
|
715
|
+
// A lifecycle mutation cannot be applied without a pose. Keep only the
|
|
716
|
+
// narrow migration case where Transform alone disappeared from an already
|
|
717
|
+
// committed fixed body; never create or reshape a Transform-less row.
|
|
718
|
+
if (delta.colliderChanged || delta.rigidBodyChanged) {
|
|
719
|
+
this.removeEntity(entity);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
this.reconcileTransformlessCompatibility(entity, physicsRowIsStatic(row));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const descriptor = readPhysicsSyncDescriptor(row, state.transformComponent);
|
|
727
|
+
if (descriptor === undefined) {
|
|
728
|
+
this.removeEntity(entity);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (this.hasBody(entity) && (delta.colliderChanged || delta.rigidBodyChanged)) {
|
|
733
|
+
// Collider/RigidBody lifecycle is reconciled by replacement from the final
|
|
734
|
+
// ECS combination. This deliberately resets velocity/contact state for an
|
|
735
|
+
// affected dynamic body; no descriptor mirror is introduced in M1.
|
|
736
|
+
this.removeEntity(entity);
|
|
737
|
+
}
|
|
738
|
+
if (!this.hasBody(entity)) {
|
|
739
|
+
this.ensureBody(entity, descriptor.transform, descriptor.rigidBody, descriptor.collider);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (delta.characterControllerChanged) {
|
|
744
|
+
const cachedOffset = this.kccOffsets.get(entity);
|
|
745
|
+
const hadCachedReceipt = this.kccCache.has(entity);
|
|
746
|
+
const finalOffset = descriptor.characterControllerOffset;
|
|
747
|
+
if (!descriptor.hasCharacterController) {
|
|
748
|
+
this.removeKccController(entity);
|
|
749
|
+
} else if (
|
|
750
|
+
hadCachedReceipt &&
|
|
751
|
+
(delta.characterControllerRemoved ||
|
|
752
|
+
finalOffset === undefined ||
|
|
753
|
+
!Object.is(cachedOffset, finalOffset))
|
|
754
|
+
) {
|
|
755
|
+
// Consume the final ECS combination. A remove+add in one journal window
|
|
756
|
+
// rebuilds the KCC receipt with the final offset, while transient
|
|
757
|
+
// grounded writeback leaves an equal-offset receipt untouched.
|
|
758
|
+
this.removeKccController(entity);
|
|
759
|
+
if (finalOffset !== undefined) this.ensureKcc(entity, finalOffset);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (!delta.transformChanged && !delta.characterControllerRemoved) return;
|
|
763
|
+
|
|
764
|
+
const bodyType = rigidBodyTypeFromF32(descriptor.rigidBody.type);
|
|
765
|
+
if (bodyType === 'static') {
|
|
766
|
+
this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, 'static');
|
|
767
|
+
} else if (bodyType === 'kinematic' && !descriptor.hasCharacterController) {
|
|
768
|
+
this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, 'kinematic');
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** @internal ECS system bridge; consumers should register PhysicsSyncBackend. */
|
|
773
|
+
_syncFromEcs(world: World, transformComponent: Component): void {
|
|
774
|
+
this.assertActive('syncFromEcs');
|
|
775
|
+
let state = this.syncState;
|
|
776
|
+
if (
|
|
777
|
+
state === undefined ||
|
|
778
|
+
state.world !== world ||
|
|
779
|
+
state.transformComponent !== transformComponent
|
|
780
|
+
) {
|
|
781
|
+
const queryResult = world.query({
|
|
782
|
+
read: [Collider],
|
|
783
|
+
optional: [transformComponent, RigidBody, CharacterController, ChildOf],
|
|
784
|
+
});
|
|
785
|
+
if (!queryResult.ok) return;
|
|
786
|
+
state = {
|
|
787
|
+
world,
|
|
788
|
+
transformComponent,
|
|
789
|
+
query: queryResult.value as unknown as PhysicsSyncQuery,
|
|
790
|
+
projection: createWorldProjection(world, {
|
|
791
|
+
components: [
|
|
792
|
+
transformComponent,
|
|
793
|
+
Collider,
|
|
794
|
+
RigidBody,
|
|
795
|
+
CharacterController,
|
|
796
|
+
ChildOf,
|
|
797
|
+
Disabled,
|
|
798
|
+
],
|
|
799
|
+
}),
|
|
800
|
+
initialized: false,
|
|
801
|
+
};
|
|
802
|
+
this.syncState = state;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (!state.initialized) {
|
|
806
|
+
this.fullReconcilePhysicsState(state);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const evidence = state.projection.poll();
|
|
811
|
+
if (evidence.status === 'rebuild') {
|
|
812
|
+
this.fullReconcilePhysicsState(state);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (evidence.changes.length === 0) return;
|
|
816
|
+
|
|
817
|
+
const deltas = new Map<EntityHandle, PhysicsEntityDelta>();
|
|
818
|
+
for (const change of evidence.changes) {
|
|
819
|
+
let delta = deltas.get(change.entity);
|
|
820
|
+
if (delta === undefined) {
|
|
821
|
+
delta = {
|
|
822
|
+
transformChanged: false,
|
|
823
|
+
colliderChanged: false,
|
|
824
|
+
rigidBodyChanged: false,
|
|
825
|
+
characterControllerChanged: false,
|
|
826
|
+
characterControllerRemoved: false,
|
|
827
|
+
};
|
|
828
|
+
deltas.set(change.entity, delta);
|
|
829
|
+
}
|
|
830
|
+
if (change.kind === 'entity-removed') continue;
|
|
831
|
+
if (change.component === undefined) {
|
|
832
|
+
this.fullReconcilePhysicsState(state);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (change.component === transformComponent) {
|
|
836
|
+
delta.transformChanged = true;
|
|
837
|
+
} else if (change.component === Collider) {
|
|
838
|
+
delta.colliderChanged = true;
|
|
839
|
+
} else if (change.component === RigidBody) {
|
|
840
|
+
delta.rigidBodyChanged = true;
|
|
841
|
+
} else if (change.component === CharacterController) {
|
|
842
|
+
delta.characterControllerChanged = true;
|
|
843
|
+
if (change.kind === 'component-removed') delta.characterControllerRemoved = true;
|
|
844
|
+
} else if (change.component === ChildOf) {
|
|
845
|
+
delta.transformChanged = true;
|
|
846
|
+
} else if (change.component !== Disabled) {
|
|
847
|
+
// The projection is intentionally closed over the membership inputs.
|
|
848
|
+
// Any future unattributable record fails closed to a complete read.
|
|
849
|
+
this.fullReconcilePhysicsState(state);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
for (const [entity, delta] of deltas) this.reconcilePhysicsDelta(state, entity, delta);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Remove an entity's cached KCC and unregister it from the Rapier world
|
|
859
|
+
* (plan-strategy D-3). Idempotent — safe for entities that never moved.
|
|
860
|
+
*/
|
|
861
|
+
removeKccController(entity: number): void {
|
|
862
|
+
const ctrl = this.kccCache.get(entity);
|
|
863
|
+
this.kccOffsets.delete(entity);
|
|
864
|
+
if (!ctrl) return;
|
|
865
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.removeCharacterController
|
|
866
|
+
(this.raw as any).removeCharacterController(ctrl);
|
|
867
|
+
this.kccCache.delete(entity);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ─── ECS→Rapier bridge (D-2) ──────────────────────────────────────────
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Ensure a Rapier body and collider exist for an ECS entity (idempotent).
|
|
874
|
+
*
|
|
875
|
+
* When `entityMap` already contains the entity this returns immediately.
|
|
876
|
+
* Otherwise creates a Rapier RigidBody (dynamic / fixed / kinematic) +
|
|
877
|
+
* Collider (cuboid / ball / capsule) from the ECS component data, sets
|
|
878
|
+
* `body.userData = entity`, and registers the pairing via `registerBody`.
|
|
879
|
+
*
|
|
880
|
+
* @param entity Raw ECS entity number (stored in Rapier body.userData).
|
|
881
|
+
* @param transform ECS Transform fields: { posX, posY, posZ, ... }.
|
|
882
|
+
* @param rigidBody ECS RigidBody fields: { type (enum num), mass, ... }.
|
|
883
|
+
* @param collider ECS Collider fields: { shape (enum num), radius, ... }.
|
|
884
|
+
*
|
|
885
|
+
* Plan-strategy D-2 + D-3: enum→Rapier desc mapping consumes
|
|
886
|
+
* rigidBodyTypeFromF32 / colliderShapeFromF32 helpers; closed switch with
|
|
887
|
+
* no default — TypeScript enforces exhaustiveness on the string-union arms.
|
|
888
|
+
*/
|
|
889
|
+
ensureBody(
|
|
890
|
+
entity: number,
|
|
891
|
+
transform: PhysicsTransform3D,
|
|
892
|
+
rigidBody: {
|
|
893
|
+
type: number;
|
|
894
|
+
mass: number;
|
|
895
|
+
linearDamping: number;
|
|
896
|
+
angularDamping: number;
|
|
897
|
+
gravityScale: number;
|
|
898
|
+
ccdEnabled: number;
|
|
899
|
+
},
|
|
900
|
+
collider: PhysicsCollider3D,
|
|
901
|
+
): void {
|
|
902
|
+
this.assertActive('ensureBody');
|
|
903
|
+
if (this.entityMap.has(entity)) return; // M1 idempotent guard (D-2)
|
|
904
|
+
|
|
905
|
+
const RAPIER = this.rapierModule;
|
|
906
|
+
|
|
907
|
+
// ── Create RigidBodyDesc ──
|
|
908
|
+
const rbType = rigidBodyTypeFromF32(rigidBody.type);
|
|
909
|
+
let body: RapierRigidBody;
|
|
910
|
+
switch (rbType) {
|
|
911
|
+
case 'dynamic': {
|
|
912
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier RigidBodyDesc
|
|
913
|
+
const desc = (RAPIER as any).RigidBodyDesc.dynamic()
|
|
914
|
+
.setTranslation(transform.position.x, transform.position.y, transform.position.z)
|
|
915
|
+
.setRotation(transform.rotation)
|
|
916
|
+
.setLinearDamping(rigidBody.linearDamping)
|
|
917
|
+
.setAngularDamping(rigidBody.angularDamping)
|
|
918
|
+
.setGravityScale(rigidBody.gravityScale);
|
|
919
|
+
if (rigidBody.mass > 0) {
|
|
920
|
+
desc.setAdditionalMass(rigidBody.mass);
|
|
921
|
+
}
|
|
922
|
+
if (rigidBody.ccdEnabled) {
|
|
923
|
+
desc.setCcdEnabled(true);
|
|
924
|
+
}
|
|
925
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.createRigidBody
|
|
926
|
+
body = (this.raw as any).createRigidBody(desc);
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
case 'static': {
|
|
930
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier RigidBodyDesc
|
|
931
|
+
const desc = (RAPIER as any).RigidBodyDesc.fixed()
|
|
932
|
+
.setTranslation(transform.position.x, transform.position.y, transform.position.z)
|
|
933
|
+
.setRotation(transform.rotation);
|
|
934
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.createRigidBody
|
|
935
|
+
body = (this.raw as any).createRigidBody(desc);
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
case 'kinematic': {
|
|
939
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier RigidBodyDesc
|
|
940
|
+
const desc = (RAPIER as any).RigidBodyDesc.kinematicPositionBased()
|
|
941
|
+
.setTranslation(transform.position.x, transform.position.y, transform.position.z)
|
|
942
|
+
.setRotation(transform.rotation);
|
|
943
|
+
// CCD sweeps the collider along its per-step kinematic translation so a
|
|
944
|
+
// fast mover (player, bullet) reliably contacts dynamics instead of
|
|
945
|
+
// tunneling through them on discrete steps.
|
|
946
|
+
if (rigidBody.ccdEnabled) {
|
|
947
|
+
desc.setCcdEnabled(true);
|
|
948
|
+
}
|
|
949
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.createRigidBody
|
|
950
|
+
body = (this.raw as any).createRigidBody(desc);
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
// No default — rigidBodyTypeFromF32 ensures only 3 arms; TS guards completeness.
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
body.userData = entity;
|
|
957
|
+
this.registerBody(entity, body.handle);
|
|
958
|
+
|
|
959
|
+
// ── Create ColliderDesc ──
|
|
960
|
+
const scaleX = Math.abs(transform.scale.x);
|
|
961
|
+
const scaleY = Math.abs(transform.scale.y);
|
|
962
|
+
const scaleZ = Math.abs(transform.scale.z);
|
|
963
|
+
// Enable collision events + all body-type combinations so sensors register
|
|
964
|
+
// overlaps against kinematic/fixed bodies too (the default omits non-dynamic
|
|
965
|
+
// pairs, which would silence kinematic-sensor-vs-kinematic-body pickup).
|
|
966
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier enums from dynamic module
|
|
967
|
+
const activeEvents = (RAPIER as any).ActiveEvents.COLLISION_EVENTS as number;
|
|
968
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier enums from dynamic module
|
|
969
|
+
const activeCollisionTypes = (RAPIER as any).ActiveCollisionTypes.ALL as number;
|
|
970
|
+
const cShape = colliderShapeFromF32(collider.shape);
|
|
971
|
+
switch (cShape) {
|
|
972
|
+
case 'cuboid': {
|
|
973
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier ColliderDesc
|
|
974
|
+
const desc = (RAPIER as any).ColliderDesc.cuboid(
|
|
975
|
+
collider.halfExtents[0] * scaleX,
|
|
976
|
+
collider.halfExtents[1] * scaleY,
|
|
977
|
+
collider.halfExtents[2] * scaleZ,
|
|
978
|
+
)
|
|
979
|
+
.setFriction(collider.friction)
|
|
980
|
+
.setRestitution(collider.restitution)
|
|
981
|
+
.setDensity(collider.density)
|
|
982
|
+
.setCollisionGroups(collider.collisionGroups)
|
|
983
|
+
.setSolverGroups(collider.solverGroups)
|
|
984
|
+
.setActiveEvents(activeEvents)
|
|
985
|
+
.setActiveCollisionTypes(activeCollisionTypes);
|
|
986
|
+
if (collider.isSensor) desc.setSensor(true);
|
|
987
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCollider
|
|
988
|
+
(this.raw as any).createCollider(desc, body);
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
case 'sphere': {
|
|
992
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier ColliderDesc
|
|
993
|
+
const desc = (RAPIER as any).ColliderDesc.ball(
|
|
994
|
+
collider.radius * Math.max(scaleX, scaleY, scaleZ),
|
|
995
|
+
)
|
|
996
|
+
.setFriction(collider.friction)
|
|
997
|
+
.setRestitution(collider.restitution)
|
|
998
|
+
.setDensity(collider.density)
|
|
999
|
+
.setCollisionGroups(collider.collisionGroups)
|
|
1000
|
+
.setSolverGroups(collider.solverGroups)
|
|
1001
|
+
.setActiveEvents(activeEvents)
|
|
1002
|
+
.setActiveCollisionTypes(activeCollisionTypes);
|
|
1003
|
+
if (collider.isSensor) desc.setSensor(true);
|
|
1004
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCollider
|
|
1005
|
+
(this.raw as any).createCollider(desc, body);
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
case 'capsule': {
|
|
1009
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier ColliderDesc
|
|
1010
|
+
const desc = (RAPIER as any).ColliderDesc.capsule(
|
|
1011
|
+
collider.halfHeight * scaleY,
|
|
1012
|
+
collider.radius * Math.max(scaleX, scaleZ),
|
|
1013
|
+
)
|
|
1014
|
+
.setFriction(collider.friction)
|
|
1015
|
+
.setRestitution(collider.restitution)
|
|
1016
|
+
.setDensity(collider.density)
|
|
1017
|
+
.setCollisionGroups(collider.collisionGroups)
|
|
1018
|
+
.setSolverGroups(collider.solverGroups)
|
|
1019
|
+
.setActiveEvents(activeEvents)
|
|
1020
|
+
.setActiveCollisionTypes(activeCollisionTypes);
|
|
1021
|
+
if (collider.isSensor) desc.setSensor(true);
|
|
1022
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCollider
|
|
1023
|
+
(this.raw as any).createCollider(desc, body);
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
// No default — colliderShapeFromF32 ensures only 3 arms; TS guards completeness.
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Synchronize a static or kinematic body's Rapier pose and collider shape from
|
|
1032
|
+
* the resolved Transform pose. Dynamic bodies own their pose after creation.
|
|
1033
|
+
*/
|
|
1034
|
+
syncAuthoredPose(
|
|
1035
|
+
entity: number,
|
|
1036
|
+
transform: PhysicsTransform3D,
|
|
1037
|
+
collider: PhysicsCollider3D,
|
|
1038
|
+
bodyType: 'static' | 'kinematic',
|
|
1039
|
+
): void {
|
|
1040
|
+
this.assertActive('syncAuthoredPose');
|
|
1041
|
+
const record = this.entityMap.get(entity);
|
|
1042
|
+
if (!record) return;
|
|
1043
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
|
|
1044
|
+
const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
|
|
1045
|
+
if (!body) return;
|
|
1046
|
+
|
|
1047
|
+
if (bodyType === 'static') {
|
|
1048
|
+
body.setTranslation(transform.position, true);
|
|
1049
|
+
body.setRotation(transform.rotation, true);
|
|
1050
|
+
} else {
|
|
1051
|
+
body.setNextKinematicTranslation(transform.position);
|
|
1052
|
+
body.setNextKinematicRotation(transform.rotation);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const rapierCollider = body.collider(0);
|
|
1056
|
+
if (!rapierCollider) return;
|
|
1057
|
+
const scaleX = Math.abs(transform.scale.x);
|
|
1058
|
+
const scaleY = Math.abs(transform.scale.y);
|
|
1059
|
+
const scaleZ = Math.abs(transform.scale.z);
|
|
1060
|
+
switch (colliderShapeFromF32(collider.shape)) {
|
|
1061
|
+
case 'cuboid':
|
|
1062
|
+
rapierCollider.setHalfExtents({
|
|
1063
|
+
x: collider.halfExtents[0] * scaleX,
|
|
1064
|
+
y: collider.halfExtents[1] * scaleY,
|
|
1065
|
+
z: collider.halfExtents[2] * scaleZ,
|
|
1066
|
+
});
|
|
1067
|
+
break;
|
|
1068
|
+
case 'sphere':
|
|
1069
|
+
rapierCollider.setRadius(collider.radius * Math.max(scaleX, scaleY, scaleZ));
|
|
1070
|
+
break;
|
|
1071
|
+
case 'capsule':
|
|
1072
|
+
rapierCollider.setHalfHeight(collider.halfHeight * scaleY);
|
|
1073
|
+
rapierCollider.setRadius(collider.radius * Math.max(scaleX, scaleZ));
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ─── ECS integration helpers ───────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Register an ECS entity with its Rapier body handle.
|
|
1082
|
+
*/
|
|
1083
|
+
registerBody(entity: number, bodyHandle: number): void {
|
|
1084
|
+
this.entityMap.set(entity, { bodyHandle });
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Apply all pending teleports to their respective bodies.
|
|
1089
|
+
*/
|
|
1090
|
+
applyPendingTeleports(): void {
|
|
1091
|
+
for (const [entity, target] of this.pendingTeleports) {
|
|
1092
|
+
const record = this.entityMap.get(entity);
|
|
1093
|
+
if (!record) continue;
|
|
1094
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
|
|
1095
|
+
const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
|
|
1096
|
+
if (!body) continue;
|
|
1097
|
+
|
|
1098
|
+
body.setTranslation({ x: target.x, y: target.y, z: target.z }, true);
|
|
1099
|
+
body.setLinvel({ x: 0, y: 0, z: 0 }, false);
|
|
1100
|
+
body.setAngvel({ x: 0, y: 0, z: 0 }, false);
|
|
1101
|
+
}
|
|
1102
|
+
this.pendingTeleports.clear();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Set a kinematic body's next position from ECS transform.
|
|
1107
|
+
*/
|
|
1108
|
+
setKinematicPosition(entity: number, pos: { x: number; y: number; z: number }): void {
|
|
1109
|
+
const record = this.entityMap.get(entity);
|
|
1110
|
+
if (!record) return;
|
|
1111
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
|
|
1112
|
+
const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
|
|
1113
|
+
if (!body) return;
|
|
1114
|
+
body.setNextKinematicTranslation({ x: pos.x, y: pos.y, z: pos.z });
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Write Rapier dynamic body poses back.
|
|
1119
|
+
*/
|
|
1120
|
+
writebackDynamicBodies(): Array<{
|
|
1121
|
+
entity: number;
|
|
1122
|
+
pos: { x: number; y: number; z: number };
|
|
1123
|
+
rotation: { x: number; y: number; z: number; w: number };
|
|
1124
|
+
}> {
|
|
1125
|
+
const results: Array<{
|
|
1126
|
+
entity: number;
|
|
1127
|
+
pos: { x: number; y: number; z: number };
|
|
1128
|
+
rotation: { x: number; y: number; z: number; w: number };
|
|
1129
|
+
}> = [];
|
|
1130
|
+
for (const [entity, record] of this.entityMap) {
|
|
1131
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
|
|
1132
|
+
const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
|
|
1133
|
+
if (!body) continue;
|
|
1134
|
+
if (body.bodyType() !== this.rapierModule.RigidBodyType.Dynamic) continue;
|
|
1135
|
+
const translation = body.translation();
|
|
1136
|
+
const rotation = body.rotation();
|
|
1137
|
+
results.push({
|
|
1138
|
+
entity,
|
|
1139
|
+
pos: { x: translation.x, y: translation.y, z: translation.z },
|
|
1140
|
+
rotation: { x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w },
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return results;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Remove a Rapier body and its colliders when the ECS entity is despawned.
|
|
1148
|
+
*/
|
|
1149
|
+
removeEntity(entity: number): void {
|
|
1150
|
+
const record = this.entityMap.get(entity);
|
|
1151
|
+
if (!record) return;
|
|
1152
|
+
const ownPairs = [...(this.collisionPairs.get(entity) ?? [])];
|
|
1153
|
+
for (const other of ownPairs) {
|
|
1154
|
+
if (this.removePair(entity, other)) {
|
|
1155
|
+
this.pushCollisionEvent({ type: 'stopped', entityA: entity, entityB: other });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
this.removeKccController(entity); // D-3: clear cached KCC before body removal
|
|
1159
|
+
// biome-ignore lint/suspicious/noExplicitAny: Rapier World.removeRigidBody
|
|
1160
|
+
(this.raw as any).removeRigidBody({ handle: record.bodyHandle } as RapierRigidBody);
|
|
1161
|
+
this.entityMap.delete(entity);
|
|
1162
|
+
// Clear the despawned entity from every overlap set so a collected Core does
|
|
1163
|
+
// not linger in the player's CollidingEntities (Rapier emits no `stopped`
|
|
1164
|
+
// event when a collider is removed mid-overlap).
|
|
1165
|
+
const own = this.collisionPairs.get(entity);
|
|
1166
|
+
if (own) {
|
|
1167
|
+
for (const other of own) this.collisionPairs.get(other)?.delete(entity);
|
|
1168
|
+
this.collisionPairs.delete(entity);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Create a new RapierPhysicsWorld3D instance.
|
|
1175
|
+
*/
|
|
1176
|
+
export function createRapier3DPhysicsWorld(rapier: Rapier3DModule): RapierPhysicsWorld3D {
|
|
1177
|
+
return new RapierPhysicsWorld3D(rapier);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function hasReadableWorldPose(world: Float32Array | undefined): world is Float32Array {
|
|
1181
|
+
return world !== undefined && world.length >= 16;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** dt upper bound (plan-strategy D-4): skip step if dt exceeds this. */
|
|
1185
|
+
const PHYSICS_DT_MAX = 0.1;
|
|
1186
|
+
const poseScratchPosition = vec3.create();
|
|
1187
|
+
const poseScratchRotation = quat.create();
|
|
1188
|
+
const poseScratchScale = vec3.create();
|
|
1189
|
+
const poseScratchWorld = new Float32Array(16);
|
|
1190
|
+
|
|
1191
|
+
function physicsRowIsStatic(row: PhysicsSyncQueryRow): boolean {
|
|
1192
|
+
if (!row.has(RigidBody)) return true;
|
|
1193
|
+
const rigidBody = row.get(RigidBody) as { readonly type: number } | undefined;
|
|
1194
|
+
return rigidBody !== undefined && rigidBodyTypeFromF32(rigidBody.type) === 'static';
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function readPhysicsSyncDescriptor(
|
|
1198
|
+
row: PhysicsSyncQueryRow,
|
|
1199
|
+
transformComponent: Component,
|
|
1200
|
+
): PhysicsSyncDescriptor | undefined {
|
|
1201
|
+
const transformData = row.get(transformComponent) as
|
|
1202
|
+
| {
|
|
1203
|
+
readonly pos: Float32Array;
|
|
1204
|
+
readonly quat: Float32Array;
|
|
1205
|
+
readonly scale: Float32Array;
|
|
1206
|
+
readonly world?: Float32Array;
|
|
1207
|
+
}
|
|
1208
|
+
| undefined;
|
|
1209
|
+
const colliderData = row.get(Collider) as
|
|
1210
|
+
| {
|
|
1211
|
+
readonly shape: number;
|
|
1212
|
+
readonly halfExtents: Float32Array;
|
|
1213
|
+
readonly radius: number;
|
|
1214
|
+
readonly halfHeight: number;
|
|
1215
|
+
readonly friction: number;
|
|
1216
|
+
readonly restitution: number;
|
|
1217
|
+
readonly density: number;
|
|
1218
|
+
readonly isSensor: number | boolean;
|
|
1219
|
+
readonly collisionGroups: number;
|
|
1220
|
+
readonly solverGroups: number;
|
|
1221
|
+
}
|
|
1222
|
+
| undefined;
|
|
1223
|
+
if (transformData === undefined || colliderData === undefined) return undefined;
|
|
1224
|
+
|
|
1225
|
+
// Root-local TRS is already a world pose. A ChildOf row, however, must use
|
|
1226
|
+
// Scene's derived world matrix whenever that field is readable. Matrix
|
|
1227
|
+
// contents cannot be a validity sentinel: a legitimate parent/local
|
|
1228
|
+
// composition can resolve to identity. Direct/test rows that omit `world`
|
|
1229
|
+
// retain the authored-local fallback.
|
|
1230
|
+
const useWorldPose = row.has(ChildOf) && hasReadableWorldPose(transformData.world);
|
|
1231
|
+
if (useWorldPose) {
|
|
1232
|
+
poseScratchWorld.set(transformData.world.subarray(0, 16));
|
|
1233
|
+
mat4.decompose(poseScratchPosition, poseScratchRotation, poseScratchScale, poseScratchWorld);
|
|
1234
|
+
} else {
|
|
1235
|
+
poseScratchPosition[0] = transformData.pos[0] ?? 0;
|
|
1236
|
+
poseScratchPosition[1] = transformData.pos[1] ?? 0;
|
|
1237
|
+
poseScratchPosition[2] = transformData.pos[2] ?? 0;
|
|
1238
|
+
poseScratchRotation[0] = transformData.quat[0] ?? 0;
|
|
1239
|
+
poseScratchRotation[1] = transformData.quat[1] ?? 0;
|
|
1240
|
+
poseScratchRotation[2] = transformData.quat[2] ?? 0;
|
|
1241
|
+
poseScratchRotation[3] = transformData.quat[3] ?? 1;
|
|
1242
|
+
poseScratchScale[0] = transformData.scale[0] ?? 1;
|
|
1243
|
+
poseScratchScale[1] = transformData.scale[1] ?? 1;
|
|
1244
|
+
poseScratchScale[2] = transformData.scale[2] ?? 1;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const rigidBodyData = row.has(RigidBody)
|
|
1248
|
+
? (row.get(RigidBody) as
|
|
1249
|
+
| {
|
|
1250
|
+
readonly type: number;
|
|
1251
|
+
readonly mass: number;
|
|
1252
|
+
readonly linearDamping: number;
|
|
1253
|
+
readonly angularDamping: number;
|
|
1254
|
+
readonly gravityScale: number;
|
|
1255
|
+
readonly ccdEnabled: number | boolean;
|
|
1256
|
+
}
|
|
1257
|
+
| undefined)
|
|
1258
|
+
: undefined;
|
|
1259
|
+
const characterControllerData = row.has(CharacterController)
|
|
1260
|
+
? (row.get(CharacterController) as { readonly offset: number } | undefined)
|
|
1261
|
+
: undefined;
|
|
1262
|
+
|
|
1263
|
+
return {
|
|
1264
|
+
entity: row.entity,
|
|
1265
|
+
transform: {
|
|
1266
|
+
position: {
|
|
1267
|
+
x: poseScratchPosition[0] ?? 0,
|
|
1268
|
+
y: poseScratchPosition[1] ?? 0,
|
|
1269
|
+
z: poseScratchPosition[2] ?? 0,
|
|
1270
|
+
},
|
|
1271
|
+
rotation: {
|
|
1272
|
+
x: poseScratchRotation[0] ?? 0,
|
|
1273
|
+
y: poseScratchRotation[1] ?? 0,
|
|
1274
|
+
z: poseScratchRotation[2] ?? 0,
|
|
1275
|
+
w: poseScratchRotation[3] ?? 1,
|
|
1276
|
+
},
|
|
1277
|
+
scale: {
|
|
1278
|
+
x: poseScratchScale[0] ?? 1,
|
|
1279
|
+
y: poseScratchScale[1] ?? 1,
|
|
1280
|
+
z: poseScratchScale[2] ?? 1,
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
rigidBody:
|
|
1284
|
+
rigidBodyData === undefined
|
|
1285
|
+
? {
|
|
1286
|
+
type: RIGID_BODY_TYPE_STATIC,
|
|
1287
|
+
mass: 0,
|
|
1288
|
+
linearDamping: 0,
|
|
1289
|
+
angularDamping: 0,
|
|
1290
|
+
gravityScale: 1,
|
|
1291
|
+
ccdEnabled: 0,
|
|
1292
|
+
}
|
|
1293
|
+
: {
|
|
1294
|
+
type: rigidBodyData.type,
|
|
1295
|
+
mass: rigidBodyData.mass,
|
|
1296
|
+
linearDamping: rigidBodyData.linearDamping,
|
|
1297
|
+
angularDamping: rigidBodyData.angularDamping,
|
|
1298
|
+
gravityScale: rigidBodyData.gravityScale,
|
|
1299
|
+
ccdEnabled: Number(rigidBodyData.ccdEnabled),
|
|
1300
|
+
},
|
|
1301
|
+
collider: {
|
|
1302
|
+
shape: colliderData.shape,
|
|
1303
|
+
halfExtents: [
|
|
1304
|
+
colliderData.halfExtents[0] ?? 0,
|
|
1305
|
+
colliderData.halfExtents[1] ?? 0,
|
|
1306
|
+
colliderData.halfExtents[2] ?? 0,
|
|
1307
|
+
],
|
|
1308
|
+
radius: colliderData.radius,
|
|
1309
|
+
halfHeight: colliderData.halfHeight,
|
|
1310
|
+
friction: colliderData.friction,
|
|
1311
|
+
restitution: colliderData.restitution,
|
|
1312
|
+
density: colliderData.density,
|
|
1313
|
+
isSensor: Number(colliderData.isSensor),
|
|
1314
|
+
collisionGroups: colliderData.collisionGroups,
|
|
1315
|
+
solverGroups: colliderData.solverGroups,
|
|
1316
|
+
},
|
|
1317
|
+
hasCharacterController: row.has(CharacterController),
|
|
1318
|
+
characterControllerOffset: characterControllerData?.offset,
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// ── System name constants ──
|
|
1323
|
+
const PHYSICS_SYNC_BACKEND = 'physicsSyncBackend' as const;
|
|
1324
|
+
const PHYSICS_STEP_SIMULATION = 'physicsStepSimulation' as const;
|
|
1325
|
+
const PHYSICS_WRITEBACK = 'physicsWriteback' as const;
|
|
1326
|
+
const PHYSICS_COLLISION_SYNC = 'physicsCollisionSync' as const;
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Resolve the runtime `Transform` component token from the World-local ECS
|
|
1330
|
+
* registry (M2 — full resource-ification, D-3). physics already depends on
|
|
1331
|
+
* `@forgeax/engine-ecs`, so the catalog introduces no new dependency
|
|
1332
|
+
* and replaces the closure-captured `transformComponent` second parameter.
|
|
1333
|
+
* Returns `undefined` when Transform is not yet defined (the runtime package
|
|
1334
|
+
* defines it on import); callers early-out.
|
|
1335
|
+
*/
|
|
1336
|
+
function resolveTransform(world: World): Component | undefined {
|
|
1337
|
+
return world.components.resolve('Transform');
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* `physicsSyncBackend` system token (M2 — full resource-ification, D-4).
|
|
1342
|
+
*
|
|
1343
|
+
* After propagateTransforms, bootstrap/recovery performs one complete Collider
|
|
1344
|
+
* reconcile. Warm ticks poll the existing ECS projection and identity-read only
|
|
1345
|
+
* final changed rows. Bare Colliders remain implicit static bodies; Transform-less
|
|
1346
|
+
* fixed bodies are retained only as a migration defense and are never created.
|
|
1347
|
+
* Reads `world` from its first parameter; resolves Transform via the global
|
|
1348
|
+
* registry. Labelled `'physics'`.
|
|
1349
|
+
*/
|
|
1350
|
+
export const PhysicsSyncBackend: SystemHandle<readonly []> = defineSystem({
|
|
1351
|
+
name: PHYSICS_SYNC_BACKEND,
|
|
1352
|
+
queries: [],
|
|
1353
|
+
after: ['propagateTransformsFixed'],
|
|
1354
|
+
fn: (world) => {
|
|
1355
|
+
const transformComponent = resolveTransform(world);
|
|
1356
|
+
if (transformComponent === undefined) return;
|
|
1357
|
+
let pw: RapierPhysicsWorld3D;
|
|
1358
|
+
try {
|
|
1359
|
+
pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
|
|
1360
|
+
} catch {
|
|
1361
|
+
return; // C-2: PhysicsWorld resource not yet ready — safe early out
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
pw.applyPendingTeleports();
|
|
1365
|
+
pw._syncFromEcs(world, transformComponent);
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* `physicsStepSimulation` system token (M2 — full resource-ification, D-4).
|
|
1371
|
+
*
|
|
1372
|
+
* After physicsSyncBackend — read FixedTime.delta and call pw.step() with dt-gating.
|
|
1373
|
+
*/
|
|
1374
|
+
export const PhysicsStepSimulation: SystemHandle<readonly []> = defineSystem({
|
|
1375
|
+
name: PHYSICS_STEP_SIMULATION,
|
|
1376
|
+
queries: [],
|
|
1377
|
+
after: [PHYSICS_SYNC_BACKEND],
|
|
1378
|
+
fn: (world) => {
|
|
1379
|
+
let pw: RapierPhysicsWorld3D;
|
|
1380
|
+
try {
|
|
1381
|
+
pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
|
|
1382
|
+
} catch {
|
|
1383
|
+
return; // C-2: safe early out
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const dt = world.getResource(FixedTime).delta;
|
|
1387
|
+
if (dt <= 0 || dt > PHYSICS_DT_MAX) return; // D-4: skip abnormal delta
|
|
1388
|
+
|
|
1389
|
+
pw.step(dt);
|
|
1390
|
+
},
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* `physicsWriteback` system token (M2 — full resource-ification, D-4).
|
|
1395
|
+
*
|
|
1396
|
+
* After physicsStepSimulation — call pw.writebackDynamicBodies() and write
|
|
1397
|
+
* positions back to ECS Transform (resolved via the global registry, D-3).
|
|
1398
|
+
*/
|
|
1399
|
+
export const PhysicsWriteback: SystemHandle<readonly []> = defineSystem({
|
|
1400
|
+
name: PHYSICS_WRITEBACK,
|
|
1401
|
+
queries: [],
|
|
1402
|
+
after: [PHYSICS_STEP_SIMULATION],
|
|
1403
|
+
fn: (world) => {
|
|
1404
|
+
const transformComponent = resolveTransform(world);
|
|
1405
|
+
if (transformComponent === undefined) return;
|
|
1406
|
+
let pw: RapierPhysicsWorld3D;
|
|
1407
|
+
try {
|
|
1408
|
+
pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
|
|
1409
|
+
} catch {
|
|
1410
|
+
return; // C-2: safe early out
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const results = pw.writebackDynamicBodies();
|
|
1414
|
+
for (const r of results) {
|
|
1415
|
+
const entity = r.entity as EntityHandle;
|
|
1416
|
+
world.set(entity, transformComponent, {
|
|
1417
|
+
pos: [r.pos.x, r.pos.y, r.pos.z],
|
|
1418
|
+
quat: [r.rotation.x, r.rotation.y, r.rotation.z, r.rotation.w],
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* `physicsCollisionSync` system token — writes the drained overlap set into each
|
|
1426
|
+
* entity's `CollidingEntities` component (the contact/sensor set-query path).
|
|
1427
|
+
*
|
|
1428
|
+
* Runs after writeback so the component reflects this step's contacts. Without
|
|
1429
|
+
* it the `CollidingEntities` component documented in the physics README never
|
|
1430
|
+
* updates (the event queue was drained-on-overflow only), so sensor pickup +
|
|
1431
|
+
* proximity queries silently saw an empty set.
|
|
1432
|
+
*/
|
|
1433
|
+
export const PhysicsCollisionSync: SystemHandle<readonly []> = defineSystem({
|
|
1434
|
+
name: PHYSICS_COLLISION_SYNC,
|
|
1435
|
+
queries: [],
|
|
1436
|
+
after: [PHYSICS_WRITEBACK],
|
|
1437
|
+
fn: (world) => {
|
|
1438
|
+
let pw: RapierPhysicsWorld3D;
|
|
1439
|
+
try {
|
|
1440
|
+
pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
|
|
1441
|
+
} catch {
|
|
1442
|
+
return; // C-2: safe early out
|
|
1443
|
+
}
|
|
1444
|
+
pw.writebackCollidingEntities(world, CollidingEntities as unknown as Component);
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Register the physics tick systems into an ECS World.
|
|
1450
|
+
*
|
|
1451
|
+
* The systems ({@link PhysicsSyncBackend} / {@link PhysicsStepSimulation} /
|
|
1452
|
+
* {@link PhysicsWriteback} / {@link PhysicsCollisionSync}) are module-level
|
|
1453
|
+
* `defineSystem` tokens; this helper wires the moveAndSlide context + despawn
|
|
1454
|
+
* cleanup hook, then adds the tokens to the schedule.
|
|
1455
|
+
*
|
|
1456
|
+
* Transform is resolved from the World-local ECS component catalog,
|
|
1457
|
+
* D-3) — the previous `transformComponent` second parameter was redundant once
|
|
1458
|
+
* the system fns and moveContext resolve Transform themselves, so it is gone.
|
|
1459
|
+
*
|
|
1460
|
+
* @param world ECS World instance.
|
|
1461
|
+
*/
|
|
1462
|
+
export function registerPhysicsSystems(world: World): () => void {
|
|
1463
|
+
const releaseComponents = registerPhysicsComponents(world);
|
|
1464
|
+
// ── moveAndSlide context + despawn cleanup wiring (D-1/D-3) ──
|
|
1465
|
+
// Wire the World + Transform/CharacterController components into the backend
|
|
1466
|
+
// so moveAndSlide can read tuning and write pose/grounded back, and register
|
|
1467
|
+
// the backend for the global Collider.onRemove dispatch (despawn cleanup).
|
|
1468
|
+
const transformComponent = resolveTransform(world);
|
|
1469
|
+
try {
|
|
1470
|
+
const pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
|
|
1471
|
+
if (transformComponent !== undefined) {
|
|
1472
|
+
pw.setMoveContext(world, transformComponent, CharacterController);
|
|
1473
|
+
}
|
|
1474
|
+
} catch {
|
|
1475
|
+
// PhysicsWorld resource not yet inserted — moveAndSlide falls back to
|
|
1476
|
+
// CharacterController schema defaults until a later registration wires it.
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
world
|
|
1480
|
+
.addSystems(FixedUpdate, PhysicsSet, [
|
|
1481
|
+
PhysicsSyncBackend,
|
|
1482
|
+
PhysicsStepSimulation,
|
|
1483
|
+
PhysicsWriteback,
|
|
1484
|
+
PhysicsCollisionSync,
|
|
1485
|
+
])
|
|
1486
|
+
.unwrap();
|
|
1487
|
+
return () => {
|
|
1488
|
+
world.removeSystem(FixedUpdate, PHYSICS_COLLISION_SYNC);
|
|
1489
|
+
world.removeSystem(FixedUpdate, PHYSICS_WRITEBACK);
|
|
1490
|
+
world.removeSystem(FixedUpdate, PHYSICS_STEP_SIMULATION);
|
|
1491
|
+
world.removeSystem(FixedUpdate, PHYSICS_SYNC_BACKEND);
|
|
1492
|
+
try {
|
|
1493
|
+
world.getResource<RapierPhysicsWorld3D>('PhysicsWorld').clearEcsContext(world);
|
|
1494
|
+
} catch {
|
|
1495
|
+
// PhysicsWorld may already have been removed as part of outer teardown.
|
|
1496
|
+
}
|
|
1497
|
+
releaseComponents();
|
|
1498
|
+
};
|
|
1499
|
+
}
|