@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,198 @@
|
|
|
1
|
+
# Wiring the character controller
|
|
2
|
+
|
|
3
|
+
The full path from an empty scene to a playable character:
|
|
4
|
+
world → `CharacterController` → `FollowCamera` → `KeyboardInput` /
|
|
5
|
+
`TouchJoystick` → `CharacterAnimations`. Every class and option name below is
|
|
6
|
+
the vendored code's real API — copy from here, not from memory.
|
|
7
|
+
|
|
8
|
+
## 1. Physics world
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
|
|
12
|
+
|
|
13
|
+
const physics = await PhysicsWorld.create(); // awaits RAPIER.init() (embedded WASM)
|
|
14
|
+
const world = physics.world; // the raw Rapier world controllers consume
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Nothing may construct any RAPIER object before `create()` resolves. Options if
|
|
18
|
+
you need them: `gravity` (default `[0, -9.81, 0]`), `timeStep` (default
|
|
19
|
+
`1/60`), `maxDelta`, `interpolate`. `physics.paused` and `physics.timeScale`
|
|
20
|
+
give you pause and slow motion for free.
|
|
21
|
+
|
|
22
|
+
## 2. Level colliders
|
|
23
|
+
|
|
24
|
+
Every walkable surface needs a rigid body + collider. For authored boxes:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { cuboidCollider } from "./controllers/shared/colliders.ts";
|
|
28
|
+
|
|
29
|
+
const ground = new THREE.Mesh(new THREE.BoxGeometry(90, 0.5, 90), groundMat);
|
|
30
|
+
scene.add(ground);
|
|
31
|
+
const groundBody = physics.createBody({ type: "fixed", position: [0, -0.25, 0] }, ground);
|
|
32
|
+
cuboidCollider(world, groundBody, [45, 0.25, 45], { friction: 1 }); // HALF extents
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For loaded GLB level pieces, `collidersFromObject(world, body, gltf.scene, "trimesh")`
|
|
36
|
+
auto-generates colliders per mesh (`"hull"` for dynamic props from
|
|
37
|
+
`$genex-ai-model`; `"trimesh"` only on static geometry). `cuboidCollider`
|
|
38
|
+
takes **half** extents — do not halve twice.
|
|
39
|
+
|
|
40
|
+
## 3. Character
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { CharacterController } from "./controllers/character/character-controller.ts";
|
|
44
|
+
import { characterPresets } from "./controllers/character/presets.ts";
|
|
45
|
+
|
|
46
|
+
const character = new CharacterController(world, camera, {
|
|
47
|
+
...characterPresets["default"].options,
|
|
48
|
+
position: { x: 0, y: 2, z: 0 },
|
|
49
|
+
userData: { controller: { excludeVehicleRay: true } },
|
|
50
|
+
});
|
|
51
|
+
scene.add(character.root);
|
|
52
|
+
physics.registerBody(character.body, character.root);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- The constructor creates the dynamic body + capsule collider immediately.
|
|
56
|
+
- `character.root` is the visual anchor; `registerBody` makes it follow the
|
|
57
|
+
body with interpolation. Register scene-root-level groups and add them to the
|
|
58
|
+
scene **first** — `registerBody` captures the parent's world matrix once and
|
|
59
|
+
never refreshes it.
|
|
60
|
+
- `userData: { controller: { excludeVehicleRay: true } }` keeps car wheels
|
|
61
|
+
from treating the on-foot player as drivable ground. Related keys (set on
|
|
62
|
+
OTHER bodies): `excludeRay` (ignored by all ground queries),
|
|
63
|
+
`excludeCharacterRay` (ignored by the character's ground query only).
|
|
64
|
+
- The camera argument is how movement becomes camera-relative ("forward" = away
|
|
65
|
+
from camera). Pass the same camera the `FollowCamera` drives.
|
|
66
|
+
|
|
67
|
+
### Place the model so its feet touch the ground
|
|
68
|
+
|
|
69
|
+
The capsule floats: in root space the ground is at
|
|
70
|
+
`-(capsuleHalfHeight + capsuleRadius + floatHeight)` — **-0.8** with the
|
|
71
|
+
defaults (0.3 + 0.3 + 0.2). Normalize any loaded model to ~1.35 units tall and
|
|
72
|
+
drop its bounding-box bottom to that line:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const bbox = new THREE.Box3().setFromObject(model);
|
|
76
|
+
const scale = 1.35 / Math.max(bbox.max.y - bbox.min.y, 1e-3);
|
|
77
|
+
model.scale.setScalar(scale);
|
|
78
|
+
model.position.y = -0.8 - bbox.min.y * scale;
|
|
79
|
+
character.root.add(model);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A model hovering above the floor or knee-deep in it means this offset is wrong
|
|
83
|
+
— it is the single most common wiring bug.
|
|
84
|
+
|
|
85
|
+
## 4. Input
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
|
|
89
|
+
|
|
90
|
+
const kb = new KeyboardInput(); // WASD/arrows move, Shift run, Space jump, F interact
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Event-driven — nothing to poll per frame. Read `kb.getCharacterMovement()`
|
|
94
|
+
inside the before-step callback and pass the **complete** intent object every
|
|
95
|
+
time: the controller merges defined fields, so a stale partial would leave old
|
|
96
|
+
`true`s behind. Merge touch controls caller-side (see the SKILL's mobile
|
|
97
|
+
section). `kb.onInteract(cb)` is the rising-edge F-key hook (for enter/exit).
|
|
98
|
+
It already clears all keys on window blur / tab hide, so no stuck-key handling
|
|
99
|
+
is needed.
|
|
100
|
+
|
|
101
|
+
## 5. Follow camera
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { FollowCamera } from "./controllers/character/follow-camera.ts";
|
|
105
|
+
|
|
106
|
+
const followCam = new FollowCamera(camera, {
|
|
107
|
+
domElement: renderer.domElement,
|
|
108
|
+
colliderMeshes: staticWallMeshes,
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Drag orbits, wheel/pinch zooms, and rays pull the camera in front of walls.
|
|
113
|
+
Rules that matter:
|
|
114
|
+
|
|
115
|
+
- `colliderMeshes` takes static **leaf** meshes only (the ray test is
|
|
116
|
+
non-recursive) and must never include the character or vehicle meshes — the
|
|
117
|
+
rays start at the character's head and would hit them every frame. The array
|
|
118
|
+
is public — mutate it after level loads.
|
|
119
|
+
- `minDistance` defaults to 0.02, so zooming all the way in is effectively
|
|
120
|
+
first-person; clamp with `minDistance`/`maxDistance` for your scale.
|
|
121
|
+
- Feel: `smoothTime` (0.05 snappy → 0.25 cinematic, default 0.1),
|
|
122
|
+
`initialDistance` (default 4), `initialAzimuthAngle` (default `Math.PI` —
|
|
123
|
+
camera starts behind a +Z-facing character).
|
|
124
|
+
- v1 limits (by design): no pointer-lock mode, no truck/pan, and the orbit
|
|
125
|
+
space assumes the up axis stays roughly world +Y — far-from-Y custom gravity
|
|
126
|
+
will misbehave.
|
|
127
|
+
|
|
128
|
+
## 6. The loop — exact order
|
|
129
|
+
|
|
130
|
+
Per fixed **substep** (inside `physics.onBeforeStep`), in this order:
|
|
131
|
+
|
|
132
|
+
1. Drive moving platforms (`setNextKinematicTranslation` / `setNextKinematicRotation`).
|
|
133
|
+
2. `EnterExitManager.update(dt)` — only if vehicles exist (vehicle skill).
|
|
134
|
+
3. `character.setMovement(...)` then `character.update()` — skip while
|
|
135
|
+
`character.isParked`.
|
|
136
|
+
4. (Vehicle/drone `update()` calls, if any.)
|
|
137
|
+
5. `world.step()` happens automatically right after your callback returns.
|
|
138
|
+
|
|
139
|
+
Per **render** frame (inside `renderer.setAnimationLoop`), in this order:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const delta = clock.getDelta();
|
|
143
|
+
physics.step(delta); // 1. fixed substeps + mesh sync
|
|
144
|
+
|
|
145
|
+
pivot.copy(character.currPos).addScaledVector(character.bodyYAxis, 0.5);
|
|
146
|
+
followCam.moveTo(pivot.x, pivot.y, pivot.z, true); // 2. feed the camera
|
|
147
|
+
followCam.setUp(character.upAxis);
|
|
148
|
+
if (physics.stepsLastFrame > 0 && character.isOnPlatform) {
|
|
149
|
+
followCam.applyPlatformTurn(character.turnOnYQuat);
|
|
150
|
+
}
|
|
151
|
+
followCam.update(delta); // 3. damp + place the camera
|
|
152
|
+
|
|
153
|
+
anims.update(character, delta); // 4. animations (render delta)
|
|
154
|
+
renderer.render(scene, camera); // 5. draw
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Non-negotiables:
|
|
158
|
+
|
|
159
|
+
- `update()` takes an optional dt **and ignores it** — all controller time
|
|
160
|
+
terms use the fixed `world.timestep`. Never call controller updates from the
|
|
161
|
+
render loop directly.
|
|
162
|
+
- `applyPlatformTurn` consumes a per-physics-step yaw delta: gate it on
|
|
163
|
+
`physics.stepsLastFrame > 0`, or high-refresh displays re-apply a stale delta
|
|
164
|
+
and the camera over-rotates on platforms.
|
|
165
|
+
- Camera and animation updates take the **render** delta, never
|
|
166
|
+
`physics.timeStep`.
|
|
167
|
+
|
|
168
|
+
## 7. Reading state (for game logic)
|
|
169
|
+
|
|
170
|
+
All getters are live internal objects — `.clone()` if you keep them:
|
|
171
|
+
`currPos`, `currQuat`, `currLinVel`, `isOnGround`, `isFalling`, `isMoving`,
|
|
172
|
+
`isOnPlatform`, `moveSpeed`, `verticalSpeed`, `runActive`, `jumpActive`,
|
|
173
|
+
`slopeAngle`, `actualSlopeAngle`, `standCollider` (the rigid body under the
|
|
174
|
+
character), `standPoint`, `standNormal`, `upAxis`, `bodyZAxis` (facing).
|
|
175
|
+
Useful methods beyond `setMovement`: `setLockForward(true)` for strafe mode,
|
|
176
|
+
`setGroundDetection("rayCast" | "shapeCast")`, `park()` / `unpark(pos, euler)`
|
|
177
|
+
for vehicle boarding, `syncRoot()` if you skip `registerBody`.
|
|
178
|
+
|
|
179
|
+
## 8. Debug and teardown
|
|
180
|
+
|
|
181
|
+
- `physics.enableDebug(scene)` draws every collider as wireframe lines
|
|
182
|
+
(disable in shipped games); `{ debug: true }` + a `debugScene` constructor
|
|
183
|
+
arg adds per-controller indicators.
|
|
184
|
+
- Teardown: `character.dispose()`, `kb.dispose()`, `followCam.dispose()`,
|
|
185
|
+
joystick/button `.dispose()`, then `physics.dispose()` last.
|
|
186
|
+
|
|
187
|
+
## Troubleshooting
|
|
188
|
+
|
|
189
|
+
- **Character slides down gentle ramps or climbs cliffs** — check
|
|
190
|
+
`slopeMaxAngle` (default 72°); see the tuning reference.
|
|
191
|
+
- **Stuck bouncing at spawn** — spawn `position.y` too low; the capsule center
|
|
192
|
+
must start above `capsuleHalfHeight + capsuleRadius + floatHeight`.
|
|
193
|
+
- **Model drifts away from the capsule** — the registered root's parent moved
|
|
194
|
+
or scaled after `registerBody`; register scene-root groups only.
|
|
195
|
+
- **Movement ignores the camera** — you passed a different camera to the
|
|
196
|
+
controller than the one being rendered.
|
|
197
|
+
- **`using deprecated parameters for the initialization function` in the
|
|
198
|
+
console** — harmless one-time Rapier WASM-loader message; ignore it.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-threejs-physics-rapier
|
|
3
|
+
description: Set up Rapier physics for Genex Three.js games with the vendored PhysicsWorld glue — async WASM init, fixed-timestep world, body-to-mesh sync, collision events — and pick the right collider per asset (cuboid, hull, trimesh, including genex model GLBs). Use whenever anything falls, collides, gets pushed, or stands on moving ground; load before writing any physics code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Three.js Physics (Rapier)
|
|
7
|
+
|
|
8
|
+
Genex games run physics on Rapier (`@dimforge/rapier3d-compat`) through a small
|
|
9
|
+
vendored glue layer: `PhysicsWorld` owns the world, the fixed-timestep loop,
|
|
10
|
+
the rigid-body ↔ `Object3D` sync, and collision events; `shared/colliders.ts`
|
|
11
|
+
turns meshes and GLBs into colliders. You do not install this from a skill —
|
|
12
|
+
a CLI command drops the code into the game.
|
|
13
|
+
|
|
14
|
+
## Get the code (one command, not hand-rolled)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx genex controller character # or car / drone — every kind installs shared/
|
|
18
|
+
npm i @dimforge/rapier3d-compat
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The command copies `src/controllers/shared/physics-world.ts`,
|
|
22
|
+
`shared/colliders.ts`, and `shared/math.ts` into the game (plus the controller
|
|
23
|
+
you picked). Even a game that only needs loose physics — falling crates, a
|
|
24
|
+
rolling ball — should run it once for the `shared/` layer; existing files are
|
|
25
|
+
never overwritten without `--force`.
|
|
26
|
+
|
|
27
|
+
**Never hand-write character or vehicle physics.** A capsule you push around
|
|
28
|
+
with raw impulses will clip slopes, stutter on stairs, and slide off moving
|
|
29
|
+
platforms — this is the single most common way physics games go wrong. Tuned,
|
|
30
|
+
tested controllers already exist: load `$genex-threejs-character-controller`
|
|
31
|
+
for walking/running/jumping and `$genex-threejs-vehicle-controllers` for cars
|
|
32
|
+
and drones, each installed by the same `npx genex controller` command.
|
|
33
|
+
|
|
34
|
+
## Minimal working setup
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import * as THREE from "three";
|
|
38
|
+
import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
|
|
39
|
+
import { cuboidCollider } from "./controllers/shared/colliders.ts";
|
|
40
|
+
|
|
41
|
+
const physics = await PhysicsWorld.create(); // awaits RAPIER.init() (embedded WASM)
|
|
42
|
+
const world = physics.world; // the raw Rapier world
|
|
43
|
+
|
|
44
|
+
// Static ground: a box whose top face is at y = 0.
|
|
45
|
+
const ground = new THREE.Mesh(new THREE.BoxGeometry(100, 0.5, 100), groundMat);
|
|
46
|
+
scene.add(ground);
|
|
47
|
+
const groundBody = physics.createBody({ type: "fixed", position: [0, -0.25, 0] }, ground);
|
|
48
|
+
cuboidCollider(world, groundBody, [50, 0.25, 50], { friction: 1 });
|
|
49
|
+
|
|
50
|
+
// Dynamic crate: passing the mesh to createBody registers it — the mesh
|
|
51
|
+
// follows the body automatically every frame, interpolated between steps.
|
|
52
|
+
const crate = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), crateMat);
|
|
53
|
+
scene.add(crate);
|
|
54
|
+
const crateBody = physics.createBody({ type: "dynamic", position: [0, 3, 0] }, crate);
|
|
55
|
+
cuboidCollider(world, crateBody, [0.5, 0.5, 0.5], { friction: 0.6, density: 1 });
|
|
56
|
+
|
|
57
|
+
// Anything that pushes bodies runs once per fixed substep, before the step:
|
|
58
|
+
physics.onBeforeStep(() => {
|
|
59
|
+
// controller.update(), impulses, kinematic platform poses ...
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const clock = new THREE.Clock();
|
|
63
|
+
renderer.setAnimationLoop(() => {
|
|
64
|
+
physics.step(clock.getDelta()); // fixed substeps + mesh sync + events
|
|
65
|
+
// camera follow, animation mixers — render-delta work stays OUT here
|
|
66
|
+
renderer.render(scene, camera);
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Note `cuboidCollider` takes **half** extents: `[0.5, 0.5, 0.5]` is a 1×1×1 box.
|
|
71
|
+
|
|
72
|
+
## The update ordering contract
|
|
73
|
+
|
|
74
|
+
Physics code never sees the render framerate. Keep this order or controllers
|
|
75
|
+
misbehave in ways that look like tuning problems:
|
|
76
|
+
|
|
77
|
+
1. **Inside `physics.onBeforeStep(...)`** — runs once per fixed substep, in
|
|
78
|
+
this order: kinematic platform poses (`setNextKinematicTranslation` /
|
|
79
|
+
`setNextKinematicRotation`), then `EnterExitManager.update(dt)` if the game
|
|
80
|
+
has enter/exit vehicles, then each controller's `update()`. Controllers
|
|
81
|
+
ignore any dt argument and read `world.timestep` internally — never feed
|
|
82
|
+
them the render delta.
|
|
83
|
+
2. **`physics.step(delta)`** — once per `requestAnimationFrame`, with the
|
|
84
|
+
render clock delta in **seconds**. It runs zero or more fixed substeps,
|
|
85
|
+
syncs registered meshes (interpolated), and drains collision events.
|
|
86
|
+
3. **After `step`** — cameras, animation mixers, HUD: render-delta work. Gate
|
|
87
|
+
once-per-physics-step camera corrections on `physics.stepsLastFrame > 0`.
|
|
88
|
+
|
|
89
|
+
Read [references/physics-setup.md](references/physics-setup.md) for world
|
|
90
|
+
options, the body registry and its caveats, kinematic platforms, collision and
|
|
91
|
+
sensor events, sleeping bodies, CCD/tunneling, pause/slow-mo, and debug
|
|
92
|
+
rendering.
|
|
93
|
+
|
|
94
|
+
Read [references/colliders-from-assets.md](references/colliders-from-assets.md)
|
|
95
|
+
for the collider decision table per asset type — `genex model` GLBs, level
|
|
96
|
+
geometry, primitives — hull vs trimesh tradeoffs, and the scale pitfalls.
|
|
97
|
+
|
|
98
|
+
## Benign boot warning — do not chase it
|
|
99
|
+
|
|
100
|
+
`@dimforge/rapier3d-compat` 0.19.3 logs once at startup:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
using deprecated parameters for the initialization function; pass a single object instead
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This comes from the library's own embedded WASM loader, cannot be fixed from
|
|
107
|
+
game code, and is harmless. Ignore it; do not refactor init code trying to
|
|
108
|
+
silence it.
|
|
109
|
+
|
|
110
|
+
## Failure conditions
|
|
111
|
+
|
|
112
|
+
- physics stepped with the raw render delta instead of `physics.step`'s fixed
|
|
113
|
+
accumulator (speed varies with framerate);
|
|
114
|
+
- a controller updated outside `onBeforeStep`, or fed the render delta;
|
|
115
|
+
- a dynamic body repositioned with `setTranslation` every frame instead of
|
|
116
|
+
being kinematic;
|
|
117
|
+
- a trimesh collider on a fast dynamic body (falls through the floor);
|
|
118
|
+
- colliders built from a model whose scale changes afterwards;
|
|
119
|
+
- a mesh registered to a body after its parent moved or scaled (silent desync);
|
|
120
|
+
- hand-written character/vehicle physics instead of `npx genex controller`.
|
|
121
|
+
|
|
122
|
+
## Routing boundary
|
|
123
|
+
|
|
124
|
+
Player movement, jumping, slopes, and animation state belong to
|
|
125
|
+
`$genex-threejs-character-controller`. Cars, drones, and enter/exit flow belong
|
|
126
|
+
to `$genex-threejs-vehicle-controllers`. Chase/orbit camera design beyond the
|
|
127
|
+
bundled follow camera is `$genex-threejs-camera-direction`. Meshes for props
|
|
128
|
+
come from `$genex-ai-model`; this skill only decides how they collide.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Colliders from assets
|
|
2
|
+
|
|
3
|
+
Everything here documents `src/controllers/shared/colliders.ts` as installed
|
|
4
|
+
by `npx genex controller`. All helpers assume `PhysicsWorld.create()` has
|
|
5
|
+
already resolved — constructing a collider before WASM init throws.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- Decision table: which collider for which asset
|
|
10
|
+
- Explicit primitive helpers
|
|
11
|
+
- Auto-colliders from a GLB scene
|
|
12
|
+
- Hull vs trimesh
|
|
13
|
+
- Scale pitfalls
|
|
14
|
+
- Collider options
|
|
15
|
+
- Sensors
|
|
16
|
+
- End-to-end: a genex model prop
|
|
17
|
+
|
|
18
|
+
## Decision table: which collider for which asset
|
|
19
|
+
|
|
20
|
+
| Asset | Collider | Why |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| ground, walls, crates, platforms (box-shaped) | `cuboidCollider` | cheapest, exact for boxes |
|
|
23
|
+
| dynamic prop from `npx genex model` (barrel, rock, chair) | `convexHullColliderFromMesh` (or `collidersFromObject(..., "hull")` for multi-mesh GLBs) | tight fit, still convex/fast/solid |
|
|
24
|
+
| static level GLB (terrain piece, building interior, track) | `trimeshColliderFromMesh` per mesh | exact triangles; safe because it never moves |
|
|
25
|
+
| ball-shaped things | `ballCollider` | exact sphere |
|
|
26
|
+
| tall dynamic props, posts | `capsuleCollider` / `cylinderCollider` | stable standing shapes |
|
|
27
|
+
| pickups, triggers, zones | any shape + `{ sensor: true, mass: 0 }` | overlap events, no contact forces |
|
|
28
|
+
| the player, cars, drones | none of the above — `npx genex controller` | controllers own their collider recipes |
|
|
29
|
+
|
|
30
|
+
## Explicit primitive helpers
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import {
|
|
34
|
+
cuboidCollider,
|
|
35
|
+
ballCollider,
|
|
36
|
+
capsuleCollider,
|
|
37
|
+
cylinderCollider,
|
|
38
|
+
} from "./controllers/shared/colliders.ts";
|
|
39
|
+
|
|
40
|
+
cuboidCollider(world, body, [1, 0.4, 2.4], { friction: 1 }); // HALF extents: 2 x 0.8 x 4.8 box
|
|
41
|
+
ballCollider(world, body, 0.5);
|
|
42
|
+
capsuleCollider(world, body, 0.3, 0.3); // (halfHeight, radius)
|
|
43
|
+
cylinderCollider(world, body, 0.15, 3); // (halfHeight, radius)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Two classic mistakes, called out because they are silent:
|
|
47
|
+
|
|
48
|
+
- **Cuboid takes HALF extents.** A `[1, 0.4, 2.4]` collider is a 2 × 0.8 × 4.8
|
|
49
|
+
box. Do not halve twice.
|
|
50
|
+
- **Capsule/cylinder arg order is `(halfHeight, radius)`** — the REVERSE of
|
|
51
|
+
`THREE.CapsuleGeometry(radius, length)` — and the capsule's `halfHeight`
|
|
52
|
+
covers the cylindrical section only: total height is
|
|
53
|
+
`2 * halfHeight + 2 * radius`, so `(0.3, 0.3)` is 1.2 units tall.
|
|
54
|
+
|
|
55
|
+
All helpers return the created `RAPIER.Collider` and accept a `ColliderOptions`
|
|
56
|
+
last argument (below), including `position`/`rotation` relative to the body.
|
|
57
|
+
|
|
58
|
+
## Auto-colliders from a GLB scene
|
|
59
|
+
|
|
60
|
+
`collidersFromObject` walks every visible mesh under an object and creates one
|
|
61
|
+
collider per mesh — the batteries-included path for GLBs:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { collidersFromObject } from "./controllers/shared/colliders.ts";
|
|
65
|
+
|
|
66
|
+
const gltf = await loader.loadAsync("./assets/models/weathered-wooden-barrel.glb");
|
|
67
|
+
const model = gltf.scene;
|
|
68
|
+
scene.add(model);
|
|
69
|
+
|
|
70
|
+
const body = physics.createBody({ type: "dynamic", position: [0, 2, 0] }, model);
|
|
71
|
+
collidersFromObject(world, body, model, "hull", { friction: 0.7, density: 400 });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- Shapes: `"cuboid"` (bounding box per mesh — cheapest), `"ball"` (bounding
|
|
75
|
+
sphere), `"hull"` (convex hull per mesh — the default choice for `genex
|
|
76
|
+
model` props), `"trimesh"` (exact triangles — static geometry only).
|
|
77
|
+
- `object3d` must be the SAME object registered to the body (their frames must
|
|
78
|
+
coincide); call it right after `createBody`, before the first step.
|
|
79
|
+
- Hidden meshes are skipped unless you pass `includeInvisible: true` in the
|
|
80
|
+
options.
|
|
81
|
+
- It returns the array of created colliders, so you can attach events or
|
|
82
|
+
retune later.
|
|
83
|
+
|
|
84
|
+
For a single mesh you can target directly, the explicit forms skip the
|
|
85
|
+
traversal:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import {
|
|
89
|
+
convexHullColliderFromMesh,
|
|
90
|
+
trimeshColliderFromMesh,
|
|
91
|
+
} from "./controllers/shared/colliders.ts";
|
|
92
|
+
|
|
93
|
+
convexHullColliderFromMesh(world, dynamicBody, propMesh, { density: 400 });
|
|
94
|
+
trimeshColliderFromMesh(world, fixedBody, levelMesh, { friction: 1 });
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Both bake the mesh's current world transform relative to the body, so the
|
|
98
|
+
collider lands exactly where the mesh renders.
|
|
99
|
+
|
|
100
|
+
## Hull vs trimesh
|
|
101
|
+
|
|
102
|
+
| | Convex hull | Trimesh |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| fit | shrink-wraps the mesh; concavities filled in (a mug loses its opening) | exact triangles, concavities preserved |
|
|
105
|
+
| solidity | solid volume | **hollow shell** — a body starting inside it is stuck or falls through |
|
|
106
|
+
| cost | cheap contacts | expensive contacts; scales with triangle count |
|
|
107
|
+
| dynamic bodies | yes — the default for props | **never on fast dynamic bodies** — thin hollow triangles are the classic tunneling recipe |
|
|
108
|
+
| static level geometry | fine but approximate | the right tool |
|
|
109
|
+
|
|
110
|
+
When one hull is too crude for a concave prop, prefer either of these before
|
|
111
|
+
reaching for a dynamic trimesh:
|
|
112
|
+
|
|
113
|
+
- **Per-mesh hulls:** most multi-part GLBs already split into several meshes,
|
|
114
|
+
and `collidersFromObject(world, body, model, "hull")` gives one hull per
|
|
115
|
+
part — a compound of convex pieces approximates concavity well.
|
|
116
|
+
- **A few hand-placed primitives:** two or three `cuboidCollider`s with
|
|
117
|
+
`position` offsets often beat any auto shape (this is how the bundled
|
|
118
|
+
vehicle chassis recipes work).
|
|
119
|
+
|
|
120
|
+
`"hull"` can also fail outright on degenerate/coplanar geometry — the helper
|
|
121
|
+
throws with the mesh name; fall back to `"cuboid"` for such meshes.
|
|
122
|
+
|
|
123
|
+
## Scale pitfalls
|
|
124
|
+
|
|
125
|
+
Colliders copy geometry **at creation time**; they do not track later
|
|
126
|
+
transforms.
|
|
127
|
+
|
|
128
|
+
- **Scale the model BEFORE creating colliders.** Normalize the GLB (e.g.
|
|
129
|
+
`model.scale.setScalar(targetHeight / bboxHeight)`) first, then create the
|
|
130
|
+
body and colliders. Changing `scale` afterwards resizes the render mesh
|
|
131
|
+
only — the collider keeps the old size.
|
|
132
|
+
- **Put scale on the mesh, not on the registered root.** The auto-collider
|
|
133
|
+
path faithfully replicates an upstream quirk: the registered root's world
|
|
134
|
+
scale is double-counted in collider sizing, so a root scaled 2× produces
|
|
135
|
+
colliders 2× larger than the rendered meshes. Scaling child meshes (or the
|
|
136
|
+
GLB scene before registering a plain unscaled root) avoids the quirk
|
|
137
|
+
entirely.
|
|
138
|
+
- **`"ball"` uses `scale.x` only** (same upstream quirk, replicated not
|
|
139
|
+
fixed) — a non-uniformly scaled sphere gets the wrong radius. Use `"hull"`
|
|
140
|
+
for squashed spheres.
|
|
141
|
+
- **Verify visually.** `physics.enableDebug(scene)` draws every collider as a
|
|
142
|
+
wireframe — one glance catches every scale/offset mistake in this section.
|
|
143
|
+
|
|
144
|
+
## Collider options
|
|
145
|
+
|
|
146
|
+
`ColliderOptions`, accepted by every helper (and applied via
|
|
147
|
+
`applyColliderOptions(collider, options)` if you retune at runtime):
|
|
148
|
+
|
|
149
|
+
| Option | Notes |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `friction` | negative values are legal and load-bearing — the bundled character capsule ships with `-0.5` because its traction is synthetic; do not clamp |
|
|
152
|
+
| `restitution` | bounciness 0–1 |
|
|
153
|
+
| `density` / `mass` | **mutually exclusive — picking both throws.** `density` is kg/m³ (water ≈ 1000, wood ≈ 400–700); `mass` is absolute kg |
|
|
154
|
+
| `sensor` | overlap detection, no contact forces |
|
|
155
|
+
| `position` / `rotation` | collider pose relative to its body (euler XYZ radians) |
|
|
156
|
+
| `collisionGroups` / `solverGroups` | raw Rapier bitmasks |
|
|
157
|
+
| `contactSkin` | extra contact thickness — trades a visual gap for less jitter |
|
|
158
|
+
| `frictionCombineRule` / `restitutionCombineRule`, `activeCollisionTypes` | raw Rapier passthroughs |
|
|
159
|
+
|
|
160
|
+
Density matters more than it looks: impulse-driven controllers push bodies by
|
|
161
|
+
force, so a crate with the default density reacts very differently from one at
|
|
162
|
+
`density: 100`. Give props deliberate densities.
|
|
163
|
+
|
|
164
|
+
## Sensors
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const zone = cuboidCollider(world, zoneBody, [2, 1, 2], { sensor: true, mass: 0 });
|
|
168
|
+
physics.setColliderEvents(zone, {
|
|
169
|
+
onIntersectionEnter: ({ other }) => { if (other.object3d === playerRoot) enterZone(); },
|
|
170
|
+
onIntersectionExit: ({ other }) => { if (other.object3d === playerRoot) leaveZone(); },
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Always give sensors `mass: 0` when they hang off a dynamic body (e.g. a
|
|
175
|
+
vehicle's boarding sensor) so they add no mass. Remember exit events fire both
|
|
176
|
+
handler families — keep exit logic idempotent (see the physics-setup
|
|
177
|
+
reference).
|
|
178
|
+
|
|
179
|
+
## End-to-end: a genex model prop
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
183
|
+
import { collidersFromObject } from "./controllers/shared/colliders.ts";
|
|
184
|
+
|
|
185
|
+
const loader = new GLTFLoader();
|
|
186
|
+
const gltf = await loader.loadAsync("./assets/models/weathered-wooden-barrel.glb");
|
|
187
|
+
const barrel = gltf.scene;
|
|
188
|
+
|
|
189
|
+
// 1. Normalize scale FIRST (target: 1.1 units tall).
|
|
190
|
+
const bbox = new THREE.Box3().setFromObject(barrel);
|
|
191
|
+
barrel.scale.setScalar(1.1 / (bbox.max.y - bbox.min.y));
|
|
192
|
+
|
|
193
|
+
// 2. Wrap in an unscaled root, add to the scene, register, then colliders.
|
|
194
|
+
const root = new THREE.Object3D();
|
|
195
|
+
root.add(barrel);
|
|
196
|
+
scene.add(root);
|
|
197
|
+
const body = physics.createBody({ type: "dynamic", position: [3, 2, 0] }, root);
|
|
198
|
+
collidersFromObject(world, body, root, "hull", { friction: 0.7, density: 500 });
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Generating the GLB itself (`npx genex model "..."`) is covered by
|
|
202
|
+
`$genex-ai-model`.
|