@gg-web-engine/rapier3d 0.0.66 → 0.0.68
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/components/rapier-3d-rigid-body.component.d.ts +29 -1
- package/dist/components/rapier-3d-rigid-body.component.js +49 -0
- package/dist/components/rapier-3d-trigger.component.d.ts +21 -0
- package/dist/components/rapier-3d-trigger.component.js +36 -20
- package/dist/components/rapier-3d-world.component.d.ts +41 -1
- package/dist/components/rapier-3d-world.component.js +130 -0
- package/dist/rapier-3d-factory.js +8 -1
- package/package.json +3 -3
- package/test/components/rapier-3d-object-grab-integration.spec.ts +147 -0
- package/test/components/rapier-3d-player-character-controller-integration.spec.ts +10 -1
- package/test/components/rapier-3d-rigid-body-collision.spec.ts +185 -0
- package/test/components/rapier-3d-trigger.component.spec.ts +68 -33
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Body3DOptions, CollisionGroup, DebugBody3DSettings, Entity3d, IRigidBody3dComponent, Point3, Point4, Shape3DDescriptor } from '@gg-web-engine/core';
|
|
1
|
+
import { Body3DOptions, CollisionEvent, CollisionGroup, DebugBody3DSettings, Entity3d, IRigidBody3dComponent, Point3, Point4, Shape3DDescriptor } from '@gg-web-engine/core';
|
|
2
2
|
import { Collider, ColliderDesc, InteractionGroups, RigidBody, RigidBodyDesc } from '@dimforge/rapier3d-compat';
|
|
3
|
+
import { Observable, Subject } from 'rxjs';
|
|
3
4
|
import { Rapier3dWorldComponent } from './rapier-3d-world.component';
|
|
4
5
|
import { Rapier3dGgWorld, Rapier3dPhysicsTypeDocRepo } from '../types';
|
|
5
6
|
export declare class Rapier3dRigidBodyComponent implements IRigidBody3dComponent<Rapier3dPhysicsTypeDocRepo> {
|
|
@@ -23,6 +24,33 @@ export declare class Rapier3dRigidBodyComponent implements IRigidBody3dComponent
|
|
|
23
24
|
get nativeBody(): RigidBody | null;
|
|
24
25
|
set nativeBody(value: RigidBody | null);
|
|
25
26
|
name: string;
|
|
27
|
+
/**
|
|
28
|
+
* Other rigid-body components this one is currently touching via a real (non-sensor) contact -
|
|
29
|
+
* mirrors `Rapier3dTriggerComponent.overlaps`, but symmetric: both sides of an ordinary collision
|
|
30
|
+
* get notified, so both sides track it. Populated/drained by `Rapier3dWorldComponent`'s centralized
|
|
31
|
+
* collision-event dispatch (see `notifyCollisionStart`/`notifyCollisionEnd` below), and consulted by
|
|
32
|
+
* `removeFromWorld` to emit `onCollisionEnd(null)` to any partner still touching this body at the
|
|
33
|
+
* moment it's removed (per `CollisionEvent`'s "null when the other body was removed from the world
|
|
34
|
+
* while still in contact" convention) - Rapier does not reliably emit a native collision-stop event
|
|
35
|
+
* for a collider that's simply deleted mid-contact (same reason `Rapier3dTriggerComponent.
|
|
36
|
+
* checkOverlaps` has its own manual `!body.nativeBody` cleanup pass instead of trusting the event
|
|
37
|
+
* queue for that case).
|
|
38
|
+
*/
|
|
39
|
+
readonly collidingWith: Set<Rapier3dRigidBodyComponent>;
|
|
40
|
+
protected readonly onCollisionStart$: Subject<CollisionEvent<Point3, Rapier3dRigidBodyComponent>>;
|
|
41
|
+
protected readonly onCollisionEnd$: Subject<Rapier3dRigidBodyComponent | null>;
|
|
42
|
+
get onCollisionStart(): Observable<CollisionEvent<Point3, Rapier3dRigidBodyComponent>>;
|
|
43
|
+
get onCollisionEnd(): Observable<Rapier3dRigidBodyComponent | null>;
|
|
44
|
+
/**
|
|
45
|
+
* Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
|
|
46
|
+
* `Rapier3dWorldComponent.simulate`) - not meant to be called by app code directly. Kept `public`
|
|
47
|
+
* (rather than some cross-class-accessible `protected`) purely because the dispatching class isn't
|
|
48
|
+
* a subclass of this one; there's nothing else in this package it's meant to be called from.
|
|
49
|
+
*/
|
|
50
|
+
notifyCollisionStart(event: CollisionEvent<Point3, Rapier3dRigidBodyComponent>): void;
|
|
51
|
+
/** See `notifyCollisionStart`'s doc. `otherBody: null` signals the partner was removed from the
|
|
52
|
+
* world while still in contact, per `onCollisionEnd`'s doc. */
|
|
53
|
+
notifyCollisionEnd(otherBody: Rapier3dRigidBodyComponent | null): void;
|
|
26
54
|
get factoryProps(): [
|
|
27
55
|
ColliderDesc[],
|
|
28
56
|
Shape3DDescriptor,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BitMask, DebugBody3DSettings, Pnt3, Qtrn, } from '@gg-web-engine/core';
|
|
2
2
|
import { ColliderDesc, Quaternion, RigidBodyDesc, Vector3, } from '@dimforge/rapier3d-compat';
|
|
3
|
+
import { Subject } from 'rxjs';
|
|
3
4
|
export class Rapier3dRigidBodyComponent {
|
|
4
5
|
get position() {
|
|
5
6
|
return Pnt3.clone(this.nativeBody ? this.nativeBody.translation() : this._bodyDescr.translation);
|
|
@@ -52,6 +53,30 @@ export class Rapier3dRigidBodyComponent {
|
|
|
52
53
|
}
|
|
53
54
|
this._nativeBody = value;
|
|
54
55
|
}
|
|
56
|
+
get onCollisionStart() {
|
|
57
|
+
return this.onCollisionStart$.asObservable();
|
|
58
|
+
}
|
|
59
|
+
get onCollisionEnd() {
|
|
60
|
+
return this.onCollisionEnd$.asObservable();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
|
|
64
|
+
* `Rapier3dWorldComponent.simulate`) - not meant to be called by app code directly. Kept `public`
|
|
65
|
+
* (rather than some cross-class-accessible `protected`) purely because the dispatching class isn't
|
|
66
|
+
* a subclass of this one; there's nothing else in this package it's meant to be called from.
|
|
67
|
+
*/
|
|
68
|
+
notifyCollisionStart(event) {
|
|
69
|
+
this.collidingWith.add(event.otherBody);
|
|
70
|
+
this.onCollisionStart$.next(event);
|
|
71
|
+
}
|
|
72
|
+
/** See `notifyCollisionStart`'s doc. `otherBody: null` signals the partner was removed from the
|
|
73
|
+
* world while still in contact, per `onCollisionEnd`'s doc. */
|
|
74
|
+
notifyCollisionEnd(otherBody) {
|
|
75
|
+
if (otherBody) {
|
|
76
|
+
this.collidingWith.delete(otherBody);
|
|
77
|
+
}
|
|
78
|
+
this.onCollisionEnd$.next(otherBody);
|
|
79
|
+
}
|
|
55
80
|
get factoryProps() {
|
|
56
81
|
const colliderDescr = this._colliderDescr.map(cd => {
|
|
57
82
|
const d = new ColliderDesc(cd.shape);
|
|
@@ -84,6 +109,21 @@ export class Rapier3dRigidBodyComponent {
|
|
|
84
109
|
this._nativeBody = null;
|
|
85
110
|
this._nativeBodyColliders = null;
|
|
86
111
|
this.name = '';
|
|
112
|
+
/**
|
|
113
|
+
* Other rigid-body components this one is currently touching via a real (non-sensor) contact -
|
|
114
|
+
* mirrors `Rapier3dTriggerComponent.overlaps`, but symmetric: both sides of an ordinary collision
|
|
115
|
+
* get notified, so both sides track it. Populated/drained by `Rapier3dWorldComponent`'s centralized
|
|
116
|
+
* collision-event dispatch (see `notifyCollisionStart`/`notifyCollisionEnd` below), and consulted by
|
|
117
|
+
* `removeFromWorld` to emit `onCollisionEnd(null)` to any partner still touching this body at the
|
|
118
|
+
* moment it's removed (per `CollisionEvent`'s "null when the other body was removed from the world
|
|
119
|
+
* while still in contact" convention) - Rapier does not reliably emit a native collision-stop event
|
|
120
|
+
* for a collider that's simply deleted mid-contact (same reason `Rapier3dTriggerComponent.
|
|
121
|
+
* checkOverlaps` has its own manual `!body.nativeBody` cleanup pass instead of trusting the event
|
|
122
|
+
* queue for that case).
|
|
123
|
+
*/
|
|
124
|
+
this.collidingWith = new Set();
|
|
125
|
+
this.onCollisionStart$ = new Subject();
|
|
126
|
+
this.onCollisionEnd$ = new Subject();
|
|
87
127
|
this.collisionGroups = BitMask.full(32);
|
|
88
128
|
this.ownCollisionGroups = (_colliderOptions === null || _colliderOptions === void 0 ? void 0 : _colliderOptions.ownCollisionGroups) || [world.mainCollisionGroup];
|
|
89
129
|
this.interactWithCollisionGroups = (_colliderOptions === null || _colliderOptions === void 0 ? void 0 : _colliderOptions.interactWithCollisionGroups) || [world.mainCollisionGroup];
|
|
@@ -156,6 +196,13 @@ export class Rapier3dRigidBodyComponent {
|
|
|
156
196
|
if (world.physicsWorld != this.world) {
|
|
157
197
|
throw new Error('Rapier3D bodies cannot be shared between different worlds');
|
|
158
198
|
}
|
|
199
|
+
// notify every body still touching this one that the contact ended because *this* body vanished
|
|
200
|
+
// (not because they physically separated) - see `collidingWith`'s doc.
|
|
201
|
+
for (const other of this.collidingWith) {
|
|
202
|
+
other.collidingWith.delete(this);
|
|
203
|
+
other.notifyCollisionEnd(null);
|
|
204
|
+
}
|
|
205
|
+
this.collidingWith.clear();
|
|
159
206
|
if (this._nativeBody) {
|
|
160
207
|
for (const col of this._nativeBodyColliders) {
|
|
161
208
|
this.world.nativeWorld.removeCollider(col, false);
|
|
@@ -175,5 +222,7 @@ export class Rapier3dRigidBodyComponent {
|
|
|
175
222
|
if (this.nativeBody) {
|
|
176
223
|
this.removeFromWorld({ physicsWorld: this.world }, true);
|
|
177
224
|
}
|
|
225
|
+
this.onCollisionStart$.complete();
|
|
226
|
+
this.onCollisionEnd$.complete();
|
|
178
227
|
}
|
|
179
228
|
}
|
|
@@ -18,7 +18,28 @@ export declare class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent
|
|
|
18
18
|
constructor(world: Rapier3dWorldComponent, _colliderDescr: ColliderDesc[], shape: Shape3DDescriptor, _bodyDescr: RigidBodyDesc);
|
|
19
19
|
addToWorld(world: Rapier3dGgWorld): void;
|
|
20
20
|
removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
|
|
21
|
+
/**
|
|
22
|
+
* Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
|
|
23
|
+
* `Rapier3dWorldComponent.simulate`) once per drained sensor-intersection event involving this
|
|
24
|
+
* trigger - not meant to be called by app code directly. Previously this trigger drained
|
|
25
|
+
* `world.eventQueue` itself from inside `checkOverlaps()`, matching `otherBody` by comparing an
|
|
26
|
+
* event's *collider* handle against `this.nativeBody?.handle` (a *rigid-body* handle) - those are
|
|
27
|
+
* two different handle namespaces in this pinned `@dimforge/rapier3d-compat` build, and the
|
|
28
|
+
* comparison only ever happened to work by coincidence (a body's first/only collider is allocated
|
|
29
|
+
* from a separate arena that, absent any prior removals, marches in lockstep with the rigid-body
|
|
30
|
+
* arena for the common one-collider-per-body case - see `gg-engine-physics-adapter-rapier` for the
|
|
31
|
+
* full incident). `EventQueue.drainCollisionEvents` also fully drains the *shared* queue on every
|
|
32
|
+
* call, so more than one consumer draining it independently (this trigger, another trigger, and now
|
|
33
|
+
* `Rapier3dRigidBodyComponent`'s own collision events) would silently steal each other's events -
|
|
34
|
+
* `Rapier3dWorldComponent` is now the single place that drains it, resolving collider handles to
|
|
35
|
+
* components correctly via `Collider.parent()`, and pushes matching events to whichever
|
|
36
|
+
* component(s) care.
|
|
37
|
+
*/
|
|
38
|
+
notifyOverlap(otherBody: Rapier3dRigidBodyComponent, started: boolean): void;
|
|
21
39
|
checkOverlaps(): void;
|
|
22
40
|
clone(): Rapier3dTriggerComponent;
|
|
41
|
+
/** Completes `onEnter$`/`onLeft$` on top of `Rapier3dRigidBodyComponent.dispose()`'s own
|
|
42
|
+
* `onCollisionStart$`/`onCollisionEnd$` completion (via `super.dispose()`) - this trigger's own
|
|
43
|
+
* subjects have no other owner to complete them. */
|
|
23
44
|
dispose(): void;
|
|
24
45
|
}
|
|
@@ -36,27 +36,38 @@ export class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent {
|
|
|
36
36
|
this.overlaps.clear();
|
|
37
37
|
super.removeFromWorld(world, dispose);
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
|
|
41
|
+
* `Rapier3dWorldComponent.simulate`) once per drained sensor-intersection event involving this
|
|
42
|
+
* trigger - not meant to be called by app code directly. Previously this trigger drained
|
|
43
|
+
* `world.eventQueue` itself from inside `checkOverlaps()`, matching `otherBody` by comparing an
|
|
44
|
+
* event's *collider* handle against `this.nativeBody?.handle` (a *rigid-body* handle) - those are
|
|
45
|
+
* two different handle namespaces in this pinned `@dimforge/rapier3d-compat` build, and the
|
|
46
|
+
* comparison only ever happened to work by coincidence (a body's first/only collider is allocated
|
|
47
|
+
* from a separate arena that, absent any prior removals, marches in lockstep with the rigid-body
|
|
48
|
+
* arena for the common one-collider-per-body case - see `gg-engine-physics-adapter-rapier` for the
|
|
49
|
+
* full incident). `EventQueue.drainCollisionEvents` also fully drains the *shared* queue on every
|
|
50
|
+
* call, so more than one consumer draining it independently (this trigger, another trigger, and now
|
|
51
|
+
* `Rapier3dRigidBodyComponent`'s own collision events) would silently steal each other's events -
|
|
52
|
+
* `Rapier3dWorldComponent` is now the single place that drains it, resolving collider handles to
|
|
53
|
+
* components correctly via `Collider.parent()`, and pushes matching events to whichever
|
|
54
|
+
* component(s) care.
|
|
55
|
+
*/
|
|
56
|
+
notifyOverlap(otherBody, started) {
|
|
57
|
+
if (started) {
|
|
58
|
+
this.overlaps.add(otherBody);
|
|
59
|
+
this.onEnter$.next(otherBody);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
this.overlaps.delete(otherBody);
|
|
63
|
+
this.onLeft$.next(otherBody);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
39
66
|
checkOverlaps() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
otherBody = this.world.handleIdEntityMap.get(h2);
|
|
45
|
-
}
|
|
46
|
-
else if (h2 === ((_b = this.nativeBody) === null || _b === void 0 ? void 0 : _b.handle)) {
|
|
47
|
-
otherBody = this.world.handleIdEntityMap.get(h1);
|
|
48
|
-
}
|
|
49
|
-
if (!otherBody)
|
|
50
|
-
return;
|
|
51
|
-
if (started) {
|
|
52
|
-
this.overlaps.add(otherBody);
|
|
53
|
-
this.onEnter$.next(otherBody);
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
this.overlaps.delete(otherBody);
|
|
57
|
-
this.onLeft$.next(otherBody);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
67
|
+
// Rapier does not reliably emit a native intersection-stop event for a collider that's simply
|
|
68
|
+
// removed from the world mid-overlap (confirmed empirically) - this manual pass catches that case
|
|
69
|
+
// every tick regardless of whether `Rapier3dWorldComponent.simulate`'s own event dispatch already
|
|
70
|
+
// ran this tick.
|
|
60
71
|
for (const body of this.overlaps.keys()) {
|
|
61
72
|
if (!body.nativeBody) {
|
|
62
73
|
this.overlaps.delete(body);
|
|
@@ -71,8 +82,13 @@ export class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent {
|
|
|
71
82
|
component.interactWithCollisionGroups = this.interactWithCollisionGroups;
|
|
72
83
|
return component;
|
|
73
84
|
}
|
|
85
|
+
/** Completes `onEnter$`/`onLeft$` on top of `Rapier3dRigidBodyComponent.dispose()`'s own
|
|
86
|
+
* `onCollisionStart$`/`onCollisionEnd$` completion (via `super.dispose()`) - this trigger's own
|
|
87
|
+
* subjects have no other owner to complete them. */
|
|
74
88
|
dispose() {
|
|
75
89
|
this.overlaps.clear();
|
|
90
|
+
this.onEnter$.complete();
|
|
91
|
+
this.onLeft$.complete();
|
|
76
92
|
super.dispose();
|
|
77
93
|
}
|
|
78
94
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CollisionGroup, IPhysicsWorld3dComponent, Point3, RaycastOptions, RaycastResult } from '@gg-web-engine/core';
|
|
2
|
-
import { EventQueue, World } from '@dimforge/rapier3d-compat';
|
|
2
|
+
import { Collider, EventQueue, World } from '@dimforge/rapier3d-compat';
|
|
3
3
|
import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
|
|
4
4
|
import { Rapier3dCharacterControllerComponent } from './rapier-3d-character-controller.component';
|
|
5
5
|
import { Rapier3dFactory } from '../rapier-3d-factory';
|
|
@@ -27,6 +27,46 @@ export declare class Rapier3dWorldComponent implements IPhysicsWorld3dComponent<
|
|
|
27
27
|
constructor();
|
|
28
28
|
init(): Promise<void>;
|
|
29
29
|
simulate(delta: number): void;
|
|
30
|
+
/**
|
|
31
|
+
* Drains `eventQueue` exactly once per `simulate()` call and routes each entry to whichever
|
|
32
|
+
* component(s) care - this is the *only* place `drainCollisionEvents` is called for the whole
|
|
33
|
+
* world (see `Rapier3dTriggerComponent.notifyOverlap`'s doc for why a second/independent drain
|
|
34
|
+
* elsewhere would silently steal events from this one). A collider pair with sensor
|
|
35
|
+
* semantics (either side `isSensor()`) is routed as a trigger overlap; an ordinary pair is routed
|
|
36
|
+
* as a real rigid-body collision.
|
|
37
|
+
*
|
|
38
|
+
* `EventQueue.drainCollisionEvents` reports *collider* handles, not rigid-body handles -
|
|
39
|
+
* `handleIdEntityMap` is keyed by rigid-body handle (see `addToWorld`), so each handle is resolved
|
|
40
|
+
* via `World.getCollider(handle)` (returns `null` for a since-removed collider, not a throw - safe
|
|
41
|
+
* to just skip) then `Collider.parent()` to reach the owning `RigidBody` before the map lookup.
|
|
42
|
+
*/
|
|
43
|
+
protected dispatchCollisionEvents(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Reads world-space contact position/normal/impulse for a just-started contact between two
|
|
46
|
+
* non-sensor colliders via `World.contactPair`'s `TempContactManifold`. Returns `null` only if the
|
|
47
|
+
* pair has no manifold at all (shouldn't normally happen for a pair `drainCollisionEvents` just
|
|
48
|
+
* reported as newly touching, but guarded defensively). `World.contactPair`'s callback can in
|
|
49
|
+
* principle be invoked once per manifold between the pair - for the compound/multi-collider shapes
|
|
50
|
+
* this can matter for, each sub-collider pair already gets its own separate `drainCollisionEvents`
|
|
51
|
+
* entry (and thus its own `computeContactGeometry` call) since collision events are per-*collider*,
|
|
52
|
+
* not per-body, so in practice a single call here only ever sees one manifold; only the first
|
|
53
|
+
* encountered is used regardless. `impulse` sums `contactImpulse(i)` across every contact point of
|
|
54
|
+
* that manifold (an already-solved *impulse*, not a force estimate - the pinned
|
|
55
|
+
* `@dimforge/rapier3d-compat` build's constraint solver runs within the same `World.step()` call
|
|
56
|
+
* that produced this `started` event, so the manifold's impulses are already up to date by the time
|
|
57
|
+
* this runs). `normal` is returned oriented "away from `collider1` towards `collider2`" - Rapier's
|
|
58
|
+
* `contactPair(a, b, f)` reports whether its own internally-cached manifold order matches the
|
|
59
|
+
* queried `(a, b)` order via the callback's `flipped` flag; when `flipped` is `true` the manifold's
|
|
60
|
+
* `normal()` (always world-space, always pointing from the manifold's own shape1 to shape2) actually
|
|
61
|
+
* points from `collider2` to `collider1` and must be negated to match this method's documented
|
|
62
|
+
* orientation - verified empirically against a ball resting on a floor below it (see
|
|
63
|
+
* `gg-engine-physics-adapter-rapier` for the exact reproduction).
|
|
64
|
+
*/
|
|
65
|
+
protected computeContactGeometry(collider1: Collider, collider2: Collider): {
|
|
66
|
+
position: Point3;
|
|
67
|
+
normal: Point3;
|
|
68
|
+
impulse: number;
|
|
69
|
+
} | null;
|
|
30
70
|
protected lockedCollisionGroups: number[];
|
|
31
71
|
registerCollisionGroup(): CollisionGroup;
|
|
32
72
|
deregisterCollisionGroup(group: CollisionGroup): void;
|
|
@@ -9,6 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
};
|
|
10
10
|
import { BitMask, Pnt3, } from '@gg-web-engine/core';
|
|
11
11
|
import { EventQueue, init, Vector3, World } from '@dimforge/rapier3d-compat';
|
|
12
|
+
import { Rapier3dTriggerComponent } from './rapier-3d-trigger.component';
|
|
12
13
|
import { Rapier3dFactory } from '../rapier-3d-factory';
|
|
13
14
|
import { Rapier3dLoader } from '../rapier-3d-loader';
|
|
14
15
|
import { Subject } from 'rxjs';
|
|
@@ -76,6 +77,135 @@ export class Rapier3dWorldComponent {
|
|
|
76
77
|
var _a;
|
|
77
78
|
this._nativeWorld.timestep = delta / 1000;
|
|
78
79
|
(_a = this._nativeWorld) === null || _a === void 0 ? void 0 : _a.step(this.eventQueue);
|
|
80
|
+
this.dispatchCollisionEvents();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Drains `eventQueue` exactly once per `simulate()` call and routes each entry to whichever
|
|
84
|
+
* component(s) care - this is the *only* place `drainCollisionEvents` is called for the whole
|
|
85
|
+
* world (see `Rapier3dTriggerComponent.notifyOverlap`'s doc for why a second/independent drain
|
|
86
|
+
* elsewhere would silently steal events from this one). A collider pair with sensor
|
|
87
|
+
* semantics (either side `isSensor()`) is routed as a trigger overlap; an ordinary pair is routed
|
|
88
|
+
* as a real rigid-body collision.
|
|
89
|
+
*
|
|
90
|
+
* `EventQueue.drainCollisionEvents` reports *collider* handles, not rigid-body handles -
|
|
91
|
+
* `handleIdEntityMap` is keyed by rigid-body handle (see `addToWorld`), so each handle is resolved
|
|
92
|
+
* via `World.getCollider(handle)` (returns `null` for a since-removed collider, not a throw - safe
|
|
93
|
+
* to just skip) then `Collider.parent()` to reach the owning `RigidBody` before the map lookup.
|
|
94
|
+
*/
|
|
95
|
+
dispatchCollisionEvents() {
|
|
96
|
+
const nativeWorld = this._nativeWorld;
|
|
97
|
+
if (!nativeWorld) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.eventQueue.drainCollisionEvents((h1, h2, started) => {
|
|
101
|
+
const collider1 = nativeWorld.getCollider(h1);
|
|
102
|
+
const collider2 = nativeWorld.getCollider(h2);
|
|
103
|
+
if (!collider1 || !collider2) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const body1 = collider1.parent();
|
|
107
|
+
const body2 = collider2.parent();
|
|
108
|
+
if (!body1 || !body2) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const comp1 = this.handleIdEntityMap.get(body1.handle);
|
|
112
|
+
const comp2 = this.handleIdEntityMap.get(body2.handle);
|
|
113
|
+
if (!comp1 || !comp2 || comp1 === comp2) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (collider1.isSensor() || collider2.isSensor()) {
|
|
117
|
+
if (comp1 instanceof Rapier3dTriggerComponent) {
|
|
118
|
+
comp1.notifyOverlap(comp2, started);
|
|
119
|
+
}
|
|
120
|
+
if (comp2 instanceof Rapier3dTriggerComponent) {
|
|
121
|
+
comp2.notifyOverlap(comp1, started);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (started) {
|
|
126
|
+
const geometry = this.computeContactGeometry(collider1, collider2);
|
|
127
|
+
if (!geometry) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const v1 = body1.linvel();
|
|
131
|
+
const v2 = body2.linvel();
|
|
132
|
+
comp1.notifyCollisionStart({
|
|
133
|
+
otherBody: comp2,
|
|
134
|
+
position: geometry.position,
|
|
135
|
+
normal: geometry.normal,
|
|
136
|
+
relativeVelocity: Pnt3.sub(v2, v1),
|
|
137
|
+
impulse: geometry.impulse,
|
|
138
|
+
});
|
|
139
|
+
comp2.notifyCollisionStart({
|
|
140
|
+
otherBody: comp1,
|
|
141
|
+
position: geometry.position,
|
|
142
|
+
normal: Pnt3.scalarMult(geometry.normal, -1),
|
|
143
|
+
relativeVelocity: Pnt3.sub(v1, v2),
|
|
144
|
+
impulse: geometry.impulse,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
comp1.notifyCollisionEnd(comp2);
|
|
149
|
+
comp2.notifyCollisionEnd(comp1);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Reads world-space contact position/normal/impulse for a just-started contact between two
|
|
155
|
+
* non-sensor colliders via `World.contactPair`'s `TempContactManifold`. Returns `null` only if the
|
|
156
|
+
* pair has no manifold at all (shouldn't normally happen for a pair `drainCollisionEvents` just
|
|
157
|
+
* reported as newly touching, but guarded defensively). `World.contactPair`'s callback can in
|
|
158
|
+
* principle be invoked once per manifold between the pair - for the compound/multi-collider shapes
|
|
159
|
+
* this can matter for, each sub-collider pair already gets its own separate `drainCollisionEvents`
|
|
160
|
+
* entry (and thus its own `computeContactGeometry` call) since collision events are per-*collider*,
|
|
161
|
+
* not per-body, so in practice a single call here only ever sees one manifold; only the first
|
|
162
|
+
* encountered is used regardless. `impulse` sums `contactImpulse(i)` across every contact point of
|
|
163
|
+
* that manifold (an already-solved *impulse*, not a force estimate - the pinned
|
|
164
|
+
* `@dimforge/rapier3d-compat` build's constraint solver runs within the same `World.step()` call
|
|
165
|
+
* that produced this `started` event, so the manifold's impulses are already up to date by the time
|
|
166
|
+
* this runs). `normal` is returned oriented "away from `collider1` towards `collider2`" - Rapier's
|
|
167
|
+
* `contactPair(a, b, f)` reports whether its own internally-cached manifold order matches the
|
|
168
|
+
* queried `(a, b)` order via the callback's `flipped` flag; when `flipped` is `true` the manifold's
|
|
169
|
+
* `normal()` (always world-space, always pointing from the manifold's own shape1 to shape2) actually
|
|
170
|
+
* points from `collider2` to `collider1` and must be negated to match this method's documented
|
|
171
|
+
* orientation - verified empirically against a ball resting on a floor below it (see
|
|
172
|
+
* `gg-engine-physics-adapter-rapier` for the exact reproduction).
|
|
173
|
+
*/
|
|
174
|
+
computeContactGeometry(collider1, collider2) {
|
|
175
|
+
const nativeWorld = this._nativeWorld;
|
|
176
|
+
if (!nativeWorld) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
let result = null;
|
|
180
|
+
nativeWorld.contactPair(collider1, collider2, (manifold, flipped) => {
|
|
181
|
+
if (result) {
|
|
182
|
+
// only the first manifold is used - see doc above.
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const rawNormal = manifold.normal();
|
|
186
|
+
const normal = flipped
|
|
187
|
+
? { x: -rawNormal.x, y: -rawNormal.y, z: -rawNormal.z }
|
|
188
|
+
: { x: rawNormal.x, y: rawNormal.y, z: rawNormal.z };
|
|
189
|
+
const numSolverContacts = manifold.numSolverContacts();
|
|
190
|
+
let position = Pnt3.O;
|
|
191
|
+
if (numSolverContacts > 0) {
|
|
192
|
+
let sum = Pnt3.O;
|
|
193
|
+
for (let i = 0; i < numSolverContacts; i++) {
|
|
194
|
+
const p = manifold.solverContactPoint(i);
|
|
195
|
+
if (p) {
|
|
196
|
+
sum = Pnt3.add(sum, p);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
position = Pnt3.scalarMult(sum, 1 / numSolverContacts);
|
|
200
|
+
}
|
|
201
|
+
let impulse = 0;
|
|
202
|
+
const numContacts = manifold.numContacts();
|
|
203
|
+
for (let i = 0; i < numContacts; i++) {
|
|
204
|
+
impulse += manifold.contactImpulse(i);
|
|
205
|
+
}
|
|
206
|
+
result = { position, normal, impulse };
|
|
207
|
+
});
|
|
208
|
+
return result;
|
|
79
209
|
}
|
|
80
210
|
registerCollisionGroup() {
|
|
81
211
|
for (let i = 1; i < 16; i++) {
|
|
@@ -22,7 +22,14 @@ export class Rapier3dFactory {
|
|
|
22
22
|
this.world = world;
|
|
23
23
|
}
|
|
24
24
|
createRigidBody(descriptor, transform) {
|
|
25
|
-
|
|
25
|
+
// `Rapier3dRigidBodyComponent.onCollisionStart`/`onCollisionEnd` are backed by Rapier's own
|
|
26
|
+
// collision-event queue (drained centrally by `Rapier3dWorldComponent.simulate`), which only
|
|
27
|
+
// reports a contact pair when *at least one* of its two colliders has `COLLISION_EVENTS` active
|
|
28
|
+
// (triggers already set this on their own sensor collider - see `createTrigger` below - but an
|
|
29
|
+
// ordinary rigid-body-vs-rigid-body contact needs it here too, since neither side had it before).
|
|
30
|
+
const colliderDescr = this.createColliderDescr(descriptor.shape);
|
|
31
|
+
colliderDescr.forEach(c => c.setActiveEvents(ActiveEvents.COLLISION_EVENTS));
|
|
32
|
+
return new Rapier3dRigidBodyComponent(this.world, colliderDescr, descriptor.shape, this.createRigidBodyDescr(descriptor.body, transform), Object.assign({ friction: 0.5, restitution: 0.1, ownCollisionGroups: [this.world.mainCollisionGroup], interactWithCollisionGroups: [this.world.mainCollisionGroup] }, descriptor.body));
|
|
26
33
|
}
|
|
27
34
|
createTrigger(descriptor, transform) {
|
|
28
35
|
const colliderDescr = this.createColliderDescr(descriptor);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gg-web-engine/rapier3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.68",
|
|
4
4
|
"description": "An attempt to create open source game engine for browser",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"homepage": "https://github.com/AndyGura/gg-web-engine#readme",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@dimforge/rapier3d-compat": "0.20.0",
|
|
31
|
-
"@gg-web-engine/core": "0.0.
|
|
31
|
+
"@gg-web-engine/core": "0.0.68",
|
|
32
32
|
"@types/jest": "^30.0.0",
|
|
33
33
|
"@types/node": "^26.4.0",
|
|
34
34
|
"jest": "^30.5.0",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@dimforge/rapier3d-compat": "0.20.0",
|
|
43
|
-
"@gg-web-engine/core": "0.0.
|
|
43
|
+
"@gg-web-engine/core": "0.0.68",
|
|
44
44
|
"rxjs": "7.8.2"
|
|
45
45
|
},
|
|
46
46
|
"jest": {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CharacterController3dEntity,
|
|
3
|
+
Grabbable3dEntity,
|
|
4
|
+
KeyboardInput,
|
|
5
|
+
MouseInput,
|
|
6
|
+
ObjectGrabController,
|
|
7
|
+
Pnt3,
|
|
8
|
+
Point3,
|
|
9
|
+
Qtrn,
|
|
10
|
+
} from '@gg-web-engine/core';
|
|
11
|
+
import { Rapier3dWorldComponent } from '../../src';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Regression coverage for the Rapier3d-specific counterpart of
|
|
15
|
+
* `packages/ammo/test/components/ammo-world.component.spec.ts`'s "should still grab a small target
|
|
16
|
+
* sitting closer than the ray origin overshoots to" test - both reproduce the same real scene (the
|
|
17
|
+
* `portal-room` example's pedestal + radio prop), but they can't share a test body: the two adapters
|
|
18
|
+
* were broken by, and fixed for, two different bugs at two different layers.
|
|
19
|
+
*
|
|
20
|
+
* Ammo's gap lived in `AmmoWorldComponent.raycast()` itself - Bullet's plain `rayTest` can't detect a
|
|
21
|
+
* shape already containing the ray's origin at all, so a ray landing *inside* the small prop found
|
|
22
|
+
* nothing until the adapter grew its own `solidRayFallback`. Rapier never had that gap -
|
|
23
|
+
* `Rapier3dWorldComponent.raycast()`'s `castRay(..., solid: true, ...)` already reports a distance-`0`
|
|
24
|
+
* hit for whatever shape contains the ray's origin, prop included, with no adapter-level fix needed.
|
|
25
|
+
*
|
|
26
|
+
* Rapier's bug was one level up, in `ObjectGrabController.tryGrab()` itself: `solid: true` doesn't
|
|
27
|
+
* just handle a ray landing inside the prop, it makes *any* containing shape win over anything
|
|
28
|
+
* farther away - so with the camera sitting inside the holder's own capsule (the ordinary
|
|
29
|
+
* first-person case), `tryGrab()`'s first, un-skipped cast always self-hit that capsule before ever
|
|
30
|
+
* reaching the prop, on every single attempt, close range or not. Recovering from that self-hit is
|
|
31
|
+
* `characterControllerSelfHitSkip()`'s job (see `gg-engine-core-development`'s own notes on it) - so
|
|
32
|
+
* these tests exercise that recovery through the real `ObjectGrabController` + real
|
|
33
|
+
* `Rapier3dWorldComponent` + real character-controller collider, not `Rapier3dWorldComponent.raycast()`
|
|
34
|
+
* in isolation (which, per the above, was never the part that was broken).
|
|
35
|
+
*/
|
|
36
|
+
describe('ObjectGrabController + Rapier3dWorldComponent - end-to-end pick-up range', () => {
|
|
37
|
+
const setupPedestalAndRadio = (world: Rapier3dWorldComponent, radioPosition: Point3) => {
|
|
38
|
+
const pedestal = world.factory.createRigidBody(
|
|
39
|
+
{ shape: { shape: 'CYLINDER', radius: 0.5, height: 0.9 }, body: { dynamic: false, mass: 0 } },
|
|
40
|
+
{ position: { x: 0, y: 0, z: 0.45 } },
|
|
41
|
+
);
|
|
42
|
+
pedestal.addToWorld({ physicsWorld: world } as any);
|
|
43
|
+
|
|
44
|
+
const propBody = world.factory.createRigidBody(
|
|
45
|
+
{ shape: { shape: 'BOX', dimensions: { x: 0.41, y: 0.23, z: 0.49 } }, body: { dynamic: true, mass: 3 } },
|
|
46
|
+
{ position: radioPosition },
|
|
47
|
+
);
|
|
48
|
+
propBody.addToWorld({ physicsWorld: world } as any);
|
|
49
|
+
return new Grabbable3dEntity({ objectBody: propBody });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
it('grabs a small prop resting on a pedestal even standing very close to it', async () => {
|
|
53
|
+
const world = new Rapier3dWorldComponent();
|
|
54
|
+
await world.init();
|
|
55
|
+
world.gravity = { x: 0, y: 0, z: 0 };
|
|
56
|
+
|
|
57
|
+
// Same geometry as the `portal-room` example (and the Ammo regression test referenced above): a
|
|
58
|
+
// pedestal with a small radio prop resting on top of it.
|
|
59
|
+
const prop = setupPedestalAndRadio(world, { x: 0, y: 0, z: 1.145 });
|
|
60
|
+
|
|
61
|
+
// player capsule radius 0.4 + centersDistance 1.0, matching both the example's own player
|
|
62
|
+
// options and the Ammo test's holder.
|
|
63
|
+
const characterController = world.factory.createCharacterController(
|
|
64
|
+
{ radius: 0.4, centersDistance: 1.0 },
|
|
65
|
+
{ position: { x: 0, y: -1.1, z: 1.0 } },
|
|
66
|
+
);
|
|
67
|
+
const holder = new CharacterController3dEntity(
|
|
68
|
+
{ radius: 0.4, centersDistance: 1.0, walkSpeed: 4, jumpSpeed: 5, gravity: 9.82 },
|
|
69
|
+
null,
|
|
70
|
+
characterController,
|
|
71
|
+
);
|
|
72
|
+
holder.onSpawned({ physicsWorld: world } as any);
|
|
73
|
+
world.simulate(1);
|
|
74
|
+
|
|
75
|
+
// playerDistance = 1.1 (same number as the Ammo test) - close enough that the camera (same
|
|
76
|
+
// height as the prop, a purely horizontal look - no pitch needed to reproduce this) sits well
|
|
77
|
+
// within the holder capsule's own radius from its central axis, so `tryGrab()`'s first cast is
|
|
78
|
+
// guaranteed to self-hit that capsule before the retry this test is actually exercising kicks in.
|
|
79
|
+
const cameraPosition = { x: 0, y: -1.1, z: 1.145 };
|
|
80
|
+
const camera = { position: cameraPosition, rotation: Qtrn.lookAt(cameraPosition, { x: 0, y: 0, z: 1.145 }) } as any;
|
|
81
|
+
|
|
82
|
+
const keyboard = new KeyboardInput();
|
|
83
|
+
keyboard.start();
|
|
84
|
+
const mouseInput = new MouseInput();
|
|
85
|
+
mouseInput.start();
|
|
86
|
+
const controller = new ObjectGrabController(keyboard, mouseInput, camera, holder);
|
|
87
|
+
controller.onSpawned({ physicsWorld: world } as any);
|
|
88
|
+
|
|
89
|
+
keyboard.emulateKeyDown('KeyE');
|
|
90
|
+
|
|
91
|
+
expect(controller.heldObject).toBe(prop);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it(
|
|
95
|
+
'grabs the prop at the exact position/rotation a real gameplay report found it ungrabbable at ' +
|
|
96
|
+
"(regression: a flat `radius`-sized self-hit skip - characterControllerSelfHitSkip()'s first " +
|
|
97
|
+
'implementation - cleared the capsule fine for a purely horizontal look, but the real ' +
|
|
98
|
+
"first-person camera here sits 0.7m above the capsule center (`PlayerCharacterController`'s " +
|
|
99
|
+
'default eyeHeight) - taller than the capsule half-height (0.5m) - so it starts inside the ' +
|
|
100
|
+
"rounded top cap, not the cylindrical body; the camera's real downward pitch toward the low " +
|
|
101
|
+
"radio then meant a flat radius skip landed the retry ray's start point still measurably " +
|
|
102
|
+
'inside the capsule, self-hitting again and leaving the radio permanently ungrabbable at this ' +
|
|
103
|
+
'exact spot. Fixed by sphere-tracing the capsule\'s own exact geometry instead of guessing a ' +
|
|
104
|
+
"flat distance - see `characterControllerSelfHitSkip()`'s own doc)",
|
|
105
|
+
async () => {
|
|
106
|
+
const world = new Rapier3dWorldComponent();
|
|
107
|
+
await world.init();
|
|
108
|
+
world.gravity = { x: 0, y: 0, z: -9.82 };
|
|
109
|
+
|
|
110
|
+
// Exact positions read from the in-game dev console's entity inspector during the failing
|
|
111
|
+
// report, not guessed.
|
|
112
|
+
const radioPosition = { x: -0.010856885462999344, y: 0.004407098516821861, z: 0.8990808129310608 };
|
|
113
|
+
const prop = setupPedestalAndRadio(world, radioPosition);
|
|
114
|
+
|
|
115
|
+
const playerPosition = { x: 0.03676546365022659, y: -0.9093552231788635, z: 0.9099816679954529 };
|
|
116
|
+
const playerRotation = { x: 0, y: 0, z: 0.01860076382738503, w: 0.9998269908264321 };
|
|
117
|
+
const characterController = world.factory.createCharacterController(
|
|
118
|
+
{ radius: 0.4, centersDistance: 1.0 },
|
|
119
|
+
{ position: playerPosition, rotation: playerRotation },
|
|
120
|
+
);
|
|
121
|
+
const holder = new CharacterController3dEntity(
|
|
122
|
+
{ radius: 0.4, centersDistance: 1.0, walkSpeed: 4, jumpSpeed: 4, gravity: 9.82 },
|
|
123
|
+
null,
|
|
124
|
+
characterController,
|
|
125
|
+
);
|
|
126
|
+
holder.onSpawned({ physicsWorld: world } as any);
|
|
127
|
+
world.simulate(1);
|
|
128
|
+
|
|
129
|
+
// First-person camera at `PlayerCharacterController`'s default eyeHeight (0.7) above the
|
|
130
|
+
// player capsule's own center, looking at the radio - reproducing the actual aim, not just the
|
|
131
|
+
// position.
|
|
132
|
+
const cameraPosition = Pnt3.add(playerPosition, Pnt3.scalarMult(Pnt3.Z, 0.7));
|
|
133
|
+
const camera = { position: cameraPosition, rotation: Qtrn.lookAt(cameraPosition, radioPosition) } as any;
|
|
134
|
+
|
|
135
|
+
const keyboard = new KeyboardInput();
|
|
136
|
+
keyboard.start();
|
|
137
|
+
const mouseInput = new MouseInput();
|
|
138
|
+
mouseInput.start();
|
|
139
|
+
const controller = new ObjectGrabController(keyboard, mouseInput, camera, holder);
|
|
140
|
+
controller.onSpawned({ physicsWorld: world } as any);
|
|
141
|
+
|
|
142
|
+
keyboard.emulateKeyDown('KeyE');
|
|
143
|
+
|
|
144
|
+
expect(controller.heldObject).toBe(prop);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
});
|
|
@@ -5,7 +5,16 @@ import { Rapier3dWorldComponent } from '../../src';
|
|
|
5
5
|
// maps to world -Z at identity, in this Z-up engine), a degenerate/arbitrary case for
|
|
6
6
|
// `PlayerCharacterController.reset()`'s yaw derivation; every real app sets an actual look
|
|
7
7
|
// direction, so this is the representative case.
|
|
8
|
-
const fakeCamera = () =>
|
|
8
|
+
const fakeCamera = () =>
|
|
9
|
+
({
|
|
10
|
+
position: { x: 0, y: 0, z: 0 },
|
|
11
|
+
rotation: Qtrn.lookAt(Pnt3.O, Pnt3.Y),
|
|
12
|
+
// `PlayerCharacterController`'s view-mode toggle calls these on its own camera - stubbed here
|
|
13
|
+
// purely so constructing it doesn't throw; this suite doesn't assert on render-layer state.
|
|
14
|
+
enableRenderLayer: () => {},
|
|
15
|
+
disableRenderLayer: () => {},
|
|
16
|
+
isRenderLayerEnabled: () => true,
|
|
17
|
+
}) as any;
|
|
9
18
|
|
|
10
19
|
const setup = async () => {
|
|
11
20
|
const world = new Rapier3dWorldComponent();
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Pnt3 } from '@gg-web-engine/core';
|
|
2
|
+
import { Rapier3dFactory, Rapier3dWorldComponent } from '../../src';
|
|
3
|
+
import { Rapier3dRigidBodyComponent } from '../../src/components/rapier-3d-rigid-body.component';
|
|
4
|
+
|
|
5
|
+
describe('Rapier3dRigidBodyComponent collision events', () => {
|
|
6
|
+
let world: Rapier3dWorldComponent;
|
|
7
|
+
let factory: Rapier3dFactory;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
if (world) {
|
|
11
|
+
world.dispose();
|
|
12
|
+
}
|
|
13
|
+
world = new Rapier3dWorldComponent();
|
|
14
|
+
factory = new Rapier3dFactory(world);
|
|
15
|
+
await world.init();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
world.dispose();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Small steps, mirroring Rapier3dTriggerComponent's own spec: a single huge `simulate()` call
|
|
23
|
+
// detects a contact a whole step late (narrow-phase runs against start-of-step positions), which
|
|
24
|
+
// would make a "did it fire at all" assertion flaky. `world.simulate()` now dispatches collision
|
|
25
|
+
// events internally (see `Rapier3dWorldComponent.dispatchCollisionEvents`), so no extra per-step
|
|
26
|
+
// hook is needed here the way `Rapier3dTriggerComponent.checkOverlaps()` needs calling for triggers.
|
|
27
|
+
const advance = (totalMs: number, stepMs = 16) => {
|
|
28
|
+
for (let elapsed = 0; elapsed < totalMs; elapsed += stepMs) {
|
|
29
|
+
world.simulate(stepMs);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const createFloor = () => {
|
|
34
|
+
const floor = factory.createRigidBody(
|
|
35
|
+
{ shape: { shape: 'BOX', dimensions: { x: 20, y: 20, z: 1 } }, body: { dynamic: false } },
|
|
36
|
+
{ position: { x: 0, y: 0, z: -0.5 } },
|
|
37
|
+
);
|
|
38
|
+
floor.addToWorld({ physicsWorld: world } as any);
|
|
39
|
+
return floor;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const createBall = (z: number) => {
|
|
43
|
+
// restitution 0 so the ball settles without bouncing, keeping the "no further onCollisionStart
|
|
44
|
+
// once resting" assertion below unambiguous.
|
|
45
|
+
const ball = factory.createRigidBody(
|
|
46
|
+
{ shape: { shape: 'SPHERE', radius: 1 }, body: { dynamic: true, mass: 1, restitution: 0 } },
|
|
47
|
+
{ position: { x: 0, y: 0, z } },
|
|
48
|
+
);
|
|
49
|
+
ball.addToWorld({ physicsWorld: world } as any);
|
|
50
|
+
return ball;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
it('fires onCollisionStart on the falling body with a resolved otherBody, positive impulse, and a physically sane contact', () => {
|
|
54
|
+
const floor = createFloor();
|
|
55
|
+
const ball = createBall(5);
|
|
56
|
+
|
|
57
|
+
const ballEvents: any[] = [];
|
|
58
|
+
ball.onCollisionStart.subscribe(e => ballEvents.push(e));
|
|
59
|
+
|
|
60
|
+
advance(2000);
|
|
61
|
+
|
|
62
|
+
expect(ballEvents.length).toBeGreaterThanOrEqual(1);
|
|
63
|
+
const event = ballEvents[0];
|
|
64
|
+
expect(event.otherBody).toBe(floor);
|
|
65
|
+
expect(event.impulse).toBeGreaterThan(0);
|
|
66
|
+
// The ball starts above the floor - `normal` points away from the ball (this body) towards the
|
|
67
|
+
// floor (`otherBody`), so it should point roughly straight down.
|
|
68
|
+
expect(event.normal.z).toBeLessThan(-0.9);
|
|
69
|
+
expect(Math.abs(event.normal.x)).toBeLessThan(0.2);
|
|
70
|
+
expect(Math.abs(event.normal.y)).toBeLessThan(0.2);
|
|
71
|
+
// contact point should sit right around the floor's top surface (z = 0), not somewhere wild.
|
|
72
|
+
expect(event.position.z).toBeGreaterThan(-1);
|
|
73
|
+
expect(event.position.z).toBeLessThan(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('fires the reciprocal onCollisionStart on the other body too, with a consistently-oriented normal/relativeVelocity', () => {
|
|
77
|
+
const floor = createFloor();
|
|
78
|
+
const ball = createBall(5);
|
|
79
|
+
|
|
80
|
+
const ballEvents: any[] = [];
|
|
81
|
+
const floorEvents: any[] = [];
|
|
82
|
+
ball.onCollisionStart.subscribe(e => ballEvents.push(e));
|
|
83
|
+
floor.onCollisionStart.subscribe(e => floorEvents.push(e));
|
|
84
|
+
|
|
85
|
+
advance(2000);
|
|
86
|
+
|
|
87
|
+
expect(ballEvents.length).toBeGreaterThanOrEqual(1);
|
|
88
|
+
expect(floorEvents.length).toBeGreaterThanOrEqual(1);
|
|
89
|
+
const ballEvent = ballEvents[0];
|
|
90
|
+
const floorEvent = floorEvents[0];
|
|
91
|
+
|
|
92
|
+
expect(floorEvent.otherBody).toBe(ball);
|
|
93
|
+
expect(ballEvent.otherBody).toBe(floor);
|
|
94
|
+
|
|
95
|
+
// floor's normal points away from the floor towards the ball above it (up); the ball's points
|
|
96
|
+
// away from the ball towards the floor below it (down) - opposite directions.
|
|
97
|
+
expect(floorEvent.normal.z).toBeGreaterThan(0.9);
|
|
98
|
+
expect(ballEvent.normal.z).toBeLessThan(-0.9);
|
|
99
|
+
|
|
100
|
+
// relativeVelocity is "otherBody's velocity relative to this body", read from each body's own
|
|
101
|
+
// `linvel()` *after* this step's solve already ran (see `Rapier3dWorldComponent.
|
|
102
|
+
// dispatchCollisionEvents`'s doc) - not the pre-response approach velocity the ball actually hit
|
|
103
|
+
// at, since Rapier's constraint solver has already resolved the contact (and, with the default
|
|
104
|
+
// Baumgarte-style bias, can leave a small outward/separating residual velocity behind) by the
|
|
105
|
+
// time `drainCollisionEvents` reports it as `started`. What must still hold regardless: the two
|
|
106
|
+
// reciprocal readings are exact negations of each other (floor is stationary throughout, so this
|
|
107
|
+
// is really just `-ball.linvel().z` vs `ball.linvel().z`).
|
|
108
|
+
expect(floorEvent.relativeVelocity.z).toBeCloseTo(-ballEvent.relativeVelocity.z, 5);
|
|
109
|
+
|
|
110
|
+
expect(floorEvent.impulse).toBeCloseTo(ballEvent.impulse, 5);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not keep re-firing onCollisionStart for a body resting stably on another', () => {
|
|
114
|
+
const floor = createFloor();
|
|
115
|
+
const ball = createBall(3);
|
|
116
|
+
void floor;
|
|
117
|
+
|
|
118
|
+
let startCount = 0;
|
|
119
|
+
ball.onCollisionStart.subscribe(() => startCount++);
|
|
120
|
+
|
|
121
|
+
advance(1500); // fall and land
|
|
122
|
+
const countAfterLanding = startCount;
|
|
123
|
+
expect(countAfterLanding).toBeGreaterThanOrEqual(1);
|
|
124
|
+
|
|
125
|
+
advance(1500); // keep resting, several hundred more simulate() calls
|
|
126
|
+
expect(startCount).toBe(countAfterLanding);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('fires onCollisionEnd when a body is knocked away and separates from what it was touching', () => {
|
|
130
|
+
const floor = createFloor();
|
|
131
|
+
const ball = createBall(3);
|
|
132
|
+
|
|
133
|
+
let endedWith: Rapier3dRigidBodyComponent | null | undefined;
|
|
134
|
+
let endCount = 0;
|
|
135
|
+
ball.onCollisionEnd.subscribe(other => {
|
|
136
|
+
endCount++;
|
|
137
|
+
endedWith = other;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
advance(1500); // let it land and settle
|
|
141
|
+
expect(endCount).toBe(0);
|
|
142
|
+
|
|
143
|
+
// zero gravity so the teleported ball doesn't immediately fall back down and re-collide within
|
|
144
|
+
// this test's own observation window
|
|
145
|
+
world.gravity = Pnt3.O;
|
|
146
|
+
ball.position = { x: 0, y: 0, z: 20 };
|
|
147
|
+
advance(500);
|
|
148
|
+
|
|
149
|
+
expect(endCount).toBeGreaterThanOrEqual(1);
|
|
150
|
+
expect(endedWith).toBe(floor);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not fire a rigid body's onCollisionStart for a trigger overlap (triggers stay sensor-only)", () => {
|
|
154
|
+
world.gravity = Pnt3.O;
|
|
155
|
+
const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
|
|
156
|
+
trigger.addToWorld({ physicsWorld: world } as any);
|
|
157
|
+
|
|
158
|
+
const ball = createBall(0); // spawned already overlapping the trigger volume
|
|
159
|
+
|
|
160
|
+
let ballCollisionStarts = 0;
|
|
161
|
+
ball.onCollisionStart.subscribe(() => ballCollisionStarts++);
|
|
162
|
+
let triggerEnters = 0;
|
|
163
|
+
trigger.onEntityEntered.subscribe(() => triggerEnters++);
|
|
164
|
+
|
|
165
|
+
advance(500);
|
|
166
|
+
trigger.checkOverlaps();
|
|
167
|
+
|
|
168
|
+
expect(triggerEnters).toBeGreaterThanOrEqual(1);
|
|
169
|
+
expect(ballCollisionStarts).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('completes onCollisionStart/onCollisionEnd on dispose, matching matter/ammo', () => {
|
|
173
|
+
const ball = createBall(0);
|
|
174
|
+
|
|
175
|
+
let startCompleted = false;
|
|
176
|
+
let endCompleted = false;
|
|
177
|
+
ball.onCollisionStart.subscribe({ complete: () => (startCompleted = true) });
|
|
178
|
+
ball.onCollisionEnd.subscribe({ complete: () => (endCompleted = true) });
|
|
179
|
+
|
|
180
|
+
ball.dispose();
|
|
181
|
+
|
|
182
|
+
expect(startCompleted).toBe(true);
|
|
183
|
+
expect(endCompleted).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -2,7 +2,6 @@ import { Pnt3 } from '@gg-web-engine/core';
|
|
|
2
2
|
import { Rapier3dFactory, Rapier3dWorldComponent } from '../../src';
|
|
3
3
|
|
|
4
4
|
describe(`Rapier3dTriggerComponent`, () => {
|
|
5
|
-
|
|
6
5
|
let world: Rapier3dWorldComponent;
|
|
7
6
|
let factory: Rapier3dFactory;
|
|
8
7
|
beforeEach(async () => {
|
|
@@ -39,20 +38,23 @@ describe(`Rapier3dTriggerComponent`, () => {
|
|
|
39
38
|
it(`should detect object intersection`, async () => {
|
|
40
39
|
const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
|
|
41
40
|
trigger.addToWorld({ physicsWorld: world } as any);
|
|
42
|
-
const ball = factory.createRigidBody(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
const ball = factory.createRigidBody(
|
|
42
|
+
{
|
|
43
|
+
shape: { shape: 'SPHERE', radius: 1 },
|
|
44
|
+
body: { dynamic: true, mass: 1 },
|
|
45
|
+
},
|
|
46
|
+
{ position: { x: 0, y: 0, z: 12 } },
|
|
47
|
+
);
|
|
46
48
|
ball.addToWorld({ physicsWorld: world } as any);
|
|
47
49
|
ball.linearVelocity = { x: 0, y: 0, z: -10 };
|
|
48
50
|
let enterRegistered = false;
|
|
49
51
|
let exitRegistered = false;
|
|
50
|
-
trigger.onEntityEntered.subscribe(
|
|
52
|
+
trigger.onEntityEntered.subscribe(obj => {
|
|
51
53
|
enterRegistered = obj === ball;
|
|
52
|
-
})
|
|
53
|
-
trigger.onEntityLeft.subscribe(
|
|
54
|
+
});
|
|
55
|
+
trigger.onEntityLeft.subscribe(obj => {
|
|
54
56
|
exitRegistered = obj === ball;
|
|
55
|
-
})
|
|
57
|
+
});
|
|
56
58
|
advance(500, () => trigger.checkOverlaps()); // trigger entity performs that on tick
|
|
57
59
|
expect(enterRegistered).toBe(false);
|
|
58
60
|
expect(exitRegistered).toBe(false);
|
|
@@ -65,16 +67,19 @@ describe(`Rapier3dTriggerComponent`, () => {
|
|
|
65
67
|
it(`should detect end of object intersection`, async () => {
|
|
66
68
|
const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
|
|
67
69
|
trigger.addToWorld({ physicsWorld: world } as any);
|
|
68
|
-
const ball = factory.createRigidBody(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const ball = factory.createRigidBody(
|
|
71
|
+
{
|
|
72
|
+
shape: { shape: 'SPHERE', radius: 1 },
|
|
73
|
+
body: { dynamic: true, mass: 1 },
|
|
74
|
+
},
|
|
75
|
+
{ position: { x: 0, y: 0, z: 12 } },
|
|
76
|
+
);
|
|
72
77
|
ball.addToWorld({ physicsWorld: world } as any);
|
|
73
78
|
ball.linearVelocity = { x: 0, y: 0, z: -10 };
|
|
74
79
|
let exitRegistered = false;
|
|
75
|
-
trigger.onEntityLeft.subscribe(
|
|
80
|
+
trigger.onEntityLeft.subscribe(obj => {
|
|
76
81
|
exitRegistered = obj === ball;
|
|
77
|
-
})
|
|
82
|
+
});
|
|
78
83
|
advance(1000, () => trigger.checkOverlaps());
|
|
79
84
|
expect(exitRegistered).toBe(false);
|
|
80
85
|
advance(1000, () => trigger.checkOverlaps());
|
|
@@ -84,15 +89,18 @@ describe(`Rapier3dTriggerComponent`, () => {
|
|
|
84
89
|
it(`should fire object intersection if spawned inside`, async () => {
|
|
85
90
|
const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
|
|
86
91
|
trigger.addToWorld({ physicsWorld: world } as any);
|
|
87
|
-
const ball = factory.createRigidBody(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
const ball = factory.createRigidBody(
|
|
93
|
+
{
|
|
94
|
+
shape: { shape: 'SPHERE', radius: 1 },
|
|
95
|
+
body: { dynamic: true, mass: 1 },
|
|
96
|
+
},
|
|
97
|
+
{ position: { x: 0, y: 0, z: 0 } },
|
|
98
|
+
);
|
|
91
99
|
ball.addToWorld({ physicsWorld: world } as any);
|
|
92
100
|
let enterRegistered = false;
|
|
93
|
-
trigger.onEntityEntered.subscribe(
|
|
101
|
+
trigger.onEntityEntered.subscribe(obj => {
|
|
94
102
|
enterRegistered = obj === ball;
|
|
95
|
-
})
|
|
103
|
+
});
|
|
96
104
|
world.simulate(1000);
|
|
97
105
|
trigger.checkOverlaps();
|
|
98
106
|
expect(enterRegistered).toBe(true);
|
|
@@ -101,15 +109,18 @@ describe(`Rapier3dTriggerComponent`, () => {
|
|
|
101
109
|
it(`should fire end of object intersection if trigger removed`, async () => {
|
|
102
110
|
const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
|
|
103
111
|
trigger.addToWorld({ physicsWorld: world } as any);
|
|
104
|
-
const ball = factory.createRigidBody(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const ball = factory.createRigidBody(
|
|
113
|
+
{
|
|
114
|
+
shape: { shape: 'SPHERE', radius: 1 },
|
|
115
|
+
body: { dynamic: true, mass: 1 },
|
|
116
|
+
},
|
|
117
|
+
{ position: { x: 0, y: 0, z: 0 } },
|
|
118
|
+
);
|
|
108
119
|
ball.addToWorld({ physicsWorld: world } as any);
|
|
109
120
|
let exitRegistered = false;
|
|
110
|
-
trigger.onEntityLeft.subscribe(
|
|
121
|
+
trigger.onEntityLeft.subscribe(obj => {
|
|
111
122
|
exitRegistered = obj === ball;
|
|
112
|
-
})
|
|
123
|
+
});
|
|
113
124
|
world.simulate(1);
|
|
114
125
|
trigger.checkOverlaps();
|
|
115
126
|
trigger.removeFromWorld({ physicsWorld: world } as any);
|
|
@@ -119,15 +130,18 @@ describe(`Rapier3dTriggerComponent`, () => {
|
|
|
119
130
|
it(`should fire end of object intersection if object removed`, async () => {
|
|
120
131
|
const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
|
|
121
132
|
trigger.addToWorld({ physicsWorld: world } as any);
|
|
122
|
-
const ball = factory.createRigidBody(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
const ball = factory.createRigidBody(
|
|
134
|
+
{
|
|
135
|
+
shape: { shape: 'SPHERE', radius: 1 },
|
|
136
|
+
body: { dynamic: true, mass: 1 },
|
|
137
|
+
},
|
|
138
|
+
{ position: { x: 0, y: 0, z: 0 } },
|
|
139
|
+
);
|
|
126
140
|
ball.addToWorld({ physicsWorld: world } as any);
|
|
127
141
|
let exitRegistered = false;
|
|
128
|
-
trigger.onEntityLeft.subscribe(
|
|
142
|
+
trigger.onEntityLeft.subscribe(obj => {
|
|
129
143
|
exitRegistered = obj === ball;
|
|
130
|
-
})
|
|
144
|
+
});
|
|
131
145
|
world.simulate(1);
|
|
132
146
|
trigger.checkOverlaps();
|
|
133
147
|
ball.removeFromWorld({ physicsWorld: world } as any);
|
|
@@ -135,4 +149,25 @@ describe(`Rapier3dTriggerComponent`, () => {
|
|
|
135
149
|
trigger.checkOverlaps();
|
|
136
150
|
expect(exitRegistered).toBe(true);
|
|
137
151
|
});
|
|
152
|
+
|
|
153
|
+
it('completes onEntityEntered/onEntityLeft (and the inherited collision subjects) on dispose', () => {
|
|
154
|
+
const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
|
|
155
|
+
trigger.addToWorld({ physicsWorld: world } as any);
|
|
156
|
+
|
|
157
|
+
let enterCompleted = false;
|
|
158
|
+
let leftCompleted = false;
|
|
159
|
+
let startCompleted = false;
|
|
160
|
+
let endCompleted = false;
|
|
161
|
+
trigger.onEntityEntered.subscribe({ complete: () => (enterCompleted = true) });
|
|
162
|
+
trigger.onEntityLeft.subscribe({ complete: () => (leftCompleted = true) });
|
|
163
|
+
trigger.onCollisionStart.subscribe({ complete: () => (startCompleted = true) });
|
|
164
|
+
trigger.onCollisionEnd.subscribe({ complete: () => (endCompleted = true) });
|
|
165
|
+
|
|
166
|
+
trigger.dispose();
|
|
167
|
+
|
|
168
|
+
expect(enterCompleted).toBe(true);
|
|
169
|
+
expect(leftCompleted).toBe(true);
|
|
170
|
+
expect(startCompleted).toBe(true);
|
|
171
|
+
expect(endCompleted).toBe(true);
|
|
172
|
+
});
|
|
138
173
|
});
|