@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,1200 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2023-2026 Erdong Chen
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Vanilla-TypeScript port of the ecctrl vehicle controller (React/R3F removed).
|
|
4
|
+
// This file is the shapecast wheel: suspension spring/damper, slip-curve tire
|
|
5
|
+
// model with friction ellipse and tire relaxation, speed-sensitive steering,
|
|
6
|
+
// wheel spin integration, moving-platform following, and the wheel-model
|
|
7
|
+
// visual sync. Upstream typo'd names are corrected in our public API:
|
|
8
|
+
// `rayHitFriciton` -> `rayHitFriction`, `wheelModelReversRotation` ->
|
|
9
|
+
// `wheelModelReverseRotation`, `wheelModelUpdate` -> `updateModel`; the
|
|
10
|
+
// rigid-body userData key `ecctrl` is renamed to `controller` (de-branding).
|
|
11
|
+
|
|
12
|
+
import * as THREE from "three";
|
|
13
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
14
|
+
import type {
|
|
15
|
+
World,
|
|
16
|
+
RigidBody,
|
|
17
|
+
Collider,
|
|
18
|
+
ColliderShapeCastHit,
|
|
19
|
+
RayColliderIntersection,
|
|
20
|
+
Ray,
|
|
21
|
+
Cylinder,
|
|
22
|
+
} from "@dimforge/rapier3d-compat";
|
|
23
|
+
import {
|
|
24
|
+
remap,
|
|
25
|
+
bakeCurveLUT,
|
|
26
|
+
evaluateCurveLUT,
|
|
27
|
+
type CurveData,
|
|
28
|
+
type CurveLUT,
|
|
29
|
+
} from "../shared/math.ts";
|
|
30
|
+
|
|
31
|
+
const clamp = THREE.MathUtils.clamp;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drivetrain config pushed into drive wheels by the vehicle controller.
|
|
35
|
+
* Users normally never build this by hand — `VehicleController` derives it
|
|
36
|
+
* from `CarConfig` and re-pushes it on every gear change.
|
|
37
|
+
*/
|
|
38
|
+
export type DriveWheelConfig = {
|
|
39
|
+
/** Peak engine torque share for THIS wheel (N·m), split by drive weights. */
|
|
40
|
+
maxDriveTorque: number;
|
|
41
|
+
/** Wheel angular velocity at engine redline in top of current gear (rad/s). */
|
|
42
|
+
maxWheelAngVel: number;
|
|
43
|
+
/** Baked engine torque curve, sampled by |wheelAngVel| / maxWheelAngVel. */
|
|
44
|
+
engineTorqueCurve: CurveLUT;
|
|
45
|
+
/** Torque multiplier while reversing (1 = same punch as forward). */
|
|
46
|
+
reverseTorqueScale: number;
|
|
47
|
+
/** Scales the reverse speed cap (0.3 = reverse tops out at 30%). */
|
|
48
|
+
reverseRPMScale: number;
|
|
49
|
+
/** Current gear ratio x final drive ratio. */
|
|
50
|
+
driveRatio: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Steering config shared (by reference) across all steer wheels — the vehicle
|
|
55
|
+
* controller updates `maxWheelAngVel` in place on gear changes.
|
|
56
|
+
*/
|
|
57
|
+
export type SteerWheelConfig = {
|
|
58
|
+
/** Baked steer-angle falloff curve over forward-speed ratio (speed-sensitive steering). */
|
|
59
|
+
steerAngleCurve: CurveLUT;
|
|
60
|
+
/** How fast the wheel slews toward its target angle (rad/s). */
|
|
61
|
+
steerRate: number;
|
|
62
|
+
/** Max steer angle at standstill (rad). */
|
|
63
|
+
maxSteerAngle: number;
|
|
64
|
+
/** Theoretical top wheel spin (rad/s); normalizes the speed ratio. */
|
|
65
|
+
maxWheelAngVel: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-wheel options. Everything except `position` has an upstream default.
|
|
70
|
+
*
|
|
71
|
+
* Tuning hints:
|
|
72
|
+
* - "car bottoms out / sits too low" -> raise `springK` (scale with chassis mass!).
|
|
73
|
+
* - "car pogo-bounces" -> raise `dampingC` (keep it below `2*sqrt(springK * massPerWheel)`).
|
|
74
|
+
* - "too slidey / too grippy" -> `tireGripFactor` (averaged with ground friction).
|
|
75
|
+
* - "drifts too easily sideways" -> raise `latFrictionEllipseScale`.
|
|
76
|
+
* - "wheels dig into curbs" -> longer `rayLength` (more suspension travel).
|
|
77
|
+
*/
|
|
78
|
+
export type WheelOptions = {
|
|
79
|
+
/** Stable id used as the key in the vehicle's wheel map. Default: random UUID. */
|
|
80
|
+
id?: string;
|
|
81
|
+
/** Optional display name (not used by physics). */
|
|
82
|
+
name?: string;
|
|
83
|
+
/** Master enable; a disabled wheel skips its whole update. Default true. */
|
|
84
|
+
enable?: boolean;
|
|
85
|
+
/** REQUIRED. Wheel mount point in chassis-local space (axle position, +Z forward). */
|
|
86
|
+
position: THREE.Vector3;
|
|
87
|
+
|
|
88
|
+
/** "shapeCast" (cylinder sweep, default; handles curbs/side contact) or "rayCast" (cheaper). */
|
|
89
|
+
groundDetection?: "shapeCast" | "rayCast";
|
|
90
|
+
/** Wheel radius (m). Default 0.5. */
|
|
91
|
+
rayShapeR?: number;
|
|
92
|
+
/** Cylinder HALF-height = half the tire width (m). Default 0.15. */
|
|
93
|
+
rayShapeH?: number;
|
|
94
|
+
/** Suspension travel below the axle (m). Default 0.5. */
|
|
95
|
+
rayLength?: number;
|
|
96
|
+
/** Suspension spring constant. Default 180 (assumes a very light chassis — presets scale it). */
|
|
97
|
+
springK?: number;
|
|
98
|
+
/** Suspension damping. Default 16. Upstream note: max at 2*sqrt(K*mass). */
|
|
99
|
+
dampingC?: number;
|
|
100
|
+
|
|
101
|
+
/** Flip drive torque sign (for mirrored wheel setups). Default false. */
|
|
102
|
+
driveInvert?: boolean;
|
|
103
|
+
/** Does the engine power this wheel? Default false. */
|
|
104
|
+
driveWheel?: boolean;
|
|
105
|
+
/** Torque split weight among drive wheels. Default 1. Bigger = more of the engine. */
|
|
106
|
+
driveTorqueWeight?: number;
|
|
107
|
+
/** Flip steer direction. Default false. */
|
|
108
|
+
steerInvert?: boolean;
|
|
109
|
+
/** Does this wheel steer? Default false. */
|
|
110
|
+
steerWheel?: boolean;
|
|
111
|
+
/** Does this wheel brake? Default false. */
|
|
112
|
+
brakeWheel?: boolean;
|
|
113
|
+
/** Max brake torque (N·m). Default 40. */
|
|
114
|
+
maxBrakeTorque?: number;
|
|
115
|
+
|
|
116
|
+
/** Rolling drag while free-rolling. Default 0.007. */
|
|
117
|
+
rollingResistanceCoef?: number;
|
|
118
|
+
|
|
119
|
+
/** Below this contact speed (m/s) the tire blends toward full static grip. Default 0.4. */
|
|
120
|
+
lowVelThreshold?: number;
|
|
121
|
+
/** Tire grip, averaged with ground friction: (surface + grip) * 0.5. Default 1.5. */
|
|
122
|
+
tireGripFactor?: number;
|
|
123
|
+
/** Scales the longitudinal (accel/brake) half of the friction ellipse. Default 1. */
|
|
124
|
+
lngFrictionEllipseScale?: number;
|
|
125
|
+
/** Scales the lateral (cornering) half of the friction ellipse. Default 1. */
|
|
126
|
+
latFrictionEllipseScale?: number;
|
|
127
|
+
/** Longitudinal tire relaxation rate (smaller = snappier response). Default 0.05. */
|
|
128
|
+
relaxLngRate?: number;
|
|
129
|
+
/** Lateral tire relaxation rate. Default 0.1. */
|
|
130
|
+
relaxLatRate?: number;
|
|
131
|
+
/** Relaxation floor so low-speed tires stay responsive. Default 0.3. */
|
|
132
|
+
minLngRelaxCoeff?: number;
|
|
133
|
+
/** Relaxation floor, lateral. Default 0.3. */
|
|
134
|
+
minLatRelaxCoeff?: number;
|
|
135
|
+
/** Longitudinal slip curve. Default (0,0)->(0.25,1)->(1,0.7). */
|
|
136
|
+
lngSlipRatioCurveData?: CurveData;
|
|
137
|
+
/** Lateral slip curve. Default (0,0)->(0.15,1)->(1,0.9). */
|
|
138
|
+
latSlipRatioCurveData?: CurveData;
|
|
139
|
+
|
|
140
|
+
/** Inherit velocity from dynamic/kinematic bodies stood on. Default true. */
|
|
141
|
+
followPlatform?: boolean;
|
|
142
|
+
/** Platform-influence falloff over (platform mass / vehicle mass). */
|
|
143
|
+
massRatioFallOffCurveData?: CurveData;
|
|
144
|
+
/** Push wheel load back onto dynamic bodies stood on. Default true. */
|
|
145
|
+
applyCounterMass?: boolean;
|
|
146
|
+
/** Push tire friction back onto dynamic bodies stood on. Default true. */
|
|
147
|
+
applyCounterFriction?: boolean;
|
|
148
|
+
|
|
149
|
+
/** Drive the visual groups (suspension bounce + wheel spin). Default true. (upstream `wheelModelUpdate`) */
|
|
150
|
+
updateModel?: boolean;
|
|
151
|
+
/** VISUAL mass proxy density — fattens effective inertia; creates no collider. Default 1.5. */
|
|
152
|
+
wheelModelDensity?: number;
|
|
153
|
+
/** Visual wheel radius for the model rest offset. Default 0.5. */
|
|
154
|
+
wheelModelRadius?: number;
|
|
155
|
+
/** Suspension visual smoothing rate (1 - exp(-rate*dt)). Default 10. */
|
|
156
|
+
wheelModelLerpPosRate?: number;
|
|
157
|
+
/** Spin the wheel mesh the other way. Default false. (upstream `wheelModelReversRotation`) */
|
|
158
|
+
wheelModelReverseRotation?: boolean;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What the wheel needs from its vehicle. `VehicleController` satisfies this
|
|
163
|
+
* structurally (it exposes `world`, `body`, `chassisObject`, `gravityMag`).
|
|
164
|
+
*/
|
|
165
|
+
export type WheelVehicleContext = {
|
|
166
|
+
readonly world: World;
|
|
167
|
+
readonly body: RigidBody;
|
|
168
|
+
readonly chassisObject: THREE.Object3D;
|
|
169
|
+
readonly gravityMag: number;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Local (type-only) view of the shared rigid-body userData contract owned by
|
|
174
|
+
* `shared/physics-world.ts` (`ControllerUserData`). Runtime key is
|
|
175
|
+
* `controller` (upstream used `ecctrl`). Wheel shapecasts skip bodies with
|
|
176
|
+
* `excludeRay` or `excludeVehicleRay` — the on-foot character body should set
|
|
177
|
+
* `{ controller: { excludeVehicleRay: true } }`.
|
|
178
|
+
*/
|
|
179
|
+
type WheelUserData = {
|
|
180
|
+
controller?: {
|
|
181
|
+
excludeRay?: boolean;
|
|
182
|
+
excludeCharacterRay?: boolean;
|
|
183
|
+
excludeVehicleRay?: boolean;
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const DEFAULT_LNG_SLIP_CURVE: CurveData = {
|
|
188
|
+
points: [
|
|
189
|
+
{ x: 0, y: 0, r_out: 1.45 },
|
|
190
|
+
{ x: 0.25, y: 1, r_in: 0, r_out: 0 },
|
|
191
|
+
{ x: 1, y: 0.7, r_in: 0 },
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
const DEFAULT_LAT_SLIP_CURVE: CurveData = {
|
|
195
|
+
points: [
|
|
196
|
+
{ x: 0, y: 0, r_out: 1.45 },
|
|
197
|
+
{ x: 0.15, y: 1, r_in: 0, r_out: 0 },
|
|
198
|
+
{ x: 1, y: 0.9, r_in: 0 },
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
const DEFAULT_MASS_RATIO_FALL_OFF_CURVE: CurveData = {
|
|
202
|
+
points: [
|
|
203
|
+
{ x: 0, y: 0.5, r_out: 0 },
|
|
204
|
+
{ x: 0.5, y: 1, r_out: 0 },
|
|
205
|
+
{ x: 1, y: 1, r_in: 0 },
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One shapecast wheel. Constructed via `VehicleController.addWheel()`.
|
|
211
|
+
*
|
|
212
|
+
* Scene-graph pose derivation (the load-bearing design): the wheel's world
|
|
213
|
+
* pose comes from `wheelGroup.getWorldPosition/getWorldQuaternion`, whose
|
|
214
|
+
* parent chain is the vehicle's `chassisObject` — which the controller syncs
|
|
215
|
+
* from the rigid body at the top of ITS `update()`, BEFORE any wheel updates.
|
|
216
|
+
* Steering mutates `wheelGroup`'s local rotation via `rotateY`; the
|
|
217
|
+
* accumulated group rotation is the authoritative steer pose (the
|
|
218
|
+
* `steerAngle` number is bookkeeping for increments and reporting).
|
|
219
|
+
*
|
|
220
|
+
* Add your wheel mesh as a child of `modelObject` (it spins around local X;
|
|
221
|
+
* `suspensionGroup` above it bounces on local Y with the suspension).
|
|
222
|
+
*/
|
|
223
|
+
export class ShapeCastWheel {
|
|
224
|
+
readonly id: string;
|
|
225
|
+
readonly name: string;
|
|
226
|
+
/** Master enable; `update()` early-outs when false. */
|
|
227
|
+
enabled: boolean;
|
|
228
|
+
|
|
229
|
+
/** Steering parent, child of the chassis at the wheel mount point. */
|
|
230
|
+
readonly wheelGroup = new THREE.Group();
|
|
231
|
+
/** Suspension bounce group (local Y), child of `wheelGroup`. */
|
|
232
|
+
readonly suspensionGroup = new THREE.Group();
|
|
233
|
+
/** Add your wheel mesh here; spins around local X. Child of `suspensionGroup`. */
|
|
234
|
+
readonly modelObject = new THREE.Group();
|
|
235
|
+
|
|
236
|
+
/** Flags the vehicle brain reads for demand routing / torque split. */
|
|
237
|
+
readonly driveWheel: boolean;
|
|
238
|
+
readonly steerWheel: boolean;
|
|
239
|
+
readonly brakeWheel: boolean;
|
|
240
|
+
readonly driveTorqueWeight: number;
|
|
241
|
+
|
|
242
|
+
// --- vehicle context ---
|
|
243
|
+
private readonly vehicle: WheelVehicleContext;
|
|
244
|
+
|
|
245
|
+
// --- options ---
|
|
246
|
+
private readonly groundDetection: "shapeCast" | "rayCast";
|
|
247
|
+
private readonly rayShapeR: number;
|
|
248
|
+
private readonly rayShapeH: number;
|
|
249
|
+
private readonly rayLength: number;
|
|
250
|
+
private readonly springK: number;
|
|
251
|
+
private readonly dampingC: number;
|
|
252
|
+
private readonly driveInvert: boolean;
|
|
253
|
+
private readonly steerInvert: boolean;
|
|
254
|
+
private readonly maxBrakeTorque: number;
|
|
255
|
+
private readonly rollingResistanceCoef: number;
|
|
256
|
+
private readonly lowVelThreshold: number;
|
|
257
|
+
private readonly tireGripFactor: number;
|
|
258
|
+
private readonly lngFrictionEllipseScale: number;
|
|
259
|
+
private readonly latFrictionEllipseScale: number;
|
|
260
|
+
private readonly relaxLngRate: number;
|
|
261
|
+
private readonly relaxLatRate: number;
|
|
262
|
+
private readonly minLngRelaxCoeff: number;
|
|
263
|
+
private readonly minLatRelaxCoeff: number;
|
|
264
|
+
private readonly followPlatform: boolean;
|
|
265
|
+
private readonly applyCounterMass: boolean;
|
|
266
|
+
private readonly applyCounterFriction: boolean;
|
|
267
|
+
private readonly updateModel: boolean;
|
|
268
|
+
private readonly wheelModelRadius: number;
|
|
269
|
+
private readonly wheelModelLerpPosRate: number;
|
|
270
|
+
private readonly wheelModelReverseRotation: boolean;
|
|
271
|
+
|
|
272
|
+
// --- derived wheel constants (wheelInertia = 0.5 * m * r^2) ---
|
|
273
|
+
private readonly wheelMass: number;
|
|
274
|
+
private readonly wheelInertia: number;
|
|
275
|
+
|
|
276
|
+
// --- baked curves ---
|
|
277
|
+
private readonly lngSlipRatioCurve: CurveLUT;
|
|
278
|
+
private readonly latSlipRatioCurve: CurveLUT;
|
|
279
|
+
private readonly massRatioFallOffCurve: CurveLUT;
|
|
280
|
+
|
|
281
|
+
// --- shapecast primitives (constructed once; Ray wraps LIVE refs to
|
|
282
|
+
// rayOrigin/rayDirection — mutate the vectors, never re-create the Ray) ---
|
|
283
|
+
private readonly rayShape: Cylinder;
|
|
284
|
+
private readonly rayCastRay: Ray;
|
|
285
|
+
private readonly rotZ90 = new THREE.Quaternion().setFromAxisAngle(
|
|
286
|
+
new THREE.Vector3(0, 0, 1),
|
|
287
|
+
Math.PI / 2
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// --- vehicle info scratch (refreshed every update for the freshest pose) ---
|
|
291
|
+
private readonly vehiclePos = new THREE.Vector3();
|
|
292
|
+
private readonly vehicleQuat = new THREE.Quaternion();
|
|
293
|
+
private readonly vehicleLinVel = new THREE.Vector3();
|
|
294
|
+
private readonly vehicleAngVel = new THREE.Vector3();
|
|
295
|
+
private readonly vehicleXAxis = new THREE.Vector3();
|
|
296
|
+
private readonly vehicleYAxis = new THREE.Vector3();
|
|
297
|
+
private readonly vehicleZAxis = new THREE.Vector3();
|
|
298
|
+
|
|
299
|
+
// --- wheel physical state ---
|
|
300
|
+
private effectiveInertia = 0;
|
|
301
|
+
private _wheelAngVel = 0;
|
|
302
|
+
private readonly supportPoint = new THREE.Vector3();
|
|
303
|
+
|
|
304
|
+
// --- friction state ---
|
|
305
|
+
private frictionCoef = 0;
|
|
306
|
+
private readonly _lngAxis = new THREE.Vector3();
|
|
307
|
+
private readonly _latAxis = new THREE.Vector3();
|
|
308
|
+
private readonly lngFrictionImp = new THREE.Vector3();
|
|
309
|
+
private readonly latFrictionImp = new THREE.Vector3();
|
|
310
|
+
private _lngSlipRatio = 0;
|
|
311
|
+
private _latSlipRatio = 0;
|
|
312
|
+
private _slipStrength = 0;
|
|
313
|
+
private smoothedLngImpulse = 0;
|
|
314
|
+
private smoothedLatImpulse = 0;
|
|
315
|
+
private desiredLngImpulse = 0;
|
|
316
|
+
private desiredLatImpulse = 0;
|
|
317
|
+
|
|
318
|
+
// --- steering state ---
|
|
319
|
+
private _steerAngle = 0;
|
|
320
|
+
private steerTarget = 0;
|
|
321
|
+
private steerIncrement = 0;
|
|
322
|
+
private steerDemand = 0;
|
|
323
|
+
private steerWheelConfig: SteerWheelConfig | null = null;
|
|
324
|
+
|
|
325
|
+
// --- drive state ---
|
|
326
|
+
private _driveTorque = 0;
|
|
327
|
+
private driveDemand = 0;
|
|
328
|
+
private driveWheelConfig: DriveWheelConfig | null = null;
|
|
329
|
+
|
|
330
|
+
// --- brake state ---
|
|
331
|
+
private _brakeTorque = 0;
|
|
332
|
+
private brakeDemand = 0;
|
|
333
|
+
|
|
334
|
+
// --- shapecast scratch/state ---
|
|
335
|
+
private readonly distFromRayOriginToVehicle = new THREE.Vector3();
|
|
336
|
+
private readonly angvelToLinvel = new THREE.Vector3();
|
|
337
|
+
private readonly floatingImpulse = new THREE.Vector3();
|
|
338
|
+
private readonly rayOrigin = new THREE.Vector3();
|
|
339
|
+
private readonly rayRotation = new THREE.Quaternion();
|
|
340
|
+
private readonly rayDirection = new THREE.Vector3();
|
|
341
|
+
private readonly rayOriginVelocity = new THREE.Vector3();
|
|
342
|
+
private readonly rayUpAxis = new THREE.Vector3();
|
|
343
|
+
private readonly rayFWDAxis = new THREE.Vector3();
|
|
344
|
+
private readonly rayLeftAxis = new THREE.Vector3();
|
|
345
|
+
private _shapeRayHit: ColliderShapeCastHit | null = null;
|
|
346
|
+
private _rayHit: RayColliderIntersection | null = null;
|
|
347
|
+
private _suspensionToi = 0;
|
|
348
|
+
private _rayHitBody: RigidBody | null = null;
|
|
349
|
+
private readonly rayShapeCenter = new THREE.Vector3();
|
|
350
|
+
private readonly stableRayHitPoint = new THREE.Vector3();
|
|
351
|
+
private readonly targetRayHitPoint = new THREE.Vector3();
|
|
352
|
+
private readonly rayHitPointOffset = new THREE.Vector3();
|
|
353
|
+
private readonly rayHitPointPosition = new THREE.Vector3();
|
|
354
|
+
private readonly rayHitPointVelocity = new THREE.Vector3();
|
|
355
|
+
private readonly rayHitPointVelOnPlane = new THREE.Vector3();
|
|
356
|
+
private readonly rayHitPointNormal = new THREE.Vector3();
|
|
357
|
+
private _rayHitFriction = 0;
|
|
358
|
+
|
|
359
|
+
// --- moving platform state ---
|
|
360
|
+
private massRatio = 1;
|
|
361
|
+
private _isOnMovingObject = false;
|
|
362
|
+
private wheelSupportForceMag = 0;
|
|
363
|
+
private readonly wheelSupportImpulse = new THREE.Vector3();
|
|
364
|
+
private readonly wheelFrictionImpulse = new THREE.Vector3();
|
|
365
|
+
private readonly movingObjectPosition = new THREE.Vector3();
|
|
366
|
+
private readonly movingObjectVelocity = new THREE.Vector3();
|
|
367
|
+
private readonly movingObjectVelocityOnPlane = new THREE.Vector3();
|
|
368
|
+
private readonly movingObjectLinearVelocity = new THREE.Vector3();
|
|
369
|
+
private readonly movingObjectAngularVelocity = new THREE.Vector3();
|
|
370
|
+
private readonly distanceFromOriginToObjectPoint = new THREE.Vector3();
|
|
371
|
+
private readonly movingObjectAngvelToLinvel = new THREE.Vector3();
|
|
372
|
+
|
|
373
|
+
// --- world pose scratch ---
|
|
374
|
+
private readonly worldPos = new THREE.Vector3();
|
|
375
|
+
private readonly worldQuat = new THREE.Quaternion();
|
|
376
|
+
|
|
377
|
+
// --- published state (one-frame-stale, mirrors upstream wheelInfo) ---
|
|
378
|
+
// Upstream copies every VALUE-typed field into `wheelInfo` at the TOP of the
|
|
379
|
+
// wheel's frame (updateVehicleInfo), BEFORE floatVehicle/solveWheelRotation
|
|
380
|
+
// overwrite the live fields — so the vehicle brain (and user code) always
|
|
381
|
+
// reads LAST step's values. Vector fields are shared by reference upstream
|
|
382
|
+
// (mutated in place), so their getters stay live. Do NOT "fix" this by
|
|
383
|
+
// reading the live fields: impulse gating on contact transitions and
|
|
384
|
+
// RPM-threshold shift timing depend on the stale snapshot.
|
|
385
|
+
private publishedRayHit: ColliderShapeCastHit | RayColliderIntersection | null =
|
|
386
|
+
null;
|
|
387
|
+
private publishedRayHitBody: RigidBody | null = null;
|
|
388
|
+
private publishedRayHitFriction = 0;
|
|
389
|
+
private publishedIsOnPlatform = false;
|
|
390
|
+
private publishedLngSlipRatio = 0;
|
|
391
|
+
private publishedLatSlipRatio = 0;
|
|
392
|
+
private publishedSlipStrength = 0;
|
|
393
|
+
private publishedEffInertia = 0;
|
|
394
|
+
private publishedSteerAngle = 0;
|
|
395
|
+
private publishedDriveTorque = 0;
|
|
396
|
+
private publishedBrakeTorque = 0;
|
|
397
|
+
private publishedWheelAngVel = 0;
|
|
398
|
+
private publishedWheelLinVel = 0;
|
|
399
|
+
|
|
400
|
+
/** Ground-query filter: skip excluded bodies (userData key `controller`). */
|
|
401
|
+
private readonly rayFilter = (collider: Collider): boolean => {
|
|
402
|
+
const userData = collider.parent()?.userData as WheelUserData | undefined;
|
|
403
|
+
return !(
|
|
404
|
+
userData?.controller?.excludeRay || userData?.controller?.excludeVehicleRay
|
|
405
|
+
);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
constructor(vehicle: WheelVehicleContext, options: WheelOptions) {
|
|
409
|
+
this.vehicle = vehicle;
|
|
410
|
+
|
|
411
|
+
this.id = options.id ?? THREE.MathUtils.generateUUID();
|
|
412
|
+
this.name = options.name ?? "";
|
|
413
|
+
this.enabled = options.enable ?? true;
|
|
414
|
+
|
|
415
|
+
this.groundDetection = options.groundDetection ?? "shapeCast";
|
|
416
|
+
this.rayShapeR = options.rayShapeR ?? 0.5;
|
|
417
|
+
this.rayShapeH = options.rayShapeH ?? 0.15;
|
|
418
|
+
this.rayLength = options.rayLength ?? 0.5;
|
|
419
|
+
this.springK = options.springK ?? 180;
|
|
420
|
+
this.dampingC = options.dampingC ?? 16; // max at 2*sqrt(K*mass)
|
|
421
|
+
|
|
422
|
+
this.driveInvert = options.driveInvert ?? false;
|
|
423
|
+
this.driveWheel = options.driveWheel ?? false;
|
|
424
|
+
this.driveTorqueWeight = options.driveTorqueWeight ?? 1;
|
|
425
|
+
this.steerInvert = options.steerInvert ?? false;
|
|
426
|
+
this.steerWheel = options.steerWheel ?? false;
|
|
427
|
+
this.brakeWheel = options.brakeWheel ?? false;
|
|
428
|
+
this.maxBrakeTorque = options.maxBrakeTorque ?? 40;
|
|
429
|
+
|
|
430
|
+
this.rollingResistanceCoef = options.rollingResistanceCoef ?? 0.007;
|
|
431
|
+
|
|
432
|
+
this.lowVelThreshold = options.lowVelThreshold ?? 0.4;
|
|
433
|
+
this.tireGripFactor = options.tireGripFactor ?? 1.5;
|
|
434
|
+
this.lngFrictionEllipseScale = options.lngFrictionEllipseScale ?? 1;
|
|
435
|
+
this.latFrictionEllipseScale = options.latFrictionEllipseScale ?? 1;
|
|
436
|
+
this.relaxLngRate = options.relaxLngRate ?? 0.05;
|
|
437
|
+
this.relaxLatRate = options.relaxLatRate ?? 0.1;
|
|
438
|
+
this.minLngRelaxCoeff = options.minLngRelaxCoeff ?? 0.3;
|
|
439
|
+
this.minLatRelaxCoeff = options.minLatRelaxCoeff ?? 0.3;
|
|
440
|
+
|
|
441
|
+
this.followPlatform = options.followPlatform ?? true;
|
|
442
|
+
this.applyCounterMass = options.applyCounterMass ?? true;
|
|
443
|
+
this.applyCounterFriction = options.applyCounterFriction ?? true;
|
|
444
|
+
|
|
445
|
+
this.updateModel = options.updateModel ?? true;
|
|
446
|
+
this.wheelModelRadius = options.wheelModelRadius ?? 0.5;
|
|
447
|
+
this.wheelModelLerpPosRate = options.wheelModelLerpPosRate ?? 10;
|
|
448
|
+
this.wheelModelReverseRotation = options.wheelModelReverseRotation ?? false;
|
|
449
|
+
|
|
450
|
+
// Derived wheel constants (visual density proxy -> inertia; no collider).
|
|
451
|
+
const wheelModelDensity = options.wheelModelDensity ?? 1.5;
|
|
452
|
+
const wheelVolume =
|
|
453
|
+
Math.PI * this.rayShapeR * this.rayShapeR * (this.rayShapeH * 2);
|
|
454
|
+
this.wheelMass = wheelModelDensity * wheelVolume;
|
|
455
|
+
this.wheelInertia = 0.5 * this.wheelMass * this.rayShapeR * this.rayShapeR;
|
|
456
|
+
|
|
457
|
+
// Bake curve LUTs.
|
|
458
|
+
const lngData = options.lngSlipRatioCurveData ?? DEFAULT_LNG_SLIP_CURVE;
|
|
459
|
+
this.lngSlipRatioCurve = bakeCurveLUT(lngData.points, lngData.samples ?? 50);
|
|
460
|
+
const latData = options.latSlipRatioCurveData ?? DEFAULT_LAT_SLIP_CURVE;
|
|
461
|
+
this.latSlipRatioCurve = bakeCurveLUT(latData.points, latData.samples ?? 50);
|
|
462
|
+
const massData =
|
|
463
|
+
options.massRatioFallOffCurveData ?? DEFAULT_MASS_RATIO_FALL_OFF_CURVE;
|
|
464
|
+
this.massRatioFallOffCurve = bakeCurveLUT(
|
|
465
|
+
massData.points,
|
|
466
|
+
massData.samples ?? 50
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// Cylinder ctor order is (halfHeight, radius) — swapping the args gives a
|
|
470
|
+
// pancake wheel that "works" until side contacts.
|
|
471
|
+
this.rayShape = new RAPIER.Cylinder(this.rayShapeH, this.rayShapeR);
|
|
472
|
+
// The Ray holds LIVE references to rayOrigin/rayDirection.
|
|
473
|
+
this.rayCastRay = new RAPIER.Ray(this.rayOrigin, this.rayDirection);
|
|
474
|
+
|
|
475
|
+
// Build the visual hierarchy; the caller (VehicleController.addWheel)
|
|
476
|
+
// parents `wheelGroup` under the chassis object.
|
|
477
|
+
this.wheelGroup.position.copy(options.position);
|
|
478
|
+
this.wheelGroup.add(this.suspensionGroup);
|
|
479
|
+
this.suspensionGroup.add(this.modelObject);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Full per-step wheel pipeline. Called by `VehicleController.update()`
|
|
484
|
+
* (children-first order) once per fixed physics step, BEFORE `world.step()`.
|
|
485
|
+
*/
|
|
486
|
+
update(): void {
|
|
487
|
+
if (!this.enabled) return;
|
|
488
|
+
const body = this.vehicle.body;
|
|
489
|
+
const gravityMag = this.vehicle.gravityMag;
|
|
490
|
+
|
|
491
|
+
// 1. Refresh vehicle pose/velocity info + publish LAST step's wheel state
|
|
492
|
+
// (the snapshot the vehicle brain reads — upstream wheelInfo).
|
|
493
|
+
this.updateVehicleInfo(body);
|
|
494
|
+
|
|
495
|
+
// 2. Update shapecast pose/dir/axes/velocity (applies LAST frame's steer increment).
|
|
496
|
+
this.updateShapeCastDir();
|
|
497
|
+
|
|
498
|
+
// 3. Convert demands into drive torque / steer target / brake torque.
|
|
499
|
+
this.handleUserInput();
|
|
500
|
+
|
|
501
|
+
// 4. Slew the steer angle (increment applied to the group NEXT frame).
|
|
502
|
+
this.steeringWheel();
|
|
503
|
+
|
|
504
|
+
// 5. Cast, find contact, compute the suspension (floating) impulse.
|
|
505
|
+
this.floatVehicle(body);
|
|
506
|
+
|
|
507
|
+
// 6. Detect moving platforms and their contact-point velocity.
|
|
508
|
+
this.isOnMovingObjectDetect(body);
|
|
509
|
+
|
|
510
|
+
// 7. Push wheel load back onto the stood-on dynamic body (previous frame's force).
|
|
511
|
+
this.applyMassOnStandCollider();
|
|
512
|
+
|
|
513
|
+
// 8. Push tire friction back onto the stood-on dynamic body (previous frame's impulses).
|
|
514
|
+
this.applyFrictionOnStandCollider();
|
|
515
|
+
|
|
516
|
+
// 9. Relative contact velocity (platform-adjusted).
|
|
517
|
+
this.computeRelativeVelocity();
|
|
518
|
+
|
|
519
|
+
// 10. Contact friction coefficient (surface + tire grip average).
|
|
520
|
+
this.computeContactFriction();
|
|
521
|
+
|
|
522
|
+
// 11. Slip-curve tire model -> lng/lat friction impulses.
|
|
523
|
+
this.computeWheelFrictionImpulse(gravityMag);
|
|
524
|
+
|
|
525
|
+
// 12. Integrate wheel spin (drive/brake/rolling-resistance/friction reaction).
|
|
526
|
+
this.solveWheelRotation();
|
|
527
|
+
|
|
528
|
+
// 13. Sync the visual groups (suspension bounce + spin).
|
|
529
|
+
this.updateWheelModel();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Remove the wheel's groups from the scene graph. */
|
|
533
|
+
dispose(): void {
|
|
534
|
+
this.wheelGroup.removeFromParent();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// --- demand/config setters (called by the vehicle brain) ---
|
|
538
|
+
setDriveDemand(v: number): void {
|
|
539
|
+
this.driveDemand = v;
|
|
540
|
+
}
|
|
541
|
+
setBrakeDemand(v: number): void {
|
|
542
|
+
this.brakeDemand = v;
|
|
543
|
+
}
|
|
544
|
+
setSteerDemand(v: number): void {
|
|
545
|
+
this.steerDemand = v;
|
|
546
|
+
}
|
|
547
|
+
setDriveWheelConfig(cfg: DriveWheelConfig): void {
|
|
548
|
+
this.driveWheelConfig = cfg;
|
|
549
|
+
}
|
|
550
|
+
setSteerWheelConfig(cfg: SteerWheelConfig): void {
|
|
551
|
+
this.steerWheelConfig = cfg;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- readonly state ---
|
|
555
|
+
// Value-typed getters return the ONE-FRAME-STALE published snapshot taken at
|
|
556
|
+
// the top of update() (upstream wheelInfo semantics); vector getters return
|
|
557
|
+
// live internal instances shared by reference (copy, never mutate).
|
|
558
|
+
get rayHit(): ColliderShapeCastHit | RayColliderIntersection | null {
|
|
559
|
+
return this.publishedRayHit;
|
|
560
|
+
}
|
|
561
|
+
get rayHitBody(): RigidBody | null {
|
|
562
|
+
return this.publishedRayHitBody;
|
|
563
|
+
}
|
|
564
|
+
/** Friction application point (center-section projected + side blended). */
|
|
565
|
+
get rayHitPos(): THREE.Vector3 {
|
|
566
|
+
return this.rayHitPointPosition;
|
|
567
|
+
}
|
|
568
|
+
get rayHitNormal(): THREE.Vector3 {
|
|
569
|
+
return this.rayHitPointNormal;
|
|
570
|
+
}
|
|
571
|
+
/** Ground collider friction at the contact. (upstream `rayHitFriciton`) */
|
|
572
|
+
get rayHitFriction(): number {
|
|
573
|
+
return this.publishedRayHitFriction;
|
|
574
|
+
}
|
|
575
|
+
get rayOriginVel(): THREE.Vector3 {
|
|
576
|
+
return this.rayOriginVelocity;
|
|
577
|
+
}
|
|
578
|
+
/** Relative (platform-adjusted) contact velocity. */
|
|
579
|
+
get rayHitPointVel(): THREE.Vector3 {
|
|
580
|
+
return this.rayHitPointVelocity;
|
|
581
|
+
}
|
|
582
|
+
get isOnPlatform(): boolean {
|
|
583
|
+
return this.publishedIsOnPlatform;
|
|
584
|
+
}
|
|
585
|
+
/** Suspension impulse — applied by the vehicle at `supPos`. */
|
|
586
|
+
get floatImp(): THREE.Vector3 {
|
|
587
|
+
return this.floatingImpulse;
|
|
588
|
+
}
|
|
589
|
+
get lngFricImp(): THREE.Vector3 {
|
|
590
|
+
return this.lngFrictionImp;
|
|
591
|
+
}
|
|
592
|
+
get latFricImp(): THREE.Vector3 {
|
|
593
|
+
return this.latFrictionImp;
|
|
594
|
+
}
|
|
595
|
+
get lngAxis(): THREE.Vector3 {
|
|
596
|
+
return this._lngAxis;
|
|
597
|
+
}
|
|
598
|
+
get latAxis(): THREE.Vector3 {
|
|
599
|
+
return this._latAxis;
|
|
600
|
+
}
|
|
601
|
+
get lngSlipRatio(): number {
|
|
602
|
+
return this.publishedLngSlipRatio;
|
|
603
|
+
}
|
|
604
|
+
get latSlipRatio(): number {
|
|
605
|
+
return this.publishedLatSlipRatio;
|
|
606
|
+
}
|
|
607
|
+
/** max(lngSlipRatio, latSlipRatio) — handy for skid VFX/SFX triggers. */
|
|
608
|
+
get slipStrength(): number {
|
|
609
|
+
return this.publishedSlipStrength;
|
|
610
|
+
}
|
|
611
|
+
/** effectiveInertia = 0.5*m_wheel*r^2 + (load/g)*r^2. */
|
|
612
|
+
get effInertia(): number {
|
|
613
|
+
return this.publishedEffInertia;
|
|
614
|
+
}
|
|
615
|
+
/** Suspension application point (shape center + side-contact offset). */
|
|
616
|
+
get supPos(): THREE.Vector3 {
|
|
617
|
+
return this.supportPoint;
|
|
618
|
+
}
|
|
619
|
+
get steerAngle(): number {
|
|
620
|
+
return this.publishedSteerAngle;
|
|
621
|
+
}
|
|
622
|
+
get driveTorque(): number {
|
|
623
|
+
return this.publishedDriveTorque;
|
|
624
|
+
}
|
|
625
|
+
get brakeTorque(): number {
|
|
626
|
+
return this.publishedBrakeTorque;
|
|
627
|
+
}
|
|
628
|
+
/** Wheel spin (rad/s). */
|
|
629
|
+
get wheelAngVel(): number {
|
|
630
|
+
return this.publishedWheelAngVel;
|
|
631
|
+
}
|
|
632
|
+
/** Wheel surface speed = wheelAngVel * rayShapeR (m/s). */
|
|
633
|
+
get wheelLinVel(): number {
|
|
634
|
+
return this.publishedWheelLinVel;
|
|
635
|
+
}
|
|
636
|
+
/** Current suspension hit distance (0 when airborne). Port-only extension
|
|
637
|
+
* (no upstream wheelInfo field), so it reads LIVE, not the snapshot. */
|
|
638
|
+
get suspensionToi(): number {
|
|
639
|
+
return this._suspensionToi;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ------------------------------------------------------------------
|
|
643
|
+
// Pipeline internals (order and formulas mirror upstream exactly)
|
|
644
|
+
// ------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
private updateVehicleInfo(body: RigidBody): void {
|
|
647
|
+
this.vehiclePos.copy(body.translation());
|
|
648
|
+
this.vehicleQuat.copy(body.rotation());
|
|
649
|
+
|
|
650
|
+
this.vehicleYAxis.set(0, 1, 0).applyQuaternion(this.vehicleQuat);
|
|
651
|
+
this.vehicleXAxis.set(1, 0, 0).applyQuaternion(this.vehicleQuat);
|
|
652
|
+
this.vehicleZAxis.set(0, 0, 1).applyQuaternion(this.vehicleQuat);
|
|
653
|
+
|
|
654
|
+
this.vehicleLinVel.copy(body.linvel());
|
|
655
|
+
this.vehicleAngVel.copy(body.angvel());
|
|
656
|
+
|
|
657
|
+
// Publish LAST step's value-typed wheel state (see the published-state
|
|
658
|
+
// field block for why this snapshot must happen HERE, before floatVehicle
|
|
659
|
+
// and solveWheelRotation mutate the live fields).
|
|
660
|
+
this.publishedRayHit =
|
|
661
|
+
this.groundDetection === "rayCast" ? this._rayHit : this._shapeRayHit;
|
|
662
|
+
this.publishedRayHitBody = this._rayHitBody;
|
|
663
|
+
this.publishedRayHitFriction = this._rayHitFriction;
|
|
664
|
+
this.publishedIsOnPlatform = this._isOnMovingObject;
|
|
665
|
+
this.publishedLngSlipRatio = this._lngSlipRatio;
|
|
666
|
+
this.publishedLatSlipRatio = this._latSlipRatio;
|
|
667
|
+
this.publishedSlipStrength = this._slipStrength;
|
|
668
|
+
this.publishedEffInertia = this.effectiveInertia;
|
|
669
|
+
this.publishedSteerAngle = this._steerAngle;
|
|
670
|
+
this.publishedDriveTorque = this._driveTorque;
|
|
671
|
+
this.publishedBrakeTorque = this._brakeTorque;
|
|
672
|
+
this.publishedWheelAngVel = this._wheelAngVel;
|
|
673
|
+
this.publishedWheelLinVel = this._wheelAngVel * this.rayShapeR;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private updateShapeCastDir(): void {
|
|
677
|
+
// Steer and gather world pos/quat. NOTE: applies LAST frame's increment —
|
|
678
|
+
// the group's accumulated local rotation is the authoritative steer pose.
|
|
679
|
+
// Requires the chassis matrixWorld already synced from the rigid body
|
|
680
|
+
// (VehicleController.update() step A).
|
|
681
|
+
if (this.steerWheel) this.wheelGroup.rotateY(this.steerIncrement);
|
|
682
|
+
this.wheelGroup.getWorldPosition(this.worldPos);
|
|
683
|
+
this.wheelGroup.getWorldQuaternion(this.worldQuat);
|
|
684
|
+
|
|
685
|
+
// Update shape cast current info: pos/dir/axes.
|
|
686
|
+
this.rayOrigin.copy(this.worldPos);
|
|
687
|
+
this.rayDirection.set(0, -1, 0).applyQuaternion(this.worldQuat);
|
|
688
|
+
this.rayUpAxis.copy(this.rayDirection).negate();
|
|
689
|
+
this.rayFWDAxis.set(0, 0, 1).applyQuaternion(this.worldQuat);
|
|
690
|
+
this.rayLeftAxis.crossVectors(this.rayUpAxis, this.rayFWDAxis).normalize();
|
|
691
|
+
|
|
692
|
+
// Ray origin velocity = linvel + angvel x r.
|
|
693
|
+
this.distFromRayOriginToVehicle.copy(this.rayOrigin).sub(this.vehiclePos);
|
|
694
|
+
this.angvelToLinvel.crossVectors(
|
|
695
|
+
this.vehicleAngVel,
|
|
696
|
+
this.distFromRayOriginToVehicle
|
|
697
|
+
);
|
|
698
|
+
this.rayOriginVelocity.copy(this.vehicleLinVel).add(this.angvelToLinvel);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private handleUserInput(): void {
|
|
702
|
+
const currDriveConfig = this.driveWheelConfig;
|
|
703
|
+
const currSteerConfig = this.steerWheelConfig;
|
|
704
|
+
|
|
705
|
+
// Drive torque from demand, torque split, gear ratio, reverse scaling and
|
|
706
|
+
// the engine torque curve over |wheelAngVel|/maxAngVel. NOTE: intentionally
|
|
707
|
+
// NOT zeroed when the guard fails (faithful to upstream).
|
|
708
|
+
if (this.driveWheel && currDriveConfig && currDriveConfig.maxDriveTorque !== 0) {
|
|
709
|
+
const maxAngVel =
|
|
710
|
+
currDriveConfig.maxWheelAngVel *
|
|
711
|
+
(this.driveDemand < 0 ? currDriveConfig.reverseRPMScale : 1);
|
|
712
|
+
const angvelRatio =
|
|
713
|
+
maxAngVel > 0 ? Math.abs(this._wheelAngVel) / maxAngVel : 1;
|
|
714
|
+
this._driveTorque =
|
|
715
|
+
this.driveDemand *
|
|
716
|
+
currDriveConfig.maxDriveTorque *
|
|
717
|
+
currDriveConfig.driveRatio *
|
|
718
|
+
(this.driveDemand < 0 ? currDriveConfig.reverseTorqueScale : 1) *
|
|
719
|
+
evaluateCurveLUT(angvelRatio, currDriveConfig.engineTorqueCurve) *
|
|
720
|
+
(this.driveInvert ? -1 : 1);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Speed-sensitive steering: normalized by the CHASSIS forward speed over
|
|
724
|
+
// the car's theoretical top speed (maxWheelAngVel * r), not the wheel spin.
|
|
725
|
+
if (this.steerWheel && currSteerConfig) {
|
|
726
|
+
const steerMaxWheelAngVel = currSteerConfig.maxWheelAngVel;
|
|
727
|
+
const speedRatio =
|
|
728
|
+
steerMaxWheelAngVel > 0
|
|
729
|
+
? clamp(
|
|
730
|
+
this.vehicleLinVel.dot(this.vehicleZAxis) /
|
|
731
|
+
(steerMaxWheelAngVel * this.rayShapeR),
|
|
732
|
+
0,
|
|
733
|
+
1
|
|
734
|
+
)
|
|
735
|
+
: 0;
|
|
736
|
+
this.steerTarget =
|
|
737
|
+
this.steerDemand *
|
|
738
|
+
currSteerConfig.maxSteerAngle *
|
|
739
|
+
evaluateCurveLUT(speedRatio, currSteerConfig.steerAngleCurve) *
|
|
740
|
+
(this.steerInvert ? -1 : 1);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Brake: simply max torque scaled by demand.
|
|
744
|
+
if (this.brakeWheel) {
|
|
745
|
+
this._brakeTorque = this.brakeDemand * this.maxBrakeTorque;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private steeringWheel(): void {
|
|
750
|
+
const angleDiff = this.steerTarget - this._steerAngle;
|
|
751
|
+
const maxIncrement =
|
|
752
|
+
(this.steerWheelConfig?.steerRate ?? 0) * this.vehicle.world.timestep;
|
|
753
|
+
this.steerIncrement =
|
|
754
|
+
Math.sign(angleDiff) * Math.min(Math.abs(angleDiff), maxIncrement);
|
|
755
|
+
|
|
756
|
+
// Group rotation happens NEXT frame in updateShapeCastDir().
|
|
757
|
+
this._steerAngle += this.steerIncrement;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private floatVehicle(body: RigidBody): void {
|
|
761
|
+
const world = this.vehicle.world;
|
|
762
|
+
|
|
763
|
+
// Cast the wheel detection shape/ray.
|
|
764
|
+
if (this.groundDetection === "rayCast") {
|
|
765
|
+
this._rayHit = world.castRayAndGetNormal(
|
|
766
|
+
this.rayCastRay,
|
|
767
|
+
this.rayLength + this.rayShapeR,
|
|
768
|
+
false,
|
|
769
|
+
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
|
|
770
|
+
undefined,
|
|
771
|
+
undefined,
|
|
772
|
+
body,
|
|
773
|
+
this.rayFilter
|
|
774
|
+
);
|
|
775
|
+
} else {
|
|
776
|
+
this._shapeRayHit = world.castShape(
|
|
777
|
+
this.rayOrigin,
|
|
778
|
+
// rotZ90 applied in LOCAL space aligns the cylinder's Y principal
|
|
779
|
+
// axis with the wheel's local X axle (multiply, NOT premultiply).
|
|
780
|
+
this.rayRotation.copy(this.worldQuat).multiply(this.rotZ90),
|
|
781
|
+
this.rayDirection,
|
|
782
|
+
this.rayShape,
|
|
783
|
+
0, // targetDistance
|
|
784
|
+
this.rayLength, // maxToi
|
|
785
|
+
false, // stopAtPenetration
|
|
786
|
+
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
|
|
787
|
+
undefined,
|
|
788
|
+
undefined,
|
|
789
|
+
body,
|
|
790
|
+
this.rayFilter
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Retrieve ray hit collider and distance (rayCast hit uses camelCase
|
|
795
|
+
// `timeOfImpact`, shapecast hit uses snake_case `time_of_impact`).
|
|
796
|
+
const hitCollider =
|
|
797
|
+
this.groundDetection === "rayCast"
|
|
798
|
+
? this._rayHit?.collider
|
|
799
|
+
: this._shapeRayHit?.collider;
|
|
800
|
+
const hitDistance =
|
|
801
|
+
this.groundDetection === "rayCast"
|
|
802
|
+
? this._rayHit?.timeOfImpact
|
|
803
|
+
: this._shapeRayHit?.time_of_impact;
|
|
804
|
+
|
|
805
|
+
if (hitCollider && hitDistance != null) {
|
|
806
|
+
// The ray starts at the axle, the shapecast at the rim — hence the -rayShapeR.
|
|
807
|
+
this._suspensionToi =
|
|
808
|
+
this.groundDetection === "rayCast"
|
|
809
|
+
? Math.max(0, hitDistance - this.rayShapeR)
|
|
810
|
+
: hitDistance;
|
|
811
|
+
this._rayHitBody = hitCollider.parent();
|
|
812
|
+
// Raw ray hit point.
|
|
813
|
+
if (this.groundDetection === "rayCast") {
|
|
814
|
+
this.targetRayHitPoint
|
|
815
|
+
.copy(this.rayOrigin)
|
|
816
|
+
.addScaledVector(this.rayDirection, hitDistance);
|
|
817
|
+
} else {
|
|
818
|
+
this.targetRayHitPoint.copy(this._shapeRayHit!.witness1);
|
|
819
|
+
}
|
|
820
|
+
// Hit normal.
|
|
821
|
+
this.rayHitPointNormal
|
|
822
|
+
.copy(
|
|
823
|
+
this.groundDetection === "rayCast"
|
|
824
|
+
? this._rayHit!.normal
|
|
825
|
+
: this._shapeRayHit!.normal1
|
|
826
|
+
)
|
|
827
|
+
.normalize();
|
|
828
|
+
// Shape center at suspension hit distance.
|
|
829
|
+
this.rayShapeCenter
|
|
830
|
+
.copy(this.rayOrigin)
|
|
831
|
+
.addScaledVector(this.rayDirection, this._suspensionToi);
|
|
832
|
+
// Stable center-section hit point + side-contact blending (shapecast only).
|
|
833
|
+
let supportOffset = 0;
|
|
834
|
+
if (this.groundDetection === "rayCast") {
|
|
835
|
+
this.stableRayHitPoint.copy(this.targetRayHitPoint);
|
|
836
|
+
} else {
|
|
837
|
+
// Project the raw witness back to the wheel center section.
|
|
838
|
+
const rawOffset = clamp(
|
|
839
|
+
this.rayHitPointOffset
|
|
840
|
+
.copy(this.targetRayHitPoint)
|
|
841
|
+
.sub(this.rayShapeCenter)
|
|
842
|
+
.dot(this.rayLeftAxis),
|
|
843
|
+
-this.rayShapeH,
|
|
844
|
+
this.rayShapeH
|
|
845
|
+
);
|
|
846
|
+
this.stableRayHitPoint
|
|
847
|
+
.copy(this.targetRayHitPoint)
|
|
848
|
+
.addScaledVector(this.rayLeftAxis, -rawOffset);
|
|
849
|
+
|
|
850
|
+
// Blend side support only when the normal shows side contact.
|
|
851
|
+
const normalSide = this.rayHitPointNormal.dot(this.rayLeftAxis);
|
|
852
|
+
const normalFwd = this.rayHitPointNormal.dot(this.rayFWDAxis);
|
|
853
|
+
const sideWeight = clamp(
|
|
854
|
+
Math.abs(normalSide) /
|
|
855
|
+
Math.sqrt(Math.max(1 - normalFwd * normalFwd, 1e-6)),
|
|
856
|
+
0,
|
|
857
|
+
1
|
|
858
|
+
);
|
|
859
|
+
supportOffset = -Math.abs(rawOffset) * Math.sign(normalSide) * sideWeight;
|
|
860
|
+
}
|
|
861
|
+
// Final friction point and support point (two DIFFERENT points — mixing
|
|
862
|
+
// them up reintroduces contact-patch jacking while steering).
|
|
863
|
+
this.rayHitPointPosition
|
|
864
|
+
.copy(this.stableRayHitPoint)
|
|
865
|
+
.addScaledVector(this.rayLeftAxis, supportOffset);
|
|
866
|
+
this.supportPoint
|
|
867
|
+
.copy(this.rayShapeCenter)
|
|
868
|
+
.addScaledVector(this.rayLeftAxis, supportOffset);
|
|
869
|
+
// Ground friction at contact.
|
|
870
|
+
if (this._rayHitFriction !== hitCollider.friction())
|
|
871
|
+
this._rayHitFriction = hitCollider.friction() ?? 0;
|
|
872
|
+
// Spring + damping. NOTE: damping projects LAST frame's relative contact
|
|
873
|
+
// velocity onto rayUpAxis while the impulse points along the hit normal —
|
|
874
|
+
// the mixed frames are intentional (part of the ride feel).
|
|
875
|
+
const springForce =
|
|
876
|
+
this.springK * Math.max(0, this.rayLength - this._suspensionToi);
|
|
877
|
+
const dampingForce =
|
|
878
|
+
this.dampingC * this.rayHitPointVelocity.dot(this.rayUpAxis);
|
|
879
|
+
this.floatingImpulse
|
|
880
|
+
.copy(this.rayHitPointNormal)
|
|
881
|
+
.multiplyScalar(springForce - dampingForce)
|
|
882
|
+
.multiplyScalar(world.timestep);
|
|
883
|
+
} else {
|
|
884
|
+
// Reset contact state when no hit. Smoothed friction impulses are NOT
|
|
885
|
+
// reset — and because the vehicle gates on the PUBLISHED (one-frame-
|
|
886
|
+
// stale) rayHit, the stale lng/lat impulses ARE applied once more on
|
|
887
|
+
// the contact-loss step (faithful upstream behavior).
|
|
888
|
+
this._rayHitBody = null;
|
|
889
|
+
this._suspensionToi = 0;
|
|
890
|
+
this._rayHitFriction = 0;
|
|
891
|
+
this.floatingImpulse.set(0, 0, 0);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private isOnMovingObjectDetect(body: RigidBody): void {
|
|
896
|
+
const hitBody = this._rayHitBody;
|
|
897
|
+
// Dynamic (0) and position-kinematic (2) bodies count as platforms.
|
|
898
|
+
if (
|
|
899
|
+
this.followPlatform &&
|
|
900
|
+
hitBody &&
|
|
901
|
+
(hitBody.bodyType() === RAPIER.RigidBodyType.Dynamic ||
|
|
902
|
+
hitBody.bodyType() === RAPIER.RigidBodyType.KinematicPositionBased)
|
|
903
|
+
) {
|
|
904
|
+
this._isOnMovingObject = true;
|
|
905
|
+
|
|
906
|
+
// Mass-ratio falloff (dynamic only; kinematic platforms pin ratio at 1).
|
|
907
|
+
if (hitBody.bodyType() === RAPIER.RigidBodyType.Dynamic) {
|
|
908
|
+
const ratio = clamp(hitBody.mass() / Math.max(body.mass(), 1e-6), 0, 1);
|
|
909
|
+
this.massRatio = evaluateCurveLUT(ratio, this.massRatioFallOffCurve);
|
|
910
|
+
} else {
|
|
911
|
+
this.massRatio = 1;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Standing-point velocity = linvel + angvel x r, scaled by mass ratio.
|
|
915
|
+
this.movingObjectPosition.copy(hitBody.translation());
|
|
916
|
+
this.distanceFromOriginToObjectPoint
|
|
917
|
+
.copy(this.rayOrigin)
|
|
918
|
+
.sub(this.movingObjectPosition);
|
|
919
|
+
this.movingObjectLinearVelocity.copy(hitBody.linvel());
|
|
920
|
+
this.movingObjectAngularVelocity.copy(hitBody.angvel());
|
|
921
|
+
this.movingObjectAngvelToLinvel.crossVectors(
|
|
922
|
+
this.movingObjectAngularVelocity,
|
|
923
|
+
this.distanceFromOriginToObjectPoint
|
|
924
|
+
);
|
|
925
|
+
this.movingObjectVelocity
|
|
926
|
+
.copy(this.movingObjectLinearVelocity)
|
|
927
|
+
.add(this.movingObjectAngvelToLinvel)
|
|
928
|
+
.multiplyScalar(this.massRatio);
|
|
929
|
+
this.movingObjectVelocityOnPlane
|
|
930
|
+
.copy(this.movingObjectVelocity)
|
|
931
|
+
.projectOnPlane(this.rayHitPointNormal);
|
|
932
|
+
} else {
|
|
933
|
+
this._isOnMovingObject = false;
|
|
934
|
+
this.movingObjectVelocity.set(0, 0, 0);
|
|
935
|
+
this.movingObjectVelocityOnPlane.set(0, 0, 0);
|
|
936
|
+
this.massRatio = 1;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private applyMassOnStandCollider(): void {
|
|
941
|
+
const hitBody = this._rayHitBody;
|
|
942
|
+
// Counter impulses go only to dynamic bodies; wake flag `true` when
|
|
943
|
+
// poking OTHER bodies. Uses PREVIOUS frame's wheelSupportForceMag.
|
|
944
|
+
if (
|
|
945
|
+
!hitBody ||
|
|
946
|
+
hitBody.bodyType() !== RAPIER.RigidBodyType.Dynamic ||
|
|
947
|
+
!this.applyCounterMass
|
|
948
|
+
)
|
|
949
|
+
return;
|
|
950
|
+
this.wheelSupportImpulse
|
|
951
|
+
.copy(this.rayHitPointNormal)
|
|
952
|
+
.multiplyScalar(
|
|
953
|
+
-1 * this.wheelSupportForceMag * this.vehicle.world.timestep * this.massRatio
|
|
954
|
+
);
|
|
955
|
+
if (this.wheelSupportForceMag > 0)
|
|
956
|
+
hitBody.applyImpulseAtPoint(
|
|
957
|
+
this.wheelSupportImpulse,
|
|
958
|
+
this.rayHitPointPosition,
|
|
959
|
+
true
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Upstream method name is typo'd `applyFricitonOnStandCollider`.
|
|
964
|
+
private applyFrictionOnStandCollider(): void {
|
|
965
|
+
const hitBody = this._rayHitBody;
|
|
966
|
+
if (
|
|
967
|
+
!hitBody ||
|
|
968
|
+
hitBody.bodyType() !== RAPIER.RigidBodyType.Dynamic ||
|
|
969
|
+
!this.applyCounterFriction
|
|
970
|
+
)
|
|
971
|
+
return;
|
|
972
|
+
// PREVIOUS frame's friction impulses.
|
|
973
|
+
this.wheelFrictionImpulse
|
|
974
|
+
.addVectors(this.lngFrictionImp, this.latFrictionImp)
|
|
975
|
+
.multiplyScalar(-1 * this.massRatio);
|
|
976
|
+
if (this.wheelFrictionImpulse.lengthSq() > 1e-4)
|
|
977
|
+
hitBody.applyImpulseAtPoint(
|
|
978
|
+
this.wheelFrictionImpulse,
|
|
979
|
+
this.rayHitPointPosition,
|
|
980
|
+
true
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private computeRelativeVelocity(): void {
|
|
985
|
+
this.rayHitPointVelocity.copy(this.rayOriginVelocity);
|
|
986
|
+
this.rayHitPointVelOnPlane
|
|
987
|
+
.copy(this.rayHitPointVelocity)
|
|
988
|
+
.projectOnPlane(this.rayHitPointNormal);
|
|
989
|
+
if (this._isOnMovingObject && this.followPlatform) {
|
|
990
|
+
this.rayHitPointVelocity.sub(this.movingObjectVelocity);
|
|
991
|
+
this.rayHitPointVelOnPlane.sub(this.movingObjectVelocityOnPlane);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private computeContactFriction(): void {
|
|
996
|
+
if (this._rayHitBody)
|
|
997
|
+
this.frictionCoef = Math.max(
|
|
998
|
+
(this._rayHitFriction + this.tireGripFactor) * 0.5,
|
|
999
|
+
0
|
|
1000
|
+
);
|
|
1001
|
+
else this.frictionCoef = 0;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private computeWheelFrictionImpulse(gravityMag: number): void {
|
|
1005
|
+
const timestep = this.vehicle.world.timestep;
|
|
1006
|
+
|
|
1007
|
+
// Airborne: keep rolling resistance / effective inertia sane, zero slip.
|
|
1008
|
+
// (Friction impulses keep stale values — the stale-rayHit gate in the
|
|
1009
|
+
// vehicle applies them exactly once more on the contact-loss step, as
|
|
1010
|
+
// upstream does.)
|
|
1011
|
+
if (!this._rayHitBody) {
|
|
1012
|
+
this.wheelSupportForceMag = this.wheelMass * gravityMag;
|
|
1013
|
+
this.effectiveInertia =
|
|
1014
|
+
this.wheelInertia +
|
|
1015
|
+
(this.wheelSupportForceMag / gravityMag) * this.rayShapeR * this.rayShapeR;
|
|
1016
|
+
this._lngSlipRatio = 0;
|
|
1017
|
+
this._latSlipRatio = 0;
|
|
1018
|
+
this._slipStrength = 0;
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Wheel support force from the floating impulse.
|
|
1023
|
+
const floatingImpMag = Math.max(
|
|
1024
|
+
this.floatingImpulse.dot(this.rayHitPointNormal),
|
|
1025
|
+
0
|
|
1026
|
+
);
|
|
1027
|
+
this.wheelSupportForceMag = floatingImpMag / timestep;
|
|
1028
|
+
|
|
1029
|
+
// effectiveInertia = 0.5*m*r^2 + (load/g)*r^2.
|
|
1030
|
+
this.effectiveInertia =
|
|
1031
|
+
this.wheelInertia +
|
|
1032
|
+
(this.wheelSupportForceMag / gravityMag) * this.rayShapeR * this.rayShapeR;
|
|
1033
|
+
|
|
1034
|
+
// Longitudinal and lateral axes on the contact plane.
|
|
1035
|
+
this._lngAxis
|
|
1036
|
+
.copy(this.rayFWDAxis)
|
|
1037
|
+
.projectOnPlane(this.rayHitPointNormal)
|
|
1038
|
+
.normalize();
|
|
1039
|
+
this._latAxis
|
|
1040
|
+
.copy(this.rayLeftAxis)
|
|
1041
|
+
.projectOnPlane(this.rayHitPointNormal)
|
|
1042
|
+
.normalize();
|
|
1043
|
+
// Contact point velocities.
|
|
1044
|
+
const lngContactVel = this.rayHitPointVelocity.dot(this._lngAxis);
|
|
1045
|
+
const latContactVel = this.rayHitPointVelocity.dot(this._latAxis);
|
|
1046
|
+
const lngContactVelAbs = Math.abs(lngContactVel);
|
|
1047
|
+
const latContactVelAbs = Math.abs(latContactVel);
|
|
1048
|
+
// Wheel surface speed and slip.
|
|
1049
|
+
const wheelLinVel = this._wheelAngVel * this.rayShapeR;
|
|
1050
|
+
const slipDiff = wheelLinVel - lngContactVel;
|
|
1051
|
+
const slipDiffAbs = Math.abs(slipDiff);
|
|
1052
|
+
|
|
1053
|
+
// Slip ratios and slip-curve values.
|
|
1054
|
+
this._lngSlipRatio = slipDiffAbs / Math.max(lngContactVelAbs, 1e-4);
|
|
1055
|
+
this._latSlipRatio =
|
|
1056
|
+
latContactVelAbs === 0 && lngContactVelAbs === 0
|
|
1057
|
+
? 0
|
|
1058
|
+
: clamp(Math.atan2(latContactVelAbs, lngContactVelAbs) / (Math.PI / 2), 0, 1);
|
|
1059
|
+
this._slipStrength = Math.max(this._lngSlipRatio, this._latSlipRatio);
|
|
1060
|
+
const lngSlipValue = evaluateCurveLUT(this._lngSlipRatio, this.lngSlipRatioCurve);
|
|
1061
|
+
const latSlipValue = evaluateCurveLUT(this._latSlipRatio, this.latSlipRatioCurve);
|
|
1062
|
+
|
|
1063
|
+
// Static friction blend at low speed.
|
|
1064
|
+
const lngStaticWeight = clamp(
|
|
1065
|
+
1.0 - Math.max(slipDiffAbs, lngContactVelAbs) / this.lowVelThreshold,
|
|
1066
|
+
0,
|
|
1067
|
+
1
|
|
1068
|
+
);
|
|
1069
|
+
const finalLngSlipValue = remap(lngStaticWeight, 0, 1, lngSlipValue, 1);
|
|
1070
|
+
const latStaticWeight = clamp(
|
|
1071
|
+
1.0 - Math.max(latContactVelAbs, lngContactVelAbs) / this.lowVelThreshold,
|
|
1072
|
+
0,
|
|
1073
|
+
1
|
|
1074
|
+
);
|
|
1075
|
+
const finalLatSlipValue = remap(latStaticWeight, 0, 1, latSlipValue, 1);
|
|
1076
|
+
|
|
1077
|
+
// Friction ellipse: max allowed impulse per axis.
|
|
1078
|
+
const maxLngImp =
|
|
1079
|
+
this.wheelSupportForceMag *
|
|
1080
|
+
finalLngSlipValue *
|
|
1081
|
+
this.frictionCoef *
|
|
1082
|
+
timestep *
|
|
1083
|
+
this.lngFrictionEllipseScale;
|
|
1084
|
+
const maxLatImp =
|
|
1085
|
+
this.wheelSupportForceMag *
|
|
1086
|
+
finalLatSlipValue *
|
|
1087
|
+
this.frictionCoef *
|
|
1088
|
+
timestep *
|
|
1089
|
+
this.latFrictionEllipseScale;
|
|
1090
|
+
|
|
1091
|
+
// Desired impulses from slip and load.
|
|
1092
|
+
this.desiredLngImpulse =
|
|
1093
|
+
(slipDiff * this.effectiveInertia) / (this.rayShapeR * this.rayShapeR);
|
|
1094
|
+
this.desiredLatImpulse = latContactVel * (this.wheelSupportForceMag / gravityMag);
|
|
1095
|
+
|
|
1096
|
+
// Clamp within the ellipse. (Degenerate 0/0 -> NaN -> no clamp; Infinity ->
|
|
1097
|
+
// zeroed impulses. JS semantics make this safe — do NOT add guards.)
|
|
1098
|
+
const ellipseUsage = Math.sqrt(
|
|
1099
|
+
(this.desiredLngImpulse / maxLngImp) * (this.desiredLngImpulse / maxLngImp) +
|
|
1100
|
+
(this.desiredLatImpulse / maxLatImp) * (this.desiredLatImpulse / maxLatImp)
|
|
1101
|
+
);
|
|
1102
|
+
if (ellipseUsage > 1.0) {
|
|
1103
|
+
this.desiredLngImpulse /= ellipseUsage;
|
|
1104
|
+
this.desiredLatImpulse /= ellipseUsage;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Tire relaxation: keep low-speed tires responsive while still allowing
|
|
1108
|
+
// speed-based relaxation.
|
|
1109
|
+
const lngCoeff = clamp(
|
|
1110
|
+
Math.max(
|
|
1111
|
+
this.minLngRelaxCoeff,
|
|
1112
|
+
(lngContactVelAbs / Math.max(this.relaxLngRate, 1e-6)) * timestep
|
|
1113
|
+
),
|
|
1114
|
+
0,
|
|
1115
|
+
1
|
|
1116
|
+
);
|
|
1117
|
+
const latCoeff = clamp(
|
|
1118
|
+
Math.max(
|
|
1119
|
+
this.minLatRelaxCoeff,
|
|
1120
|
+
(latContactVelAbs / Math.max(this.relaxLatRate, 1e-6)) * timestep
|
|
1121
|
+
),
|
|
1122
|
+
0,
|
|
1123
|
+
1
|
|
1124
|
+
);
|
|
1125
|
+
this.smoothedLngImpulse +=
|
|
1126
|
+
(this.desiredLngImpulse - this.smoothedLngImpulse) * lngCoeff;
|
|
1127
|
+
this.smoothedLatImpulse +=
|
|
1128
|
+
(this.desiredLatImpulse - this.smoothedLatImpulse) * latCoeff;
|
|
1129
|
+
|
|
1130
|
+
// Final impulses. The lateral one OPPOSES lateral contact velocity via the
|
|
1131
|
+
// explicit minus; the longitudinal has no minus because slipDiff already
|
|
1132
|
+
// encodes direction.
|
|
1133
|
+
this.lngFrictionImp.copy(this._lngAxis).multiplyScalar(this.smoothedLngImpulse);
|
|
1134
|
+
this.latFrictionImp.copy(this._latAxis).multiplyScalar(-this.smoothedLatImpulse);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private solveWheelRotation(): void {
|
|
1138
|
+
const timestep = this.vehicle.world.timestep;
|
|
1139
|
+
|
|
1140
|
+
// Define wheel state (no engine spin in air).
|
|
1141
|
+
const isDriving =
|
|
1142
|
+
this.driveWheel && Math.abs(this._driveTorque) > 0 && this._rayHitBody;
|
|
1143
|
+
const isBraking =
|
|
1144
|
+
this.brakeWheel && Math.abs(this._brakeTorque) > 0 && this._rayHitBody;
|
|
1145
|
+
const isFreeRolling = !isDriving && !isBraking;
|
|
1146
|
+
|
|
1147
|
+
// Friction reaction torque — IMPULSE-based, NO timestep factor: the
|
|
1148
|
+
// impulse divided by effectiveInertia/r (with the extra r) is a Δ(rad/s).
|
|
1149
|
+
if (this._rayHitBody)
|
|
1150
|
+
this._wheelAngVel -=
|
|
1151
|
+
(this.lngFrictionImp.dot(this._lngAxis) * this.rayShapeR) /
|
|
1152
|
+
this.effectiveInertia;
|
|
1153
|
+
|
|
1154
|
+
// Rolling resistance: on ground when free-rolling, or in air while spinning.
|
|
1155
|
+
if (
|
|
1156
|
+
(this._rayHitBody && isFreeRolling) ||
|
|
1157
|
+
(!this._rayHitBody && this._wheelAngVel !== 0)
|
|
1158
|
+
) {
|
|
1159
|
+
const rollingResistTorque =
|
|
1160
|
+
-this.rollingResistanceCoef * this.wheelSupportForceMag * this._wheelAngVel;
|
|
1161
|
+
this._wheelAngVel +=
|
|
1162
|
+
(rollingResistTorque / this.effectiveInertia) * timestep;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Engine torque.
|
|
1166
|
+
if (isDriving && !isBraking)
|
|
1167
|
+
this._wheelAngVel += (this._driveTorque / this.effectiveInertia) * timestep;
|
|
1168
|
+
|
|
1169
|
+
// Brake torque — clamped so braking can never reverse the spin in one step.
|
|
1170
|
+
if (isBraking) {
|
|
1171
|
+
const appliedBrakeTorque = this._brakeTorque * -Math.sign(this._wheelAngVel);
|
|
1172
|
+
this._wheelAngVel +=
|
|
1173
|
+
Math.min(
|
|
1174
|
+
Math.abs(this._wheelAngVel),
|
|
1175
|
+
Math.abs(appliedBrakeTorque / this.effectiveInertia) * timestep
|
|
1176
|
+
) * -Math.sign(this._wheelAngVel);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private updateWheelModel(): void {
|
|
1181
|
+
if (!this.updateModel) return;
|
|
1182
|
+
const timestep = this.vehicle.world.timestep;
|
|
1183
|
+
// Suspension bounce.
|
|
1184
|
+
const hasContact =
|
|
1185
|
+
this.groundDetection === "rayCast" ? this._rayHit : this._shapeRayHit;
|
|
1186
|
+
const offsetY = hasContact
|
|
1187
|
+
? -(this.rayLength + this.rayShapeR) +
|
|
1188
|
+
this.wheelModelRadius +
|
|
1189
|
+
(this.rayLength - this._suspensionToi)
|
|
1190
|
+
: -(this.rayLength + this.rayShapeR) + this.wheelModelRadius;
|
|
1191
|
+
this.suspensionGroup.position.y = THREE.MathUtils.lerp(
|
|
1192
|
+
this.suspensionGroup.position.y,
|
|
1193
|
+
offsetY,
|
|
1194
|
+
1 - Math.exp(-this.wheelModelLerpPosRate * timestep)
|
|
1195
|
+
);
|
|
1196
|
+
// Wheel spin.
|
|
1197
|
+
this.modelObject.rotation.x +=
|
|
1198
|
+
this._wheelAngVel * timestep * (this.wheelModelReverseRotation ? -1 : 1);
|
|
1199
|
+
}
|
|
1200
|
+
}
|