@genex-ai/cli-demo 0.12.1 → 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-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,456 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TS port of the ecctrl controller (collider glue: re-implements the
|
|
4
|
+
// mesh -> collider auto-generation and the explicit collider helpers that the
|
|
5
|
+
// upstream React components received from their React physics wrapper,
|
|
6
|
+
// @react-three/rapier v2.2.0). One deliberate substitution: `mergeVertices`
|
|
7
|
+
// comes from `three/addons/utils/BufferGeometryUtils.js` (ships inside the
|
|
8
|
+
// `three` package) instead of the upstream wrapper's `three-stdlib` — same
|
|
9
|
+
// function, no extra dependency.
|
|
10
|
+
// All helpers assume `PhysicsWorld.create()` (i.e. RAPIER.init) has already
|
|
11
|
+
// resolved — constructing a ColliderDesc before WASM init throws.
|
|
12
|
+
|
|
13
|
+
import * as THREE from "three";
|
|
14
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
15
|
+
import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Auto-collider shape for {@link collidersFromObject}.
|
|
19
|
+
* - `"cuboid"`: bounding box per mesh — cheapest, fine for crates/walls.
|
|
20
|
+
* - `"ball"`: bounding sphere per mesh.
|
|
21
|
+
* - `"hull"`: convex hull per mesh — good default for `genex model` props
|
|
22
|
+
* (tight fit, still convex/fast).
|
|
23
|
+
* - `"trimesh"`: exact triangle mesh — for static level geometry only; never
|
|
24
|
+
* put a trimesh on a fast dynamic body (tunneling).
|
|
25
|
+
*/
|
|
26
|
+
export type AutoColliderShape = "cuboid" | "ball" | "hull" | "trimesh";
|
|
27
|
+
|
|
28
|
+
/** Options applied to every created collider. */
|
|
29
|
+
export interface ColliderOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Friction coefficient. NEGATIVE values are legal and load-bearing: the
|
|
32
|
+
* character capsule ships with friction -0.5 because its traction is
|
|
33
|
+
* synthetic (the controller applies its own grip impulses). Do not clamp.
|
|
34
|
+
*/
|
|
35
|
+
friction?: number;
|
|
36
|
+
/** Bounciness, 0 (dead) to 1 (superball). */
|
|
37
|
+
restitution?: number;
|
|
38
|
+
/** Mass density (kg/m^3). Mutually exclusive with `mass`. */
|
|
39
|
+
density?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Explicit mass in kg. Mutually exclusive with `density`. Use `mass: 0` on
|
|
42
|
+
* sensor colliders so they add no mass to the vehicle.
|
|
43
|
+
*/
|
|
44
|
+
mass?: number;
|
|
45
|
+
/** Sensor colliders detect overlaps but produce no contact forces. */
|
|
46
|
+
sensor?: boolean;
|
|
47
|
+
/** Rapier collision-groups bitmask. */
|
|
48
|
+
collisionGroups?: number;
|
|
49
|
+
/** Rapier solver-groups bitmask. */
|
|
50
|
+
solverGroups?: number;
|
|
51
|
+
/** Extra contact skin thickness (helps jitter at the cost of visual gap). */
|
|
52
|
+
contactSkin?: number;
|
|
53
|
+
/** `RAPIER.ActiveCollisionTypes` bitmask. */
|
|
54
|
+
activeCollisionTypes?: number;
|
|
55
|
+
frictionCombineRule?: RAPIER.CoefficientCombineRule;
|
|
56
|
+
restitutionCombineRule?: RAPIER.CoefficientCombineRule;
|
|
57
|
+
/** Translation of the collider relative to its parent body. */
|
|
58
|
+
position?: [number, number, number];
|
|
59
|
+
/** Rotation (euler XYZ, radians) relative to its parent body. */
|
|
60
|
+
rotation?: [number, number, number];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Exact upstream error string (we support density/mass; massProperties is not ported).
|
|
64
|
+
const massPropertiesConflictError =
|
|
65
|
+
"Please pick ONLY ONE of the `density`, `mass` and `massProperties` options.";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Auto-generate one collider per visible mesh under `object3d` — the vanilla
|
|
69
|
+
* equivalent of the upstream `<RigidBody colliders="...">` prop. Works on GLB
|
|
70
|
+
* scenes, including `genex model` output.
|
|
71
|
+
*
|
|
72
|
+
* `object3d` must be the SAME object registered to `body` (their frames must
|
|
73
|
+
* coincide); call this right after creating the body, before the first step.
|
|
74
|
+
* Pass `includeInvisible: true` to also process hidden meshes.
|
|
75
|
+
*/
|
|
76
|
+
export function collidersFromObject(
|
|
77
|
+
world: RAPIER.World,
|
|
78
|
+
body: RAPIER.RigidBody,
|
|
79
|
+
object3d: THREE.Object3D,
|
|
80
|
+
shape: AutoColliderShape = "cuboid",
|
|
81
|
+
options: ColliderOptions & { includeInvisible?: boolean } = {}
|
|
82
|
+
): RAPIER.Collider[] {
|
|
83
|
+
const colliders: RAPIER.Collider[] = [];
|
|
84
|
+
object3d.updateWorldMatrix(true, false);
|
|
85
|
+
const invertedRootMatrix = object3d.matrixWorld.clone().invert();
|
|
86
|
+
const rootWorldScale = object3d.getWorldScale(new THREE.Vector3());
|
|
87
|
+
|
|
88
|
+
const colliderFromChild = (child: THREE.Object3D) => {
|
|
89
|
+
if (!("isMesh" in child)) return;
|
|
90
|
+
const mesh = child as THREE.Mesh;
|
|
91
|
+
|
|
92
|
+
const worldScale = mesh.getWorldScale(new THREE.Vector3());
|
|
93
|
+
mesh.updateWorldMatrix(true, false);
|
|
94
|
+
const relPosition = new THREE.Vector3();
|
|
95
|
+
const relRotation = new THREE.Quaternion();
|
|
96
|
+
const relScale = new THREE.Vector3();
|
|
97
|
+
new THREE.Matrix4()
|
|
98
|
+
.copy(mesh.matrixWorld)
|
|
99
|
+
.premultiply(invertedRootMatrix)
|
|
100
|
+
.decompose(relPosition, relRotation, relScale);
|
|
101
|
+
|
|
102
|
+
// Collider ARGS (half-extents/radius/vertices) are scaled by
|
|
103
|
+
// childWorldScale * rootWorldScale — exactly upstream, where the
|
|
104
|
+
// collider's object3D carries `scale = childWorldScale` (the child's
|
|
105
|
+
// ABSOLUTE world scale, root scale included) and sits under the
|
|
106
|
+
// registered root, so its getWorldScale() = rootScale * childWorldScale.
|
|
107
|
+
// Upstream quirk faithfully replicated, not fixed: the root scale is
|
|
108
|
+
// double-counted in the collider size, so with a root scaled 2x the
|
|
109
|
+
// colliders come out 2x larger than the rendered meshes.
|
|
110
|
+
const argsScale = worldScale.clone().multiply(rootWorldScale);
|
|
111
|
+
const { desc, offset } = descFromGeometry(mesh, shape, argsScale);
|
|
112
|
+
// Placement wrt the body: (relative pose plus the geometry's own offset
|
|
113
|
+
// scaled by the mesh's world scale), the whole sum then scaled by the
|
|
114
|
+
// ROOT object's world scale — exactly upstream, where the auto-collider
|
|
115
|
+
// props store `relPosition + offset * childWorldScale` and the collider
|
|
116
|
+
// setup then multiplies the position by the registered root's world
|
|
117
|
+
// scale. Upstream quirk faithfully replicated, not fixed: the offset
|
|
118
|
+
// term ends up scaled twice (childWorldScale already contains the root
|
|
119
|
+
// scale), same policy as the ball `radius * scale.x` quirk below.
|
|
120
|
+
desc.setTranslation(
|
|
121
|
+
(relPosition.x + offset.x * worldScale.x) * rootWorldScale.x,
|
|
122
|
+
(relPosition.y + offset.y * worldScale.y) * rootWorldScale.y,
|
|
123
|
+
(relPosition.z + offset.z * worldScale.z) * rootWorldScale.z
|
|
124
|
+
);
|
|
125
|
+
desc.setRotation({
|
|
126
|
+
x: relRotation.x,
|
|
127
|
+
y: relRotation.y,
|
|
128
|
+
z: relRotation.z,
|
|
129
|
+
w: relRotation.w,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const collider = world.createCollider(desc, body);
|
|
133
|
+
applyColliderOptions(collider, options);
|
|
134
|
+
colliders.push(collider);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (options.includeInvisible) object3d.traverse(colliderFromChild);
|
|
138
|
+
else object3d.traverseVisible(colliderFromChild);
|
|
139
|
+
|
|
140
|
+
return colliders;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Attach a box collider. `halfExtents` are HALF sizes — a `[1, 0.4, 2.4]`
|
|
145
|
+
* collider is 2 x 0.8 x 4.8 units. Do not halve twice.
|
|
146
|
+
*/
|
|
147
|
+
export function cuboidCollider(
|
|
148
|
+
world: RAPIER.World,
|
|
149
|
+
body: RAPIER.RigidBody,
|
|
150
|
+
halfExtents: [number, number, number],
|
|
151
|
+
options: ColliderOptions = {}
|
|
152
|
+
): RAPIER.Collider {
|
|
153
|
+
return createFromDesc(
|
|
154
|
+
world,
|
|
155
|
+
body,
|
|
156
|
+
RAPIER.ColliderDesc.cuboid(halfExtents[0], halfExtents[1], halfExtents[2]),
|
|
157
|
+
options
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Attach a sphere collider. */
|
|
162
|
+
export function ballCollider(
|
|
163
|
+
world: RAPIER.World,
|
|
164
|
+
body: RAPIER.RigidBody,
|
|
165
|
+
radius: number,
|
|
166
|
+
options: ColliderOptions = {}
|
|
167
|
+
): RAPIER.Collider {
|
|
168
|
+
return createFromDesc(world, body, RAPIER.ColliderDesc.ball(radius), options);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Attach a capsule collider. Rapier arg order is `(halfHeight, radius)` — the
|
|
173
|
+
* REVERSE of `THREE.CapsuleGeometry(radius, length)` — and `halfHeight`
|
|
174
|
+
* covers the CYLINDRICAL section only: total height is
|
|
175
|
+
* `2 * halfHeight + 2 * radius` (so `[0.3, 0.3]` is 1.2 units tall).
|
|
176
|
+
*/
|
|
177
|
+
export function capsuleCollider(
|
|
178
|
+
world: RAPIER.World,
|
|
179
|
+
body: RAPIER.RigidBody,
|
|
180
|
+
halfHeight: number,
|
|
181
|
+
radius: number,
|
|
182
|
+
options: ColliderOptions = {}
|
|
183
|
+
): RAPIER.Collider {
|
|
184
|
+
return createFromDesc(
|
|
185
|
+
world,
|
|
186
|
+
body,
|
|
187
|
+
RAPIER.ColliderDesc.capsule(halfHeight, radius),
|
|
188
|
+
options
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Attach a cylinder collider (arg order `(halfHeight, radius)`, same caveat
|
|
194
|
+
* as {@link capsuleCollider}). The vehicle enter/exit sensors use this shape.
|
|
195
|
+
*/
|
|
196
|
+
export function cylinderCollider(
|
|
197
|
+
world: RAPIER.World,
|
|
198
|
+
body: RAPIER.RigidBody,
|
|
199
|
+
halfHeight: number,
|
|
200
|
+
radius: number,
|
|
201
|
+
options: ColliderOptions = {}
|
|
202
|
+
): RAPIER.Collider {
|
|
203
|
+
return createFromDesc(
|
|
204
|
+
world,
|
|
205
|
+
body,
|
|
206
|
+
RAPIER.ColliderDesc.cylinder(halfHeight, radius),
|
|
207
|
+
options
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Attach an exact triangle-mesh collider built from `mesh` (transform
|
|
213
|
+
* relative to the body's registered object is baked in, world scale applied
|
|
214
|
+
* to the vertices). Static level geometry only — trimeshes are hollow and
|
|
215
|
+
* expensive to collide against for fast dynamic bodies.
|
|
216
|
+
*/
|
|
217
|
+
export function trimeshColliderFromMesh(
|
|
218
|
+
world: RAPIER.World,
|
|
219
|
+
body: RAPIER.RigidBody,
|
|
220
|
+
mesh: THREE.Mesh,
|
|
221
|
+
options: ColliderOptions = {}
|
|
222
|
+
): RAPIER.Collider {
|
|
223
|
+
return meshColliderFromMesh(world, body, mesh, "trimesh", options);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Attach a convex-hull collider built from `mesh` — the best default for
|
|
228
|
+
* dynamic props from `genex model` GLBs (tight fit, fast, solid).
|
|
229
|
+
*/
|
|
230
|
+
export function convexHullColliderFromMesh(
|
|
231
|
+
world: RAPIER.World,
|
|
232
|
+
body: RAPIER.RigidBody,
|
|
233
|
+
mesh: THREE.Mesh,
|
|
234
|
+
options: ColliderOptions = {}
|
|
235
|
+
): RAPIER.Collider {
|
|
236
|
+
return meshColliderFromMesh(world, body, mesh, "hull", options);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Apply mutable options to an existing collider (upstream option order).
|
|
241
|
+
* Call again manually if you tune values at runtime. `density` and `mass`
|
|
242
|
+
* are mutually exclusive — picking both throws, exactly like upstream.
|
|
243
|
+
*/
|
|
244
|
+
export function applyColliderOptions(
|
|
245
|
+
collider: RAPIER.Collider,
|
|
246
|
+
options: ColliderOptions
|
|
247
|
+
): void {
|
|
248
|
+
if (options.sensor !== undefined) collider.setSensor(options.sensor);
|
|
249
|
+
if (options.collisionGroups !== undefined)
|
|
250
|
+
collider.setCollisionGroups(options.collisionGroups);
|
|
251
|
+
if (options.solverGroups !== undefined)
|
|
252
|
+
collider.setSolverGroups(options.solverGroups);
|
|
253
|
+
if (options.friction !== undefined) collider.setFriction(options.friction);
|
|
254
|
+
if (options.frictionCombineRule !== undefined)
|
|
255
|
+
collider.setFrictionCombineRule(options.frictionCombineRule);
|
|
256
|
+
if (options.restitution !== undefined)
|
|
257
|
+
collider.setRestitution(options.restitution);
|
|
258
|
+
if (options.restitutionCombineRule !== undefined)
|
|
259
|
+
collider.setRestitutionCombineRule(options.restitutionCombineRule);
|
|
260
|
+
if (options.activeCollisionTypes !== undefined)
|
|
261
|
+
collider.setActiveCollisionTypes(options.activeCollisionTypes);
|
|
262
|
+
if (options.contactSkin !== undefined)
|
|
263
|
+
collider.setContactSkin(options.contactSkin);
|
|
264
|
+
|
|
265
|
+
// Mass LAST, and exclusively.
|
|
266
|
+
if (options.density !== undefined) {
|
|
267
|
+
if (options.mass !== undefined) {
|
|
268
|
+
throw new Error(massPropertiesConflictError);
|
|
269
|
+
}
|
|
270
|
+
collider.setDensity(options.density);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (options.mass !== undefined) {
|
|
274
|
+
collider.setMass(options.mass);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- internals ----
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Shared body of trimesh/hull mesh helpers: bakes the mesh's current world
|
|
282
|
+
* transform relative to the body's current pose (so the collider lands where
|
|
283
|
+
* the mesh renders), scales vertices by the mesh's world scale, then defers
|
|
284
|
+
* to {@link createFromDesc} (explicit `options.position`/`rotation` override
|
|
285
|
+
* the baked pose).
|
|
286
|
+
*/
|
|
287
|
+
function meshColliderFromMesh(
|
|
288
|
+
world: RAPIER.World,
|
|
289
|
+
body: RAPIER.RigidBody,
|
|
290
|
+
mesh: THREE.Mesh,
|
|
291
|
+
shape: "trimesh" | "hull",
|
|
292
|
+
options: ColliderOptions
|
|
293
|
+
): RAPIER.Collider {
|
|
294
|
+
mesh.updateWorldMatrix(true, false);
|
|
295
|
+
const worldScale = mesh.getWorldScale(new THREE.Vector3());
|
|
296
|
+
|
|
297
|
+
const t = body.translation();
|
|
298
|
+
const r = body.rotation();
|
|
299
|
+
const invertedBodyMatrix = new THREE.Matrix4()
|
|
300
|
+
.compose(
|
|
301
|
+
new THREE.Vector3(t.x, t.y, t.z),
|
|
302
|
+
new THREE.Quaternion(r.x, r.y, r.z, r.w),
|
|
303
|
+
new THREE.Vector3(1, 1, 1)
|
|
304
|
+
)
|
|
305
|
+
.invert();
|
|
306
|
+
const relPosition = new THREE.Vector3();
|
|
307
|
+
const relRotation = new THREE.Quaternion();
|
|
308
|
+
const relScale = new THREE.Vector3();
|
|
309
|
+
new THREE.Matrix4()
|
|
310
|
+
.copy(mesh.matrixWorld)
|
|
311
|
+
.premultiply(invertedBodyMatrix)
|
|
312
|
+
.decompose(relPosition, relRotation, relScale);
|
|
313
|
+
|
|
314
|
+
const { desc } = descFromGeometry(mesh, shape, worldScale);
|
|
315
|
+
desc.setTranslation(relPosition.x, relPosition.y, relPosition.z);
|
|
316
|
+
desc.setRotation({
|
|
317
|
+
x: relRotation.x,
|
|
318
|
+
y: relRotation.y,
|
|
319
|
+
z: relRotation.z,
|
|
320
|
+
w: relRotation.w,
|
|
321
|
+
});
|
|
322
|
+
return createFromDesc(world, body, desc, options);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function createFromDesc(
|
|
326
|
+
world: RAPIER.World,
|
|
327
|
+
body: RAPIER.RigidBody,
|
|
328
|
+
desc: RAPIER.ColliderDesc,
|
|
329
|
+
options: ColliderOptions
|
|
330
|
+
): RAPIER.Collider {
|
|
331
|
+
if (options.position) {
|
|
332
|
+
desc.setTranslation(
|
|
333
|
+
options.position[0],
|
|
334
|
+
options.position[1],
|
|
335
|
+
options.position[2]
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (options.rotation) {
|
|
339
|
+
const quat = new THREE.Quaternion().setFromEuler(
|
|
340
|
+
new THREE.Euler(
|
|
341
|
+
options.rotation[0],
|
|
342
|
+
options.rotation[1],
|
|
343
|
+
options.rotation[2],
|
|
344
|
+
"XYZ"
|
|
345
|
+
)
|
|
346
|
+
);
|
|
347
|
+
desc.setRotation({ x: quat.x, y: quat.y, z: quat.z, w: quat.w });
|
|
348
|
+
}
|
|
349
|
+
const collider = world.createCollider(desc, body);
|
|
350
|
+
applyColliderOptions(collider, options);
|
|
351
|
+
return collider;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Build a ColliderDesc from a mesh's geometry for the given auto shape,
|
|
356
|
+
* mirroring the upstream args + scaling exactly (`scale` is whatever scale
|
|
357
|
+
* the caller wants baked into the args — the auto-collider path passes
|
|
358
|
+
* childWorldScale * rootWorldScale to replicate upstream's double-counted
|
|
359
|
+
* root scale; the explicit mesh helpers pass the mesh's world scale):
|
|
360
|
+
* - cuboid: bounding-box half extents, scaled per-axis; offset = box center.
|
|
361
|
+
* - ball: bounding-sphere radius scaled by `scale.x` ONLY (upstream quirk —
|
|
362
|
+
* non-uniformly scaled spheres are wrong upstream too; replicated, not
|
|
363
|
+
* fixed); offset = sphere center.
|
|
364
|
+
* - trimesh/hull: vertices scaled component-wise by `scale`; no offset.
|
|
365
|
+
*/
|
|
366
|
+
function descFromGeometry(
|
|
367
|
+
mesh: THREE.Mesh,
|
|
368
|
+
shape: AutoColliderShape,
|
|
369
|
+
scale: THREE.Vector3
|
|
370
|
+
): { desc: RAPIER.ColliderDesc; offset: THREE.Vector3 } {
|
|
371
|
+
const geometry = mesh.geometry;
|
|
372
|
+
switch (shape) {
|
|
373
|
+
case "cuboid": {
|
|
374
|
+
geometry.computeBoundingBox();
|
|
375
|
+
const boundingBox = geometry.boundingBox;
|
|
376
|
+
if (!boundingBox)
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Could not compute a bounding box for mesh "${mesh.name}"`
|
|
379
|
+
);
|
|
380
|
+
const size = boundingBox.getSize(new THREE.Vector3());
|
|
381
|
+
return {
|
|
382
|
+
desc: RAPIER.ColliderDesc.cuboid(
|
|
383
|
+
(size.x / 2) * scale.x,
|
|
384
|
+
(size.y / 2) * scale.y,
|
|
385
|
+
(size.z / 2) * scale.z
|
|
386
|
+
),
|
|
387
|
+
offset: boundingBox.getCenter(new THREE.Vector3()),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
case "ball": {
|
|
391
|
+
geometry.computeBoundingSphere();
|
|
392
|
+
const boundingSphere = geometry.boundingSphere;
|
|
393
|
+
if (!boundingSphere)
|
|
394
|
+
throw new Error(
|
|
395
|
+
`Could not compute a bounding sphere for mesh "${mesh.name}"`
|
|
396
|
+
);
|
|
397
|
+
return {
|
|
398
|
+
desc: RAPIER.ColliderDesc.ball(boundingSphere.radius * scale.x),
|
|
399
|
+
offset: boundingSphere.center.clone(),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
case "trimesh": {
|
|
403
|
+
// Non-indexed geometry is WELDED via mergeVertices (upstream behavior)
|
|
404
|
+
// rather than given a fabricated sequential index — welding removes
|
|
405
|
+
// duplicate vertices and matters for internal-edge behavior.
|
|
406
|
+
const clonedGeometry = geometry.index
|
|
407
|
+
? geometry.clone()
|
|
408
|
+
: mergeVertices(geometry);
|
|
409
|
+
const index = clonedGeometry.index;
|
|
410
|
+
if (!index)
|
|
411
|
+
throw new Error(
|
|
412
|
+
`Could not build a triangle index for mesh "${mesh.name}"`
|
|
413
|
+
);
|
|
414
|
+
const desc = RAPIER.ColliderDesc.trimesh(
|
|
415
|
+
scaledPositions(clonedGeometry, scale),
|
|
416
|
+
new Uint32Array(index.array)
|
|
417
|
+
);
|
|
418
|
+
return { desc, offset: new THREE.Vector3() };
|
|
419
|
+
}
|
|
420
|
+
case "hull": {
|
|
421
|
+
const clonedGeometry = geometry.clone();
|
|
422
|
+
const desc = RAPIER.ColliderDesc.convexHull(
|
|
423
|
+
scaledPositions(clonedGeometry, scale)
|
|
424
|
+
);
|
|
425
|
+
if (!desc)
|
|
426
|
+
throw new Error(
|
|
427
|
+
`Failed to build a convex hull for mesh "${mesh.name}" ` +
|
|
428
|
+
"(degenerate or coplanar geometry?)"
|
|
429
|
+
);
|
|
430
|
+
return { desc, offset: new THREE.Vector3() };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Copy the position attribute into a fresh Float32Array, scaled
|
|
437
|
+
* component-wise by the mesh's world scale. Reads via getX/getY/getZ so
|
|
438
|
+
* interleaved buffer attributes cannot silently corrupt the vertex data
|
|
439
|
+
* (raw `.array` access on an interleaved attribute returns the whole
|
|
440
|
+
* interleaved buffer).
|
|
441
|
+
*/
|
|
442
|
+
function scaledPositions(
|
|
443
|
+
geometry: THREE.BufferGeometry,
|
|
444
|
+
scale: THREE.Vector3
|
|
445
|
+
): Float32Array {
|
|
446
|
+
const attribute = geometry.attributes.position;
|
|
447
|
+
if (!attribute)
|
|
448
|
+
throw new Error("Geometry has no position attribute to build a collider from");
|
|
449
|
+
const out = new Float32Array(attribute.count * 3);
|
|
450
|
+
for (let i = 0; i < attribute.count; i++) {
|
|
451
|
+
out[i * 3] = attribute.getX(i) * scale.x;
|
|
452
|
+
out[i * 3 + 1] = attribute.getY(i) * scale.y;
|
|
453
|
+
out[i * 3 + 2] = attribute.getZ(i) * scale.z;
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TS port of the ecctrl controller (pure math helpers: value remapping,
|
|
4
|
+
// antipodal-safe vector slerp, and the weighted-Hermite curve LUT used by the
|
|
5
|
+
// tire slip curves, engine torque curve, steer-angle curve, and the character's
|
|
6
|
+
// platform mass-ratio falloff).
|
|
7
|
+
|
|
8
|
+
import * as THREE from "three";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Linearly remap `value` from the range [inMin, inMax] to [outMin, outMax].
|
|
12
|
+
*
|
|
13
|
+
* Deliberately does NOT clamp — out-of-range input extrapolates linearly.
|
|
14
|
+
* The tire static-friction blend relies on this pass-through behavior.
|
|
15
|
+
*/
|
|
16
|
+
export const remap = (
|
|
17
|
+
value: number,
|
|
18
|
+
inMin: number,
|
|
19
|
+
inMax: number,
|
|
20
|
+
outMin: number,
|
|
21
|
+
outMax: number
|
|
22
|
+
) => {
|
|
23
|
+
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a tunable falloff curve `x => a * exp(-((x + c) / d) ** t) + b`.
|
|
28
|
+
*
|
|
29
|
+
* Tuning hints: `a` sets the peak height, `b` the floor the curve settles to,
|
|
30
|
+
* `c` shifts the curve left/right, `d` stretches it wider, and `t` controls
|
|
31
|
+
* how sharply it drops (higher = steeper cliff).
|
|
32
|
+
*/
|
|
33
|
+
export const dynamicCurve = (
|
|
34
|
+
a: number,
|
|
35
|
+
b: number,
|
|
36
|
+
c: number,
|
|
37
|
+
d: number,
|
|
38
|
+
t: number
|
|
39
|
+
) => {
|
|
40
|
+
return (x: number) => a * Math.exp(-Math.pow((x + c) / d, t)) + b;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a spherical-lerp function for unit direction vectors that stays
|
|
45
|
+
* stable even when `start` and `end` are nearly opposite (the antipodal case
|
|
46
|
+
* picks a perpendicular rotation axis instead of collapsing to zero).
|
|
47
|
+
*
|
|
48
|
+
* Used for smooth gravity-direction changes on characters and vehicles.
|
|
49
|
+
*
|
|
50
|
+
* IMPORTANT: the returned function reuses ONE preallocated result vector —
|
|
51
|
+
* every call returns the same mutated `THREE.Vector3` instance. Callers must
|
|
52
|
+
* `.copy()` the result immediately. Create one factory instance per consumer
|
|
53
|
+
* (e.g. one class field per controller); do not share across controllers.
|
|
54
|
+
*/
|
|
55
|
+
export const createSlerpVec3 = () => {
|
|
56
|
+
const startClone = new THREE.Vector3();
|
|
57
|
+
const relativeVec = new THREE.Vector3();
|
|
58
|
+
const resultVec3 = new THREE.Vector3();
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
start: THREE.Vector3,
|
|
62
|
+
end: THREE.Vector3,
|
|
63
|
+
percent: number,
|
|
64
|
+
refAxis?: THREE.Vector3
|
|
65
|
+
) => {
|
|
66
|
+
const dot = THREE.MathUtils.clamp(start.dot(end), -1, 1);
|
|
67
|
+
|
|
68
|
+
// When vectors are nearly opposite, find a stable perpendicular vector
|
|
69
|
+
if (Math.abs(dot + 1) < 0.001) {
|
|
70
|
+
// Choose a stable perpendicular axis
|
|
71
|
+
if (refAxis && Math.abs(refAxis.dot(start)) < 0.99) {
|
|
72
|
+
relativeVec.copy(refAxis).normalize();
|
|
73
|
+
} else {
|
|
74
|
+
if (Math.abs(start.y) > 0.99) {
|
|
75
|
+
relativeVec.set(1, 0, 0);
|
|
76
|
+
} else if (Math.abs(start.x) > 0.99) {
|
|
77
|
+
relativeVec.set(0, 1, 0);
|
|
78
|
+
} else {
|
|
79
|
+
relativeVec.set(0, 0, 1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Compute orthogonal vector
|
|
83
|
+
relativeVec.cross(start).normalize();
|
|
84
|
+
const theta = Math.PI * percent;
|
|
85
|
+
resultVec3
|
|
86
|
+
.copy(start)
|
|
87
|
+
.multiplyScalar(Math.cos(theta))
|
|
88
|
+
.addScaledVector(relativeVec, Math.sin(theta));
|
|
89
|
+
} else {
|
|
90
|
+
const theta = Math.acos(dot) * percent;
|
|
91
|
+
relativeVec
|
|
92
|
+
.copy(end)
|
|
93
|
+
.sub(startClone.copy(start).multiplyScalar(dot))
|
|
94
|
+
.normalize();
|
|
95
|
+
resultVec3
|
|
96
|
+
.copy(start)
|
|
97
|
+
.multiplyScalar(Math.cos(theta))
|
|
98
|
+
.addScaledVector(relativeVec, Math.sin(theta));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return resultVec3.normalize();
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One control point of a weighted-Hermite curve.
|
|
107
|
+
*
|
|
108
|
+
* `r_in`/`r_out` are tangent ANGLES in radians (converted to slopes via
|
|
109
|
+
* `Math.tan`), not slopes. `w_in`/`w_out` blend each user tangent toward the
|
|
110
|
+
* segment's straight-line slope: weight 0 = straight line, weight 1 = full
|
|
111
|
+
* user tangent (default 1). Omitted tangents default to flat (slope 0).
|
|
112
|
+
*/
|
|
113
|
+
export type CurvePoint = {
|
|
114
|
+
x: number;
|
|
115
|
+
y: number;
|
|
116
|
+
r_in?: number;
|
|
117
|
+
r_out?: number;
|
|
118
|
+
w_in?: number;
|
|
119
|
+
w_out?: number;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** A baked curve: uniformly sampled lookup table over [xMin, xMax]. */
|
|
123
|
+
export type CurveLUT = {
|
|
124
|
+
lut: Float32Array;
|
|
125
|
+
xMin: number;
|
|
126
|
+
xMax: number;
|
|
127
|
+
samples: number;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Serializable curve definition: control points plus optional sample count. */
|
|
131
|
+
export type CurveData = {
|
|
132
|
+
points: CurvePoint[];
|
|
133
|
+
samples?: number;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Weighted cubic Hermite curve functions.
|
|
138
|
+
* Weight blends each user tangent toward the segment linear slope:
|
|
139
|
+
* weight 0 = straight line, weight 1 = user tangent.
|
|
140
|
+
*/
|
|
141
|
+
function evalHermiteSegment(p0: CurvePoint, p1: CurvePoint, x: number) {
|
|
142
|
+
const x0 = p0.x;
|
|
143
|
+
const x1 = p1.x;
|
|
144
|
+
const dx = x1 - x0;
|
|
145
|
+
if (dx <= 0) return p0.y; // fallback if points overlap
|
|
146
|
+
|
|
147
|
+
const t = (x - x0) / dx;
|
|
148
|
+
const t2 = t * t;
|
|
149
|
+
const t3 = t2 * t;
|
|
150
|
+
|
|
151
|
+
// Cubic Hermite basis functions
|
|
152
|
+
const h00 = 2 * t3 - 3 * t2 + 1;
|
|
153
|
+
const h10 = t3 - 2 * t2 + t;
|
|
154
|
+
const h01 = -2 * t3 + 3 * t2;
|
|
155
|
+
const h11 = t3 - t2;
|
|
156
|
+
|
|
157
|
+
// Convert angle (rad) -> slope (dy/dx)
|
|
158
|
+
const m0 = p0.r_out !== undefined ? Math.tan(p0.r_out) : 0;
|
|
159
|
+
const m1 = p1.r_in !== undefined ? Math.tan(p1.r_in) : 0;
|
|
160
|
+
const w_out = p0.w_out ?? 1;
|
|
161
|
+
const w_in = p1.w_in ?? 1;
|
|
162
|
+
const linearSlope = (p1.y - p0.y) / dx;
|
|
163
|
+
const weightedM0 = linearSlope + (m0 - linearSlope) * w_out;
|
|
164
|
+
const weightedM1 = linearSlope + (m1 - linearSlope) * w_in;
|
|
165
|
+
|
|
166
|
+
return h00 * p0.y + h10 * weightedM0 * dx + h01 * p1.y + h11 * weightedM1 * dx;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function findSegmentByX(x: number, points: CurvePoint[]) {
|
|
170
|
+
let low = 0;
|
|
171
|
+
let high = points.length - 2;
|
|
172
|
+
|
|
173
|
+
while (low <= high) {
|
|
174
|
+
const mid = (low + high) >> 1;
|
|
175
|
+
if (x < points[mid].x) high = mid - 1;
|
|
176
|
+
else if (x > points[mid + 1].x) low = mid + 1;
|
|
177
|
+
else return mid;
|
|
178
|
+
}
|
|
179
|
+
return x < points[0].x ? 0 : points.length - 2;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function evalMultiPointCurveAtX(x: number, points: CurvePoint[]) {
|
|
183
|
+
const i = findSegmentByX(x, points);
|
|
184
|
+
const p0 = points[i];
|
|
185
|
+
const p1 = points[i + 1];
|
|
186
|
+
return evalHermiteSegment(p0, p1, x);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Bake a weighted-Hermite curve into a uniformly-sampled lookup table.
|
|
191
|
+
*
|
|
192
|
+
* The input `points` array is copied and sorted by `x` — the caller's array is
|
|
193
|
+
* never mutated. Throws if fewer than 2 points are given.
|
|
194
|
+
*
|
|
195
|
+
* Tuning hint: 50 samples (the default) is plenty for the smooth slip/torque
|
|
196
|
+
* curves the controllers ship with; raise it only if you add a curve with
|
|
197
|
+
* very sharp kinks and see faceting in behavior.
|
|
198
|
+
*/
|
|
199
|
+
export function bakeCurveLUT(points: CurvePoint[], samples: number = 50): CurveLUT {
|
|
200
|
+
if (points.length < 2) throw new Error("Curve needs at least 2 points");
|
|
201
|
+
const sortedPoints = [...points].sort((a, b) => a.x - b.x);
|
|
202
|
+
const xMin = sortedPoints[0].x;
|
|
203
|
+
const xMax = sortedPoints[sortedPoints.length - 1].x;
|
|
204
|
+
const lut = new Float32Array(samples);
|
|
205
|
+
for (let i = 0; i < samples; i++) {
|
|
206
|
+
const u = i / (samples - 1);
|
|
207
|
+
const x = xMin + u * (xMax - xMin);
|
|
208
|
+
lut[i] = evalMultiPointCurveAtX(x, sortedPoints);
|
|
209
|
+
}
|
|
210
|
+
return { lut, xMin, xMax, samples };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Sample a baked curve at `x` with linear interpolation between LUT entries.
|
|
215
|
+
*
|
|
216
|
+
* Input outside [xMin, xMax] clamps to the end values (constant
|
|
217
|
+
* extrapolation) — load-bearing for tire slip ratios that exceed 1.
|
|
218
|
+
*/
|
|
219
|
+
export function evaluateCurveLUT(x: number, curve: CurveLUT) {
|
|
220
|
+
const { lut, xMin, xMax, samples } = curve;
|
|
221
|
+
const u = (x - xMin) / (xMax - xMin);
|
|
222
|
+
if (u <= 0) return lut[0];
|
|
223
|
+
if (u >= 1) return lut[samples - 1];
|
|
224
|
+
const f = u * (samples - 1);
|
|
225
|
+
const i = f | 0;
|
|
226
|
+
const t = f - i;
|
|
227
|
+
const y0 = lut[i];
|
|
228
|
+
const y1 = lut[i + 1];
|
|
229
|
+
return y0 * (1 - t) + y1 * t;
|
|
230
|
+
}
|