@genex-ai/cli-demo 0.11.0 → 0.14.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/README.md +1 -0
- package/dist/index.js +203 -4
- package/package.json +7 -2
- package/templates/controllers/NOTICE.md +65 -0
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/character.glb +0 -0
- package/templates/controllers/assets/default-avatar.vrm +0 -0
- package/templates/controllers/character/character-animations.ts +682 -0
- package/templates/controllers/character/character-controller.ts +1636 -0
- package/templates/controllers/character/follow-camera.ts +644 -0
- package/templates/controllers/character/keyboard-input.ts +277 -0
- package/templates/controllers/character/presets.ts +176 -0
- package/templates/controllers/character/touch-joystick.ts +387 -0
- package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
- package/templates/controllers/character/vrm/foot-ik.ts +341 -0
- package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
- package/templates/controllers/drone/drone-controller.ts +1073 -0
- package/templates/controllers/drone/presets.ts +225 -0
- package/templates/controllers/interact/enter-exit.ts +502 -0
- package/templates/controllers/shared/colliders.ts +456 -0
- package/templates/controllers/shared/math.ts +230 -0
- package/templates/controllers/shared/physics-world.ts +622 -0
- package/templates/controllers/vehicle/presets.ts +297 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
- package/templates/controllers/vehicle/wheel.ts +1200 -0
- package/templates/skills/genex-getting-started/SKILL.md +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
- package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
- package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TS port of the ecctrl controller (physics world glue: this file
|
|
4
|
+
// re-implements, as one plain class, the behavior the upstream React
|
|
5
|
+
// components received from their React physics wrapper (@react-three/rapier
|
|
6
|
+
// v2.2.0) plus ecctrl's own TimeControl — Rapier WASM init, fixed-timestep
|
|
7
|
+
// accumulator loop, rigid-body <-> Object3D registry with interpolated pose
|
|
8
|
+
// sync, collision/intersection event dispatch, and the debug-line renderer).
|
|
9
|
+
// Note: upstream's rigid-body userData key `ecctrl` is renamed to
|
|
10
|
+
// `controller` in this port (deliberate de-branding rename).
|
|
11
|
+
|
|
12
|
+
import * as THREE from "three";
|
|
13
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shape of `RigidBody.userData` the controllers understand. Runtime key is
|
|
17
|
+
* `controller` (upstream used `ecctrl`; renamed as part of de-branding).
|
|
18
|
+
*
|
|
19
|
+
* - `excludeRay`: body is ignored by ALL ground queries (character rays AND
|
|
20
|
+
* wheel shapecasts). Set it on bodies that should never count as ground.
|
|
21
|
+
* - `excludeCharacterRay`: ignored by the character's ground query only.
|
|
22
|
+
* - `excludeVehicleRay`: ignored by wheel shapecasts only. The character body
|
|
23
|
+
* itself should be created with `{ controller: { excludeVehicleRay: true } }`
|
|
24
|
+
* so car wheels do not treat the on-foot character as drivable ground.
|
|
25
|
+
*/
|
|
26
|
+
export interface ControllerUserData {
|
|
27
|
+
controller?: {
|
|
28
|
+
excludeRay?: boolean;
|
|
29
|
+
excludeCharacterRay?: boolean;
|
|
30
|
+
excludeVehicleRay?: boolean;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* World creation options. Defaults mirror the upstream physics wrapper's
|
|
36
|
+
* defaults exactly; you rarely need to touch anything except `gravity`.
|
|
37
|
+
*/
|
|
38
|
+
export interface PhysicsWorldOptions {
|
|
39
|
+
/** World gravity in m/s^2. Default `[0, -9.81, 0]`. */
|
|
40
|
+
gravity?: [number, number, number];
|
|
41
|
+
/**
|
|
42
|
+
* Fixed simulation step in seconds. Default `1/60`. Controllers read this
|
|
43
|
+
* via `world.timestep` — it is the ONLY dt physics code may use.
|
|
44
|
+
*/
|
|
45
|
+
timeStep?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Per-frame wall-clock clamp in seconds (spiral-of-death guard). Default
|
|
48
|
+
* `1/30`, i.e. at most 2 substeps per frame at the default step. Raise it
|
|
49
|
+
* if you want physics to catch up after long frame hitches.
|
|
50
|
+
*/
|
|
51
|
+
maxDelta?: number;
|
|
52
|
+
/** Interpolate rendered poses between fixed steps. Default `true`. */
|
|
53
|
+
interpolate?: boolean;
|
|
54
|
+
/** Solver iterations. More = stiffer stacks, more CPU. Default 4. */
|
|
55
|
+
numSolverIterations?: number;
|
|
56
|
+
/** Internal PGS iterations. Default 1. */
|
|
57
|
+
numInternalPgsIterations?: number;
|
|
58
|
+
/** Allowed penetration (length units). Default 0.001. */
|
|
59
|
+
allowedLinearError?: number;
|
|
60
|
+
/** Contact prediction distance (length units). Default 0.002. */
|
|
61
|
+
predictionDistance?: number;
|
|
62
|
+
/** Minimum island size for parallelism. Default 128. */
|
|
63
|
+
minIslandSize?: number;
|
|
64
|
+
/** Max CCD substeps. Default 1. */
|
|
65
|
+
maxCcdSubsteps?: number;
|
|
66
|
+
/** Contact softness frequency (Hz). Default 30. */
|
|
67
|
+
contactNaturalFrequency?: number;
|
|
68
|
+
/** World length unit (units per meter). Default 1. */
|
|
69
|
+
lengthUnit?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Options for {@link PhysicsWorld.createBody}. */
|
|
73
|
+
export interface RigidBodyOptions {
|
|
74
|
+
/** Body type. Default `"dynamic"`. */
|
|
75
|
+
type?: "dynamic" | "fixed" | "kinematicPosition" | "kinematicVelocity";
|
|
76
|
+
/** Initial translation. */
|
|
77
|
+
position?: [number, number, number];
|
|
78
|
+
/** Initial rotation: a quaternion, or `[x, y, z]` euler angles (XYZ, rad). */
|
|
79
|
+
rotation?: THREE.Quaternion | [number, number, number];
|
|
80
|
+
/** Allow the body to sleep when at rest. Default `true`. */
|
|
81
|
+
canSleep?: boolean;
|
|
82
|
+
/** Enable continuous collision detection (fast small bodies). */
|
|
83
|
+
ccd?: boolean;
|
|
84
|
+
/** Per-body gravity multiplier (0 disables gravity for this body). */
|
|
85
|
+
gravityScale?: number;
|
|
86
|
+
/** Linear velocity damping. */
|
|
87
|
+
linearDamping?: number;
|
|
88
|
+
/** Angular velocity damping. */
|
|
89
|
+
angularDamping?: number;
|
|
90
|
+
/** Lock all rotations. */
|
|
91
|
+
lockRotations?: boolean;
|
|
92
|
+
/** Enable rotation per axis `[x, y, z]`. */
|
|
93
|
+
enabledRotations?: [boolean, boolean, boolean];
|
|
94
|
+
/** Arbitrary user data; see {@link ControllerUserData} for the keys the controllers read. */
|
|
95
|
+
userData?: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One side of a collision/intersection event. */
|
|
99
|
+
export interface CollisionTarget {
|
|
100
|
+
collider: RAPIER.Collider;
|
|
101
|
+
rigidBody: RAPIER.RigidBody | null;
|
|
102
|
+
/** The registered Object3D of the collider's body, if any. */
|
|
103
|
+
object3d: THREE.Object3D | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Payload delivered to per-collider event handlers. */
|
|
107
|
+
export interface CollisionPayload {
|
|
108
|
+
target: CollisionTarget;
|
|
109
|
+
other: CollisionTarget;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Per-collider event handlers. Enter events are disambiguated: solid contacts
|
|
114
|
+
* fire `onCollisionEnter`, sensor overlaps fire `onIntersectionEnter`. Exit
|
|
115
|
+
* events fire BOTH `onCollisionExit` and `onIntersectionExit` — faithful
|
|
116
|
+
* upstream behavior, kept on purpose.
|
|
117
|
+
*/
|
|
118
|
+
export interface ColliderEventHandlers {
|
|
119
|
+
onCollisionEnter?: (payload: CollisionPayload) => void;
|
|
120
|
+
onCollisionExit?: (payload: CollisionPayload) => void;
|
|
121
|
+
/** Sensor overlap began. */
|
|
122
|
+
onIntersectionEnter?: (payload: CollisionPayload) => void;
|
|
123
|
+
/** Sensor overlap ended. */
|
|
124
|
+
onIntersectionExit?: (payload: CollisionPayload) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface BodyState {
|
|
128
|
+
object: THREE.Object3D;
|
|
129
|
+
invertedWorldMatrix: THREE.Matrix4;
|
|
130
|
+
scale: THREE.Vector3;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface PreviousPose {
|
|
134
|
+
position: THREE.Vector3;
|
|
135
|
+
rotation: THREE.Quaternion;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Module-level shared init promise: RAPIER.init() loads the embedded WASM
|
|
139
|
+
// once, no matter how many worlds are created.
|
|
140
|
+
let rapierInitPromise: Promise<void> | null = null;
|
|
141
|
+
|
|
142
|
+
// Scratch objects for the per-frame mesh sync (never escape this module).
|
|
143
|
+
const _matrix4 = new THREE.Matrix4();
|
|
144
|
+
const _position = new THREE.Vector3();
|
|
145
|
+
const _rotation = new THREE.Quaternion();
|
|
146
|
+
const _scale = new THREE.Vector3();
|
|
147
|
+
const _bodyPos = new THREE.Vector3();
|
|
148
|
+
const _bodyRot = new THREE.Quaternion();
|
|
149
|
+
const _identityMatrix = new THREE.Matrix4();
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Owns the Rapier world, the fixed-timestep accumulator loop, the
|
|
153
|
+
* body <-> Object3D registry, and collision-event dispatch.
|
|
154
|
+
*
|
|
155
|
+
* Canonical loop shape (once per rAF frame):
|
|
156
|
+
* ```ts
|
|
157
|
+
* const physics = await PhysicsWorld.create();
|
|
158
|
+
* physics.onBeforeStep(() => controller.update(physics.timeStep));
|
|
159
|
+
* renderer.setAnimationLoop(() => {
|
|
160
|
+
* physics.step(clock.getDelta()); // fixed substeps + mesh sync + events
|
|
161
|
+
* // ...camera + render (render-delta code lives OUT here, never inside)
|
|
162
|
+
* });
|
|
163
|
+
* ```
|
|
164
|
+
* Before-step callbacks fire once per fixed SUBSTEP (before `world.step()`),
|
|
165
|
+
* so controller impulses are consumed by the step immediately following —
|
|
166
|
+
* with `frameRateCorrection = 60 * world.timestep` this preserves the
|
|
167
|
+
* upstream impulse scaling exactly.
|
|
168
|
+
*/
|
|
169
|
+
export class PhysicsWorld {
|
|
170
|
+
/** The raw Rapier world. Controllers receive this, not the wrapper. */
|
|
171
|
+
readonly world: RAPIER.World;
|
|
172
|
+
/** The fixed step in seconds; `world.timestep` is kept equal to it. */
|
|
173
|
+
readonly timeStep: number;
|
|
174
|
+
/** Pause gate — checked before any time accumulates, so no time banks up. */
|
|
175
|
+
paused = false;
|
|
176
|
+
/** Time dilation. 1 = realtime; 0.5 = half-speed slow-mo. */
|
|
177
|
+
timeScale = 1;
|
|
178
|
+
/** Per-frame wall-clock clamp in seconds (see {@link PhysicsWorldOptions.maxDelta}). */
|
|
179
|
+
maxDelta: number;
|
|
180
|
+
/** Interpolate rendered poses between fixed steps. */
|
|
181
|
+
interpolate: boolean;
|
|
182
|
+
|
|
183
|
+
private eventQueue: RAPIER.EventQueue;
|
|
184
|
+
private bodyStates = new Map<number, BodyState>();
|
|
185
|
+
private colliderEvents = new Map<number, ColliderEventHandlers>();
|
|
186
|
+
private beforeStepCallbacks = new Set<(world: RAPIER.World) => void>();
|
|
187
|
+
private afterStepCallbacks = new Set<(world: RAPIER.World) => void>();
|
|
188
|
+
private collisionEventTaps = new Set<
|
|
189
|
+
(handle1: number, handle2: number, started: boolean) => void
|
|
190
|
+
>();
|
|
191
|
+
private accumulator = 0;
|
|
192
|
+
private previousState = new Map<number, PreviousPose>();
|
|
193
|
+
private stepsExecuted = 0;
|
|
194
|
+
private debugLines: THREE.LineSegments | null = null;
|
|
195
|
+
private debugScene: THREE.Scene | null = null;
|
|
196
|
+
|
|
197
|
+
private constructor(options: PhysicsWorldOptions) {
|
|
198
|
+
const gravity = options.gravity ?? [0, -9.81, 0];
|
|
199
|
+
this.timeStep = options.timeStep ?? 1 / 60;
|
|
200
|
+
this.maxDelta = options.maxDelta ?? 1 / 30;
|
|
201
|
+
this.interpolate = options.interpolate ?? true;
|
|
202
|
+
|
|
203
|
+
this.world = new RAPIER.World(
|
|
204
|
+
new RAPIER.Vector3(gravity[0], gravity[1], gravity[2])
|
|
205
|
+
);
|
|
206
|
+
// Integration parameters — upstream physics-wrapper defaults.
|
|
207
|
+
this.world.integrationParameters.numSolverIterations =
|
|
208
|
+
options.numSolverIterations ?? 4;
|
|
209
|
+
this.world.integrationParameters.numInternalPgsIterations =
|
|
210
|
+
options.numInternalPgsIterations ?? 1;
|
|
211
|
+
this.world.integrationParameters.normalizedAllowedLinearError =
|
|
212
|
+
options.allowedLinearError ?? 0.001;
|
|
213
|
+
this.world.integrationParameters.minIslandSize =
|
|
214
|
+
options.minIslandSize ?? 128;
|
|
215
|
+
this.world.integrationParameters.maxCcdSubsteps =
|
|
216
|
+
options.maxCcdSubsteps ?? 1;
|
|
217
|
+
this.world.integrationParameters.normalizedPredictionDistance =
|
|
218
|
+
options.predictionDistance ?? 0.002;
|
|
219
|
+
this.world.lengthUnit = options.lengthUnit ?? 1;
|
|
220
|
+
this.world.integrationParameters.contact_natural_frequency =
|
|
221
|
+
options.contactNaturalFrequency ?? 30;
|
|
222
|
+
|
|
223
|
+
this.world.timestep = this.timeStep;
|
|
224
|
+
this.eventQueue = new RAPIER.EventQueue(false);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a physics world. Awaits `RAPIER.init()` (embedded WASM — no
|
|
229
|
+
* bundler config needed); multiple calls share a single init. Nothing may
|
|
230
|
+
* construct any `RAPIER.*` object before this promise resolves.
|
|
231
|
+
*/
|
|
232
|
+
static async create(options: PhysicsWorldOptions = {}): Promise<PhysicsWorld> {
|
|
233
|
+
if (!rapierInitPromise) rapierInitPromise = RAPIER.init();
|
|
234
|
+
await rapierInitPromise;
|
|
235
|
+
return new PhysicsWorld(options);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- body registry (replaces the JSX <RigidBody> mount/unmount) ----
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create a rigid body from plain options. If `object3d` is given it is
|
|
242
|
+
* registered for interpolated render sync (see {@link registerBody}).
|
|
243
|
+
*/
|
|
244
|
+
createBody(
|
|
245
|
+
options: RigidBodyOptions = {},
|
|
246
|
+
object3d?: THREE.Object3D
|
|
247
|
+
): RAPIER.RigidBody {
|
|
248
|
+
const desc = new RAPIER.RigidBodyDesc(
|
|
249
|
+
rigidBodyTypeFromString(options.type ?? "dynamic")
|
|
250
|
+
);
|
|
251
|
+
desc.canSleep = options.canSleep ?? true;
|
|
252
|
+
if (options.position) {
|
|
253
|
+
desc.setTranslation(
|
|
254
|
+
options.position[0],
|
|
255
|
+
options.position[1],
|
|
256
|
+
options.position[2]
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (options.rotation) {
|
|
260
|
+
const quat = Array.isArray(options.rotation)
|
|
261
|
+
? new THREE.Quaternion().setFromEuler(
|
|
262
|
+
new THREE.Euler(
|
|
263
|
+
options.rotation[0],
|
|
264
|
+
options.rotation[1],
|
|
265
|
+
options.rotation[2],
|
|
266
|
+
"XYZ"
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
: options.rotation;
|
|
270
|
+
desc.setRotation({ x: quat.x, y: quat.y, z: quat.z, w: quat.w });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const body = this.world.createRigidBody(desc);
|
|
274
|
+
// Mutable options, applied in the upstream option-map order.
|
|
275
|
+
if (options.gravityScale !== undefined)
|
|
276
|
+
body.setGravityScale(options.gravityScale, true);
|
|
277
|
+
if (options.linearDamping !== undefined)
|
|
278
|
+
body.setLinearDamping(options.linearDamping);
|
|
279
|
+
if (options.angularDamping !== undefined)
|
|
280
|
+
body.setAngularDamping(options.angularDamping);
|
|
281
|
+
if (options.enabledRotations) {
|
|
282
|
+
body.setEnabledRotations(
|
|
283
|
+
options.enabledRotations[0],
|
|
284
|
+
options.enabledRotations[1],
|
|
285
|
+
options.enabledRotations[2],
|
|
286
|
+
true
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (options.lockRotations !== undefined)
|
|
290
|
+
body.lockRotations(options.lockRotations, true);
|
|
291
|
+
if (options.ccd !== undefined) body.enableCcd(options.ccd);
|
|
292
|
+
if (options.userData !== undefined) body.userData = options.userData;
|
|
293
|
+
|
|
294
|
+
if (object3d) this.registerBody(body, object3d);
|
|
295
|
+
return body;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Register an Object3D to follow `body` (interpolated, once per frame).
|
|
300
|
+
*
|
|
301
|
+
* The parent's inverse world matrix and the object's world scale are
|
|
302
|
+
* captured NOW and never refreshed — register scene-root-level groups
|
|
303
|
+
* (character root, chassis group) and add them to the scene FIRST; if the
|
|
304
|
+
* registered object's parent later moves or scales, the sync silently
|
|
305
|
+
* desyncs.
|
|
306
|
+
*/
|
|
307
|
+
registerBody(body: RAPIER.RigidBody, object3d: THREE.Object3D): void {
|
|
308
|
+
object3d.updateWorldMatrix(true, false);
|
|
309
|
+
const invertedWorldMatrix = object3d.parent
|
|
310
|
+
? object3d.parent.matrixWorld.clone().invert()
|
|
311
|
+
: _identityMatrix.clone();
|
|
312
|
+
this.bodyStates.set(body.handle, {
|
|
313
|
+
object: object3d,
|
|
314
|
+
invertedWorldMatrix,
|
|
315
|
+
scale: object3d.getWorldScale(new THREE.Vector3()).clone(),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Stop syncing the body's Object3D (does not remove the body). */
|
|
320
|
+
unregisterBody(body: RAPIER.RigidBody): void {
|
|
321
|
+
this.bodyStates.delete(body.handle);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Unregister the body's Object3D, drop event handlers for all of its
|
|
326
|
+
* colliders, and remove the body (and its colliders) from the world.
|
|
327
|
+
*/
|
|
328
|
+
removeBody(body: RAPIER.RigidBody): void {
|
|
329
|
+
for (let i = 0; i < body.numColliders(); i++) {
|
|
330
|
+
this.colliderEvents.delete(body.collider(i).handle);
|
|
331
|
+
}
|
|
332
|
+
this.unregisterBody(body);
|
|
333
|
+
this.world.removeRigidBody(body);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The registered Object3D for `body`, or null. */
|
|
337
|
+
getObject3d(body: RAPIER.RigidBody): THREE.Object3D | null {
|
|
338
|
+
return this.bodyStates.get(body.handle)?.object ?? null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- step hooks ----
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Register a callback fired once per fixed SUBSTEP, immediately before
|
|
345
|
+
* `world.step()`. Controllers' `update()` calls live here. Returns an
|
|
346
|
+
* unsubscribe function.
|
|
347
|
+
*/
|
|
348
|
+
onBeforeStep(cb: (world: RAPIER.World) => void): () => void {
|
|
349
|
+
this.beforeStepCallbacks.add(cb);
|
|
350
|
+
return () => {
|
|
351
|
+
this.beforeStepCallbacks.delete(cb);
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Like {@link onBeforeStep} but fired right after `world.step()`. */
|
|
356
|
+
onAfterStep(cb: (world: RAPIER.World) => void): () => void {
|
|
357
|
+
this.afterStepCallbacks.add(cb);
|
|
358
|
+
return () => {
|
|
359
|
+
this.afterStepCallbacks.delete(cb);
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---- collision/intersection events ----
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Attach enter/exit handlers to a collider. Also enables
|
|
367
|
+
* `ActiveEvents.COLLISION_EVENTS` on it — without that flag Rapier never
|
|
368
|
+
* reports the pair, so sensors would stay silent.
|
|
369
|
+
*/
|
|
370
|
+
setColliderEvents(
|
|
371
|
+
collider: RAPIER.Collider,
|
|
372
|
+
handlers: ColliderEventHandlers
|
|
373
|
+
): void {
|
|
374
|
+
collider.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS);
|
|
375
|
+
this.colliderEvents.set(collider.handle, handlers);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Remove the handlers registered via {@link setColliderEvents}. */
|
|
379
|
+
clearColliderEvents(collider: RAPIER.Collider): void {
|
|
380
|
+
this.colliderEvents.delete(collider.handle);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Raw tap fired once per drained collision event (both solid and sensor),
|
|
385
|
+
* during the per-frame drain, BEFORE per-collider handler dispatch.
|
|
386
|
+
* Feed this to `EnterExitManager.handleIntersectionEvent`. Returns an
|
|
387
|
+
* unsubscribe function.
|
|
388
|
+
*/
|
|
389
|
+
onCollisionEvent(
|
|
390
|
+
cb: (handle1: number, handle2: number, started: boolean) => void
|
|
391
|
+
): () => void {
|
|
392
|
+
this.collisionEventTaps.add(cb);
|
|
393
|
+
return () => {
|
|
394
|
+
this.collisionEventTaps.delete(cb);
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- main loop ----
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Advance the simulation. Call once per rAF with the render clock delta in
|
|
402
|
+
* SECONDS. Runs zero or more fixed substeps (before-step callbacks +
|
|
403
|
+
* `world.step()` + after-step callbacks each), then syncs registered
|
|
404
|
+
* Object3Ds (interpolated), drains collision events, and updates the debug
|
|
405
|
+
* lines.
|
|
406
|
+
*/
|
|
407
|
+
step(delta: number): void {
|
|
408
|
+
this.stepsExecuted = 0;
|
|
409
|
+
// Pause gates BEFORE accumulation so unpausing never replays banked time.
|
|
410
|
+
if (this.paused) return;
|
|
411
|
+
|
|
412
|
+
const maxStep = Math.max(0, this.maxDelta);
|
|
413
|
+
const dt = THREE.MathUtils.clamp(delta, 0, maxStep);
|
|
414
|
+
if (this.timeScale <= 0 || dt <= 0) return;
|
|
415
|
+
// Hard safety clamp from the upstream wrapper, kept in addition to
|
|
416
|
+
// maxDelta (different layers; users may raise maxDelta).
|
|
417
|
+
const clampedDelta = THREE.MathUtils.clamp(dt * this.timeScale, 0, 0.5);
|
|
418
|
+
|
|
419
|
+
this.accumulator += clampedDelta;
|
|
420
|
+
while (this.accumulator >= this.timeStep) {
|
|
421
|
+
if (this.interpolate) {
|
|
422
|
+
// Snapshot previous poses — needed for accurate interpolation when
|
|
423
|
+
// the world steps more than once per frame. Values are COPIED out of
|
|
424
|
+
// the WASM-returned objects.
|
|
425
|
+
this.previousState.clear();
|
|
426
|
+
this.world.forEachRigidBody((body) => {
|
|
427
|
+
const t = body.translation();
|
|
428
|
+
const r = body.rotation();
|
|
429
|
+
this.previousState.set(body.handle, {
|
|
430
|
+
position: new THREE.Vector3(t.x, t.y, t.z),
|
|
431
|
+
rotation: new THREE.Quaternion(r.x, r.y, r.z, r.w),
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
this.beforeStepCallbacks.forEach((callback) => {
|
|
436
|
+
callback(this.world);
|
|
437
|
+
});
|
|
438
|
+
// Re-assert the fixed step every substep so user code that fiddled with
|
|
439
|
+
// world.timestep cannot break frameRateCorrection (= 60 * timestep).
|
|
440
|
+
this.world.timestep = this.timeStep;
|
|
441
|
+
this.world.step(this.eventQueue);
|
|
442
|
+
this.afterStepCallbacks.forEach((callback) => {
|
|
443
|
+
callback(this.world);
|
|
444
|
+
});
|
|
445
|
+
this.accumulator -= this.timeStep;
|
|
446
|
+
this.stepsExecuted++;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const interpolationAlpha = !this.interpolate
|
|
450
|
+
? 1
|
|
451
|
+
: this.accumulator / this.timeStep;
|
|
452
|
+
|
|
453
|
+
// Mesh sync: rewind to the previous-tick pose, then lerp toward the
|
|
454
|
+
// current tick by alpha (the upstream interpolation scheme, exactly).
|
|
455
|
+
this.bodyStates.forEach((state, handle) => {
|
|
456
|
+
const body = this.world.getRigidBody(handle);
|
|
457
|
+
if (!body || body.isSleeping()) return;
|
|
458
|
+
|
|
459
|
+
const t = body.translation();
|
|
460
|
+
const r = body.rotation();
|
|
461
|
+
const prev = this.previousState.get(handle);
|
|
462
|
+
if (prev) {
|
|
463
|
+
_matrix4
|
|
464
|
+
.compose(prev.position, prev.rotation, state.scale)
|
|
465
|
+
.premultiply(state.invertedWorldMatrix)
|
|
466
|
+
.decompose(_position, _rotation, _scale);
|
|
467
|
+
state.object.position.copy(_position);
|
|
468
|
+
state.object.quaternion.copy(_rotation);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
_bodyPos.set(t.x, t.y, t.z);
|
|
472
|
+
_bodyRot.set(r.x, r.y, r.z, r.w);
|
|
473
|
+
_matrix4
|
|
474
|
+
.compose(_bodyPos, _bodyRot, state.scale)
|
|
475
|
+
.premultiply(state.invertedWorldMatrix)
|
|
476
|
+
.decompose(_position, _rotation, _scale);
|
|
477
|
+
state.object.position.lerp(_position, interpolationAlpha);
|
|
478
|
+
state.object.quaternion.slerp(_rotation, interpolationAlpha);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Drain collision events ONCE per frame, after the substep loop (the
|
|
482
|
+
// queue accumulates across substeps; per-substep draining would change
|
|
483
|
+
// enter/exit pairing).
|
|
484
|
+
this.eventQueue.drainCollisionEvents((handle1, handle2, started) => {
|
|
485
|
+
this.collisionEventTaps.forEach((tap) => {
|
|
486
|
+
tap(handle1, handle2, started);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const collider1 = this.world.getCollider(handle1);
|
|
490
|
+
const collider2 = this.world.getCollider(handle2);
|
|
491
|
+
if (!collider1 || !collider2) return;
|
|
492
|
+
const handlers1 = this.colliderEvents.get(handle1);
|
|
493
|
+
const handlers2 = this.colliderEvents.get(handle2);
|
|
494
|
+
if (!handlers1 && !handlers2) return;
|
|
495
|
+
|
|
496
|
+
const target1 = this.collisionTargetFor(collider1);
|
|
497
|
+
const target2 = this.collisionTargetFor(collider2);
|
|
498
|
+
const payload1: CollisionPayload = { target: target1, other: target2 };
|
|
499
|
+
const payload2: CollisionPayload = { target: target2, other: target1 };
|
|
500
|
+
|
|
501
|
+
if (started) {
|
|
502
|
+
// Enter fires INSIDE the contactPair callback: pure sensor overlaps
|
|
503
|
+
// (no contact manifold) do NOT fire onCollisionEnter.
|
|
504
|
+
this.world.contactPair(collider1, collider2, () => {
|
|
505
|
+
handlers1?.onCollisionEnter?.(payload1);
|
|
506
|
+
handlers2?.onCollisionEnter?.(payload2);
|
|
507
|
+
});
|
|
508
|
+
if (this.world.intersectionPair(collider1, collider2)) {
|
|
509
|
+
handlers1?.onIntersectionEnter?.(payload1);
|
|
510
|
+
handlers2?.onIntersectionEnter?.(payload2);
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
// An ending event fires BOTH handler families unconditionally —
|
|
514
|
+
// faithful upstream behavior, do not "fix".
|
|
515
|
+
handlers1?.onCollisionExit?.(payload1);
|
|
516
|
+
handlers2?.onCollisionExit?.(payload2);
|
|
517
|
+
handlers1?.onIntersectionExit?.(payload1);
|
|
518
|
+
handlers2?.onIntersectionExit?.(payload2);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
this.renderDebug();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Number of fixed substeps executed by the most recent `step()` call.
|
|
527
|
+
* Wiring uses this to gate once-per-step camera work (e.g. platform turn).
|
|
528
|
+
*/
|
|
529
|
+
get stepsLastFrame(): number {
|
|
530
|
+
return this.stepsExecuted;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---- debug ----
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Add a wireframe rendering of every collider to `scene`. Costs CPU/GPU —
|
|
537
|
+
* keep it off in shipped games; great while tuning colliders.
|
|
538
|
+
*/
|
|
539
|
+
enableDebug(scene: THREE.Scene): void {
|
|
540
|
+
if (this.debugLines) return;
|
|
541
|
+
const lines = new THREE.LineSegments(
|
|
542
|
+
new THREE.BufferGeometry(),
|
|
543
|
+
new THREE.LineBasicMaterial({ color: 0xffffff, vertexColors: true })
|
|
544
|
+
);
|
|
545
|
+
lines.frustumCulled = false;
|
|
546
|
+
scene.add(lines);
|
|
547
|
+
this.debugLines = lines;
|
|
548
|
+
this.debugScene = scene;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Remove and dispose the debug lines. */
|
|
552
|
+
disableDebug(): void {
|
|
553
|
+
if (!this.debugLines) return;
|
|
554
|
+
this.debugScene?.remove(this.debugLines);
|
|
555
|
+
this.debugLines.geometry.dispose();
|
|
556
|
+
const material = this.debugLines.material;
|
|
557
|
+
if (Array.isArray(material)) material.forEach((m) => m.dispose());
|
|
558
|
+
else material.dispose();
|
|
559
|
+
this.debugLines = null;
|
|
560
|
+
this.debugScene = null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
get debugEnabled(): boolean {
|
|
564
|
+
return this.debugLines !== null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Free the Rapier world + event queue and clear every registry. */
|
|
568
|
+
dispose(): void {
|
|
569
|
+
this.disableDebug();
|
|
570
|
+
this.bodyStates.clear();
|
|
571
|
+
this.colliderEvents.clear();
|
|
572
|
+
this.beforeStepCallbacks.clear();
|
|
573
|
+
this.afterStepCallbacks.clear();
|
|
574
|
+
this.collisionEventTaps.clear();
|
|
575
|
+
this.previousState.clear();
|
|
576
|
+
this.eventQueue.free();
|
|
577
|
+
this.world.free();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---- internals ----
|
|
581
|
+
|
|
582
|
+
private collisionTargetFor(collider: RAPIER.Collider): CollisionTarget {
|
|
583
|
+
const rigidBody = collider.parent();
|
|
584
|
+
return {
|
|
585
|
+
collider,
|
|
586
|
+
rigidBody,
|
|
587
|
+
object3d: rigidBody
|
|
588
|
+
? (this.bodyStates.get(rigidBody.handle)?.object ?? null)
|
|
589
|
+
: null,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private renderDebug(): void {
|
|
594
|
+
if (!this.debugLines) return;
|
|
595
|
+
// debugRender() allocates fresh buffers every call — dispose the previous
|
|
596
|
+
// geometry each frame or leak GPU memory.
|
|
597
|
+
const buffers = this.world.debugRender();
|
|
598
|
+
const geometry = new THREE.BufferGeometry();
|
|
599
|
+
geometry.setAttribute(
|
|
600
|
+
"position",
|
|
601
|
+
new THREE.BufferAttribute(buffers.vertices, 3)
|
|
602
|
+
);
|
|
603
|
+
geometry.setAttribute("color", new THREE.BufferAttribute(buffers.colors, 4));
|
|
604
|
+
this.debugLines.geometry.dispose();
|
|
605
|
+
this.debugLines.geometry = geometry;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function rigidBodyTypeFromString(
|
|
610
|
+
type: NonNullable<RigidBodyOptions["type"]>
|
|
611
|
+
): RAPIER.RigidBodyType {
|
|
612
|
+
switch (type) {
|
|
613
|
+
case "dynamic":
|
|
614
|
+
return RAPIER.RigidBodyType.Dynamic;
|
|
615
|
+
case "fixed":
|
|
616
|
+
return RAPIER.RigidBodyType.Fixed;
|
|
617
|
+
case "kinematicPosition":
|
|
618
|
+
return RAPIER.RigidBodyType.KinematicPositionBased;
|
|
619
|
+
case "kinematicVelocity":
|
|
620
|
+
return RAPIER.RigidBodyType.KinematicVelocityBased;
|
|
621
|
+
}
|
|
622
|
+
}
|