spoint 0.1.658 → 0.1.660
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/package.json +1 -1
- package/src/netcode/SnapshotBinFormat.js +87 -0
- package/src/netcode/SnapshotEncoder.js +8 -82
- package/src/physics/VehiclePhysics.js +337 -0
- package/src/physics/World.js +682 -991
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Pure binary wire-format primitives for SnapshotEncoder.js: the fixed 23-byte numeric record
|
|
2
|
+
// (position/velocity/rotation/scale/flags) and the 32-bit packed-quaternion encode/decode. No
|
|
3
|
+
// closure/instance state -- split out as the one genuinely self-contained piece of that file.
|
|
4
|
+
// See SnapshotEncoder.js's own header comment for the full wire-layout rationale.
|
|
5
|
+
|
|
6
|
+
const Q1 = 100
|
|
7
|
+
const QSCALE = 511 * Math.SQRT2
|
|
8
|
+
|
|
9
|
+
// --- Binary numeric record (DataView, replaces JS-array-through-msgpackr for the fixed numeric
|
|
10
|
+
// fields: position/velocity/rotation/scale). id/model/bodyType/custom stay as native JS values
|
|
11
|
+
// alongside this buffer -- they are variable-shape (strings, arbitrary objects) and packing them
|
|
12
|
+
// into a fixed byte layout would be strictly worse (lossy or unbounded), not a real win. Position/
|
|
13
|
+
// velocity are int16 at the existing Q1=100 (1cm) scale, clamped to +-327.67m -- entity/player
|
|
14
|
+
// positions are always encoded relative to a region/session-local origin already (no planetary
|
|
15
|
+
// float32 range concern here; see AGENTS.md floating-origin-camera-relative-rendering row for the
|
|
16
|
+
// separate concern of >10km world coordinates, which is a rendering-layer issue, not a wire-format
|
|
17
|
+
// one). Scale uses uint16 unsigned at the same Q1 scale (0..655.35, entities are never negatively
|
|
18
|
+
// scaled). Rotation reuses packQuat's existing 32-bit packed representation verbatim -- not
|
|
19
|
+
// reinvented. Fixed player/entity record is 23 bytes: 3*i16 pos + 3*i16 vel + u32 quat + 3*u16
|
|
20
|
+
// scale + 1 flags byte = 6+6+4+6+1 = 23.
|
|
21
|
+
export const BIN_RECORD_BYTES = 23
|
|
22
|
+
export const POS_I16_MAX = 32767 / Q1 // 327.67
|
|
23
|
+
export const SCALE_U16_MAX = 65535 / Q1 // 655.35
|
|
24
|
+
|
|
25
|
+
export function clampI16(v) { return Math.max(-32767, Math.min(32767, Math.round((v || 0) * Q1))) }
|
|
26
|
+
export function clampU16Scale(v) { return Math.max(0, Math.min(65535, Math.round((v ?? 1) * Q1))) }
|
|
27
|
+
|
|
28
|
+
// Packs the fixed numeric fields of one entity/player record into a fresh 23-byte Uint8Array.
|
|
29
|
+
// flags: caller-supplied bitfield (onGround/sleeping/etc for players/entities respectively).
|
|
30
|
+
export function packBinRecord(px, py, pz, qrot, vx, vy, vz, sx, sy, sz, flags) {
|
|
31
|
+
const buf = new Uint8Array(BIN_RECORD_BYTES)
|
|
32
|
+
const dv = new DataView(buf.buffer)
|
|
33
|
+
dv.setInt16(0, clampI16(px), true); dv.setInt16(2, clampI16(py), true); dv.setInt16(4, clampI16(pz), true)
|
|
34
|
+
dv.setInt16(6, clampI16(vx), true); dv.setInt16(8, clampI16(vy), true); dv.setInt16(10, clampI16(vz), true)
|
|
35
|
+
dv.setUint32(12, qrot >>> 0, true)
|
|
36
|
+
dv.setUint16(16, clampU16Scale(sx), true); dv.setUint16(18, clampU16Scale(sy), true); dv.setUint16(20, clampU16Scale(sz), true)
|
|
37
|
+
dv.setUint8(22, flags & 0xFF)
|
|
38
|
+
return buf
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function unpackBinRecord(buf, out) {
|
|
42
|
+
const dv = buf instanceof DataView ? buf : new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
43
|
+
out.px = dv.getInt16(0, true) / Q1; out.py = dv.getInt16(2, true) / Q1; out.pz = dv.getInt16(4, true) / Q1
|
|
44
|
+
out.vx = dv.getInt16(6, true) / Q1; out.vy = dv.getInt16(8, true) / Q1; out.vz = dv.getInt16(10, true) / Q1
|
|
45
|
+
out.qrot = dv.getUint32(12, true)
|
|
46
|
+
out.sx = dv.getUint16(16, true) / Q1; out.sy = dv.getUint16(18, true) / Q1; out.sz = dv.getUint16(20, true) / Q1
|
|
47
|
+
out.flags = dv.getUint8(22)
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function packQuat(rx, ry, rz, rw) {
|
|
52
|
+
const arx = Math.abs(rx), ary = Math.abs(ry), arz = Math.abs(rz), arw = Math.abs(rw)
|
|
53
|
+
let maxIdx = 0, maxAbs = arx
|
|
54
|
+
if (ary > maxAbs) { maxIdx = 1; maxAbs = ary }
|
|
55
|
+
if (arz > maxAbs) { maxIdx = 2; maxAbs = arz }
|
|
56
|
+
if (arw > maxAbs) { maxIdx = 3; maxAbs = arw }
|
|
57
|
+
const mval = maxIdx === 0 ? rx : maxIdx === 1 ? ry : maxIdx === 2 ? rz : rw
|
|
58
|
+
const sign = mval < 0 ? -1 : 1
|
|
59
|
+
let packed = maxIdx
|
|
60
|
+
if (maxIdx !== 0) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rx * sign + Math.SQRT1_2) * QSCALE)))
|
|
61
|
+
if (maxIdx !== 1) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((ry * sign + Math.SQRT1_2) * QSCALE)))
|
|
62
|
+
if (maxIdx !== 2) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rz * sign + Math.SQRT1_2) * QSCALE)))
|
|
63
|
+
if (maxIdx !== 3) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rw * sign + Math.SQRT1_2) * QSCALE)))
|
|
64
|
+
return packed >>> 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Unrolled per maxIdx branch (was a QUAT_IDX[] lookup + generic loop over `indices`) -- this runs
|
|
68
|
+
// once per entity/player per snapshot, client-side, the hottest per-frame decode path, so the extra
|
|
69
|
+
// array indirection through QUAT_IDX plus a 3-iteration loop with a data-dependent out-index write
|
|
70
|
+
// is worth trading for 4 flat, branch-predictable unpacks. Each branch reads the same three 10-bit
|
|
71
|
+
// fields off `packed` in the same bit order (most-significant first, j=2..0) as the original loop,
|
|
72
|
+
// just with the literal QUAT_IDX[maxIdx] destination slots inlined instead of indexed.
|
|
73
|
+
export function unpackQuat(packed, out) {
|
|
74
|
+
const maxIdx = (packed >>> 30) & 0x3
|
|
75
|
+
const c2 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
|
|
76
|
+
const c1 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
|
|
77
|
+
const c0 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2
|
|
78
|
+
const sumSq = c0 * c0 + c1 * c1 + c2 * c2
|
|
79
|
+
const m = Math.sqrt(Math.max(0, 1 - sumSq))
|
|
80
|
+
switch (maxIdx) {
|
|
81
|
+
case 0: out[1] = c0; out[2] = c1; out[3] = c2; out[0] = m; break
|
|
82
|
+
case 1: out[0] = c0; out[2] = c1; out[3] = c2; out[1] = m; break
|
|
83
|
+
case 2: out[0] = c0; out[1] = c1; out[3] = c2; out[2] = m; break
|
|
84
|
+
default: out[0] = c0; out[1] = c1; out[2] = c2; out[3] = m; break
|
|
85
|
+
}
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
@@ -1,90 +1,16 @@
|
|
|
1
1
|
import { getComponentSchema, encodeCustomFields, decodeCustomFields } from '../../apps/_lib/ComponentSchema.js'
|
|
2
|
+
import {
|
|
3
|
+
BIN_RECORD_BYTES, POS_I16_MAX, SCALE_U16_MAX, clampI16, clampU16Scale,
|
|
4
|
+
packBinRecord, unpackBinRecord, packQuat, unpackQuat
|
|
5
|
+
} from './SnapshotBinFormat.js'
|
|
6
|
+
|
|
7
|
+
// Re-exported from SnapshotBinFormat.js for backward compatibility -- AnimationClipCache.js,
|
|
8
|
+
// TickHandler.js, and edge/cf-do/do-client-probe.mjs all import these from this file's own path.
|
|
9
|
+
export { unpackBinRecord, packQuat, unpackQuat }
|
|
2
10
|
|
|
3
|
-
const Q1=100
|
|
4
11
|
const TAU = 2 * Math.PI, HALF_PI = Math.PI / 2
|
|
5
12
|
const VEL_ZERO = [0,0,0]
|
|
6
13
|
const SCALE_ONE = [1,1,1]
|
|
7
|
-
const QSCALE = 511 * Math.SQRT2
|
|
8
|
-
|
|
9
|
-
// --- Binary numeric record (DataView, replaces JS-array-through-msgpackr for the fixed numeric
|
|
10
|
-
// fields: position/velocity/rotation/scale). id/model/bodyType/custom stay as native JS values
|
|
11
|
-
// alongside this buffer -- they are variable-shape (strings, arbitrary objects) and packing them
|
|
12
|
-
// into a fixed byte layout would be strictly worse (lossy or unbounded), not a real win. Position/
|
|
13
|
-
// velocity are int16 at the existing Q1=100 (1cm) scale, clamped to +-327.67m -- entity/player
|
|
14
|
-
// positions are always encoded relative to a region/session-local origin already (no planetary
|
|
15
|
-
// float32 range concern here; see AGENTS.md floating-origin-camera-relative-rendering row for the
|
|
16
|
-
// separate concern of >10km world coordinates, which is a rendering-layer issue, not a wire-format
|
|
17
|
-
// one). Scale uses uint16 unsigned at the same Q1 scale (0..655.35, entities are never negatively
|
|
18
|
-
// scaled). Rotation reuses packQuat's existing 32-bit packed representation verbatim -- not
|
|
19
|
-
// reinvented. Fixed player/entity record is 23 bytes: 3*i16 pos + 3*i16 vel + u32 quat + 3*u16
|
|
20
|
-
// scale + 1 flags byte = 6+6+4+6+1 = 23.
|
|
21
|
-
const BIN_RECORD_BYTES = 23
|
|
22
|
-
const POS_I16_MAX = 32767 / Q1 // 327.67
|
|
23
|
-
const SCALE_U16_MAX = 65535 / Q1 // 655.35
|
|
24
|
-
|
|
25
|
-
function clampI16(v) { return Math.max(-32767, Math.min(32767, Math.round((v || 0) * Q1))) }
|
|
26
|
-
function clampU16Scale(v) { return Math.max(0, Math.min(65535, Math.round((v ?? 1) * Q1))) }
|
|
27
|
-
|
|
28
|
-
// Packs the fixed numeric fields of one entity/player record into a fresh 23-byte Uint8Array.
|
|
29
|
-
// flags: caller-supplied bitfield (onGround/sleeping/etc for players/entities respectively).
|
|
30
|
-
function packBinRecord(px, py, pz, qrot, vx, vy, vz, sx, sy, sz, flags) {
|
|
31
|
-
const buf = new Uint8Array(BIN_RECORD_BYTES)
|
|
32
|
-
const dv = new DataView(buf.buffer)
|
|
33
|
-
dv.setInt16(0, clampI16(px), true); dv.setInt16(2, clampI16(py), true); dv.setInt16(4, clampI16(pz), true)
|
|
34
|
-
dv.setInt16(6, clampI16(vx), true); dv.setInt16(8, clampI16(vy), true); dv.setInt16(10, clampI16(vz), true)
|
|
35
|
-
dv.setUint32(12, qrot >>> 0, true)
|
|
36
|
-
dv.setUint16(16, clampU16Scale(sx), true); dv.setUint16(18, clampU16Scale(sy), true); dv.setUint16(20, clampU16Scale(sz), true)
|
|
37
|
-
dv.setUint8(22, flags & 0xFF)
|
|
38
|
-
return buf
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function unpackBinRecord(buf, out) {
|
|
42
|
-
const dv = buf instanceof DataView ? buf : new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
43
|
-
out.px = dv.getInt16(0, true) / Q1; out.py = dv.getInt16(2, true) / Q1; out.pz = dv.getInt16(4, true) / Q1
|
|
44
|
-
out.vx = dv.getInt16(6, true) / Q1; out.vy = dv.getInt16(8, true) / Q1; out.vz = dv.getInt16(10, true) / Q1
|
|
45
|
-
out.qrot = dv.getUint32(12, true)
|
|
46
|
-
out.sx = dv.getUint16(16, true) / Q1; out.sy = dv.getUint16(18, true) / Q1; out.sz = dv.getUint16(20, true) / Q1
|
|
47
|
-
out.flags = dv.getUint8(22)
|
|
48
|
-
return out
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function packQuat(rx, ry, rz, rw) {
|
|
52
|
-
const arx = Math.abs(rx), ary = Math.abs(ry), arz = Math.abs(rz), arw = Math.abs(rw)
|
|
53
|
-
let maxIdx = 0, maxAbs = arx
|
|
54
|
-
if (ary > maxAbs) { maxIdx = 1; maxAbs = ary }
|
|
55
|
-
if (arz > maxAbs) { maxIdx = 2; maxAbs = arz }
|
|
56
|
-
if (arw > maxAbs) { maxIdx = 3; maxAbs = arw }
|
|
57
|
-
const mval = maxIdx === 0 ? rx : maxIdx === 1 ? ry : maxIdx === 2 ? rz : rw
|
|
58
|
-
const sign = mval < 0 ? -1 : 1
|
|
59
|
-
let packed = maxIdx
|
|
60
|
-
if (maxIdx !== 0) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rx * sign + Math.SQRT1_2) * QSCALE)))
|
|
61
|
-
if (maxIdx !== 1) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((ry * sign + Math.SQRT1_2) * QSCALE)))
|
|
62
|
-
if (maxIdx !== 2) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rz * sign + Math.SQRT1_2) * QSCALE)))
|
|
63
|
-
if (maxIdx !== 3) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rw * sign + Math.SQRT1_2) * QSCALE)))
|
|
64
|
-
return packed >>> 0
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Unrolled per maxIdx branch (was a QUAT_IDX[] lookup + generic loop over `indices`) -- this runs
|
|
68
|
-
// once per entity/player per snapshot, client-side, the hottest per-frame decode path, so the extra
|
|
69
|
-
// array indirection through QUAT_IDX plus a 3-iteration loop with a data-dependent out-index write
|
|
70
|
-
// is worth trading for 4 flat, branch-predictable unpacks. Each branch reads the same three 10-bit
|
|
71
|
-
// fields off `packed` in the same bit order (most-significant first, j=2..0) as the original loop,
|
|
72
|
-
// just with the literal QUAT_IDX[maxIdx] destination slots inlined instead of indexed.
|
|
73
|
-
export function unpackQuat(packed, out) {
|
|
74
|
-
const maxIdx = (packed >>> 30) & 0x3
|
|
75
|
-
const c2 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
|
|
76
|
-
const c1 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
|
|
77
|
-
const c0 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2
|
|
78
|
-
const sumSq = c0 * c0 + c1 * c1 + c2 * c2
|
|
79
|
-
const m = Math.sqrt(Math.max(0, 1 - sumSq))
|
|
80
|
-
switch (maxIdx) {
|
|
81
|
-
case 0: out[1] = c0; out[2] = c1; out[3] = c2; out[0] = m; break
|
|
82
|
-
case 1: out[0] = c0; out[2] = c1; out[3] = c2; out[1] = m; break
|
|
83
|
-
case 2: out[0] = c0; out[1] = c1; out[3] = c2; out[2] = m; break
|
|
84
|
-
default: out[0] = c0; out[1] = c1; out[2] = c2; out[3] = m; break
|
|
85
|
-
}
|
|
86
|
-
return out
|
|
87
|
-
}
|
|
88
14
|
|
|
89
15
|
// p[12]: 8 bits pitch (range [-pi/2,pi/2]) + 8 bits yaw (range [0,2pi)) packed into one uint16
|
|
90
16
|
// enc layout (unchanged JS-value slots kept native; numeric fixed fields moved into enc[1] as a
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// Vehicle constraint methods for PhysicsWorld (World.js): real Jolt VehicleConstraint
|
|
2
|
+
// (WheeledVehicleController + TrackedVehicleController). Split out as World.js's largest
|
|
3
|
+
// self-contained block -- every method here only touches PhysicsWorld's own class-level state
|
|
4
|
+
// (this.physicsSystem/this._getBody/this.bodyInterface/this._vehicles/this.Jolt) through the same
|
|
5
|
+
// accessors the rest of that class uses, so mixing these onto PhysicsWorld.prototype changes
|
|
6
|
+
// nothing about the public API or the WASM-interop discipline documented inline below.
|
|
7
|
+
//
|
|
8
|
+
// WASM-CRASH-AVOIDANCE RULES THIS FILE MUST NOT VIOLATE (see each method's own comment for the
|
|
9
|
+
// live-reproduced finding that established the rule):
|
|
10
|
+
// - removeVehicle: do NOT J.destroy(v.constraint) or J.destroy(v.tester) after RemoveConstraint
|
|
11
|
+
// has run -- RemoveConstraint's own destructor chain already drops both refs; destroying either
|
|
12
|
+
// afterward is a real use-after-free WASM trap.
|
|
13
|
+
// - createTrackedVehicle: do NOT J.destroy() a VehicleTrackSettings handle returned by
|
|
14
|
+
// get_mTracks() after set_mTracks() has copied it in -- these are copy-semantics value handles
|
|
15
|
+
// with no Jolt-side ref to release, unlike the constraint/tester RefTarget objects above.
|
|
16
|
+
// - setVehicleDriverInput/setTrackedVehicleDriverInput: a sleeping chassis silently ignores driver
|
|
17
|
+
// input (no error) unless explicitly reactivated first.
|
|
18
|
+
|
|
19
|
+
const LAYER_DYNAMIC = 1
|
|
20
|
+
|
|
21
|
+
export function installVehiclePhysics(PhysicsWorld) {
|
|
22
|
+
const proto = PhysicsWorld.prototype
|
|
23
|
+
|
|
24
|
+
// Real Jolt VehicleConstraint (WheeledVehicleController) -- vehicles-jolt-wheeled-constraints-app.
|
|
25
|
+
// AGENTS.md's ragdoll-brawl-arena-no-joint-api caveat (2026-07-07) said "no joint/constraint
|
|
26
|
+
// primitive anywhere in World.js" -- that was already stale by the time addConstraint (TwoBody
|
|
27
|
+
// fixed/point/distance/hinge) landed, and a live probe against the ACTUAL jolt-physics 1.1.0
|
|
28
|
+
// WASM build this session (both wasm and wasm-compat -- the .d.ts ships with zero Vehicle* entries,
|
|
29
|
+
// a real type-definition gap, but the compiled WASM module itself exports the full upstream Jolt
|
|
30
|
+
// VehicleConstraint/WheeledVehicleController/TrackedVehicleController surface, ~280 distinct
|
|
31
|
+
// Vehicle*-prefixed bindings) confirms it IS available. A minimal real vehicle (box chassis body +
|
|
32
|
+
// 4 WheelSettingsWV + one rear-wheel-drive VehicleDifferentialSettings + VehicleCollisionTesterRay)
|
|
33
|
+
// was built, stepped 120 real ticks, and drove forward ~4.65m under sustained throttle -- see
|
|
34
|
+
// AGENTS.md audit log entry for this session for the full probe transcript. The single sharpest
|
|
35
|
+
// real gotcha found: WheeledVehicleControllerSettings.mDifferentials defaults to an EMPTY array --
|
|
36
|
+
// with zero differentials configured, engine torque never reaches ANY wheel (a silent no-op: the
|
|
37
|
+
// constraint builds fine, the wheels spin at 0 RPM, the chassis never moves) -- at least one
|
|
38
|
+
// differential entry (mLeftWheel/mRightWheel wheel INDEXES into mWheels, matching the order wheels
|
|
39
|
+
// were push_back'd) is mandatory for a driveable vehicle, not merely a tuning nicety.
|
|
40
|
+
//
|
|
41
|
+
// createWheeledVehicle(chassisBodyId, wheelDefs, opts): wheelDefs is an array of
|
|
42
|
+
// {position:[x,y,z] (chassis-local), radius, width, suspensionMin, suspensionMax, maxSteerAngle,
|
|
43
|
+
// maxBrakeTorque, maxHandBrakeTorque, steer:bool, drive:bool}. opts.up/opts.forward default to
|
|
44
|
+
// [0,1,0]/[0,0,1] (matches this project's Z-forward convention already used by player rotation/yaw
|
|
45
|
+
// elsewhere in this file's caller). Returns a vehicleId (opaque, keyed into this._vehicles) or null.
|
|
46
|
+
proto.createWheeledVehicle = function (chassisBodyId, wheelDefs, opts = {}) {
|
|
47
|
+
if (!this.physicsSystem) return null
|
|
48
|
+
const chassis = this._getBody(chassisBodyId); if (!chassis) return null
|
|
49
|
+
if (!Array.isArray(wheelDefs) || wheelDefs.length === 0) return null
|
|
50
|
+
const J = this.Jolt
|
|
51
|
+
let vcs = null, wheelSettingsList = [], constraint = null, tester = null, stepListener = null
|
|
52
|
+
try {
|
|
53
|
+
vcs = new J.VehicleConstraintSettings()
|
|
54
|
+
const up = opts.up || [0, 1, 0], fwd = opts.forward || [0, 0, 1]
|
|
55
|
+
vcs.mUp = new J.Vec3(up[0], up[1], up[2])
|
|
56
|
+
vcs.mForward = new J.Vec3(fwd[0], fwd[1], fwd[2])
|
|
57
|
+
if (opts.maxPitchRollAngle != null) vcs.mMaxPitchRollAngle = opts.maxPitchRollAngle
|
|
58
|
+
|
|
59
|
+
const wheelsArr = vcs.mWheels
|
|
60
|
+
const driveIdxL = [], driveIdxR = []
|
|
61
|
+
for (let i = 0; i < wheelDefs.length; i++) {
|
|
62
|
+
const w = wheelDefs[i] || {}
|
|
63
|
+
const ws = new J.WheelSettingsWV()
|
|
64
|
+
const p = w.position || [0, 0, 0]
|
|
65
|
+
ws.mPosition = new J.Vec3(p[0], p[1], p[2])
|
|
66
|
+
if (w.suspensionDirection) { const sd = w.suspensionDirection; ws.mSuspensionDirection = new J.Vec3(sd[0], sd[1], sd[2]) }
|
|
67
|
+
ws.mRadius = w.radius ?? 0.35
|
|
68
|
+
ws.mWidth = w.width ?? 0.25
|
|
69
|
+
ws.mSuspensionMinLength = w.suspensionMin ?? 0.3
|
|
70
|
+
ws.mSuspensionMaxLength = w.suspensionMax ?? 0.5
|
|
71
|
+
ws.mMaxSteerAngle = w.steer ? (w.maxSteerAngle ?? 0.6) : 0
|
|
72
|
+
ws.mMaxBrakeTorque = w.maxBrakeTorque ?? 1500
|
|
73
|
+
ws.mMaxHandBrakeTorque = w.maxHandBrakeTorque ?? 0
|
|
74
|
+
wheelSettingsList.push(ws)
|
|
75
|
+
wheelsArr.push_back(ws)
|
|
76
|
+
// Left/right classified by local X sign (chassis-local wheel position) -- matches the probe's
|
|
77
|
+
// convention and every real 4-wheel layout (negative X = left, positive X = right).
|
|
78
|
+
if (w.drive) { if (p[0] < 0) driveIdxL.push(i); else driveIdxR.push(i) }
|
|
79
|
+
}
|
|
80
|
+
vcs.mWheels = wheelsArr
|
|
81
|
+
|
|
82
|
+
const controllerSettings = new J.WheeledVehicleControllerSettings()
|
|
83
|
+
const diffs = controllerSettings.mDifferentials
|
|
84
|
+
// opts.differentials lets a caller fully hand-author the diff list (tracked-style split-per-axle
|
|
85
|
+
// setups); default is one differential per drive axle pairing left/right drive wheels 1:1 by
|
|
86
|
+
// position order (covers the common RWD/FWD/AWD single-or-dual-axle case with zero caller config).
|
|
87
|
+
if (Array.isArray(opts.differentials) && opts.differentials.length) {
|
|
88
|
+
for (const d of opts.differentials) {
|
|
89
|
+
const vd = new J.VehicleDifferentialSettings()
|
|
90
|
+
vd.mLeftWheel = d.leftWheel ?? -1; vd.mRightWheel = d.rightWheel ?? -1
|
|
91
|
+
if (d.differentialRatio != null) vd.mDifferentialRatio = d.differentialRatio
|
|
92
|
+
if (d.limitedSlipRatio != null) vd.mLimitedSlipRatio = d.limitedSlipRatio
|
|
93
|
+
if (d.engineTorqueRatio != null) vd.mEngineTorqueRatio = d.engineTorqueRatio
|
|
94
|
+
diffs.push_back(vd)
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
const n = Math.max(driveIdxL.length, driveIdxR.length)
|
|
98
|
+
for (let i = 0; i < n; i++) {
|
|
99
|
+
const vd = new J.VehicleDifferentialSettings()
|
|
100
|
+
vd.mLeftWheel = driveIdxL[i] ?? -1; vd.mRightWheel = driveIdxR[i] ?? -1
|
|
101
|
+
vd.mEngineTorqueRatio = 1 / n
|
|
102
|
+
diffs.push_back(vd)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
controllerSettings.mDifferentials = diffs
|
|
106
|
+
if (opts.engine) {
|
|
107
|
+
if (opts.engine.maxTorque != null) controllerSettings.mEngine.mMaxTorque = opts.engine.maxTorque
|
|
108
|
+
if (opts.engine.maxRPM != null) controllerSettings.mEngine.mMaxRPM = opts.engine.maxRPM
|
|
109
|
+
if (opts.engine.minRPM != null) controllerSettings.mEngine.mMinRPM = opts.engine.minRPM
|
|
110
|
+
}
|
|
111
|
+
vcs.mController = controllerSettings
|
|
112
|
+
|
|
113
|
+
constraint = new J.VehicleConstraint(chassis, vcs)
|
|
114
|
+
tester = new J.VehicleCollisionTesterRay(LAYER_DYNAMIC, new J.Vec3(up[0], up[1], up[2]))
|
|
115
|
+
constraint.SetVehicleCollisionTester(tester)
|
|
116
|
+
this.physicsSystem.AddConstraint(constraint)
|
|
117
|
+
stepListener = new J.VehicleConstraintStepListener(constraint)
|
|
118
|
+
this.physicsSystem.AddStepListener(stepListener)
|
|
119
|
+
|
|
120
|
+
const controller = J.castObject(constraint.GetController(), J.WheeledVehicleController)
|
|
121
|
+
const vid = (this._nextVehicleId = (this._nextVehicleId || 0) + 1)
|
|
122
|
+
if (!this._vehicles) this._vehicles = new Map()
|
|
123
|
+
this._vehicles.set(vid, { constraint, controller, tester, stepListener, chassisBodyId, wheelCount: wheelDefs.length })
|
|
124
|
+
return vid
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.error('[physics] createWheeledVehicle failed:', e?.message || e)
|
|
127
|
+
return null
|
|
128
|
+
} finally { if (vcs) J.destroy(vcs) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// driverInput: forward/right in -1..1, brake/handbrake in 0..1 -- straight passthrough to Jolt's own
|
|
132
|
+
// WheeledVehicleController.SetDriverInput, which internally handles engine RPM/torque/transmission
|
|
133
|
+
// gear-shift simulation every physics step via the registered VehicleConstraintStepListener.
|
|
134
|
+
//
|
|
135
|
+
// REAL BUG independently found+fixed by two sibling sessions the same day (vehicles-tracked-controller-
|
|
136
|
+
// follow-up and vehicles-wheel-visual-wire-sync, both live-reproduced via a real booted server +
|
|
137
|
+
// Playwright drive test): a parked vehicle that settles onto the ground goes to sleep via Jolt's own
|
|
138
|
+
// island-based sleep logic (this project's own aggressive World.js init() sleep tuning --
|
|
139
|
+
// mTimeBeforeSleep=0.25s -- makes a resting chassis fall asleep FAST) same as any other dynamic body,
|
|
140
|
+
// and SetDriverInput alone does NOT wake a sleeping body -- driver input reaches the controller every
|
|
141
|
+
// tick (confirmed via a call-count probe) but a sleeping VehicleConstraint's step listener still runs
|
|
142
|
+
// against an inactive body and produces zero motion, silently -- no error, no thrown exception, the
|
|
143
|
+
// constraint simply has nothing to move. A real player mounting a vehicle that already settled to sleep
|
|
144
|
+
// before they pressed a drive key (the overwhelmingly common case -- a vehicle sits parked for more
|
|
145
|
+
// than ~0.25s before anyone drives it) would find it completely unresponsive. Fix: wake the body on any
|
|
146
|
+
// driver-input call carrying real forward/right/handbrake input -- brake alone is intentionally
|
|
147
|
+
// excluded, since braking an already-sleeping/at-rest vehicle has nothing to do and must not fight the
|
|
148
|
+
// sleep optimization by re-waking it every tick a parked driver holds the brake. Gated on isActive()
|
|
149
|
+
// first so an already-awake vehicle (the common case, mid-drive) pays zero extra native call per tick.
|
|
150
|
+
proto.setVehicleDriverInput = function (vehicleId, forward, right, brake = 0, handbrake = 0) {
|
|
151
|
+
const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return false
|
|
152
|
+
if ((forward || right || handbrake) && this.bodyInterface.ActivateBody) {
|
|
153
|
+
const chassis = this._getBody(v.chassisBodyId)
|
|
154
|
+
if (chassis && !chassis.IsActive()) this.bodyInterface.ActivateBody(chassis.GetID())
|
|
155
|
+
}
|
|
156
|
+
v.controller.SetDriverInput(forward, right, brake, handbrake)
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
proto.getVehicleWheelTransform = function (vehicleId, wheelIndex) {
|
|
161
|
+
const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return null
|
|
162
|
+
const J = this.Jolt
|
|
163
|
+
// (bodyRotation, wheelRotationAxis) -- GetWheelWorldTransform's 3rd param is the local rotation
|
|
164
|
+
// axis wheels spin about; [1,0,0] matches the WheelSettingsWV convention (wheel spin axis = local X).
|
|
165
|
+
const t = v.constraint.GetWheelWorldTransform(wheelIndex, new J.Vec3(1, 0, 0), new J.Vec3(0, 1, 0))
|
|
166
|
+
const pos = t.GetTranslation(), rot = t.GetQuaternion()
|
|
167
|
+
const out = { position: [pos.GetX(), pos.GetY(), pos.GetZ()], rotation: [rot.GetX(), rot.GetY(), rot.GetZ(), rot.GetW()] }
|
|
168
|
+
J.destroy(t)
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
proto.getVehicleWheelSpeed = function (vehicleId, wheelIndex) {
|
|
172
|
+
const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return 0
|
|
173
|
+
const w = v.constraint.GetWheel(wheelIndex)
|
|
174
|
+
return w ? w.GetAngularVelocity() : 0
|
|
175
|
+
}
|
|
176
|
+
proto.isVehicleWheelGrounded = function (vehicleId, wheelIndex) {
|
|
177
|
+
const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return false
|
|
178
|
+
const w = v.constraint.GetWheel(wheelIndex)
|
|
179
|
+
return w ? w.HasContact() : false
|
|
180
|
+
}
|
|
181
|
+
proto.removeVehicle = function (vehicleId) {
|
|
182
|
+
const v = this._vehicles && this._vehicles.get(vehicleId); if (!v || !this.physicsSystem) return false
|
|
183
|
+
const J = this.Jolt
|
|
184
|
+
try {
|
|
185
|
+
this.physicsSystem.RemoveStepListener(v.stepListener)
|
|
186
|
+
J.destroy(v.stepListener)
|
|
187
|
+
// v.constraint and v.tester are BOTH Jolt-side ref-counted objects (RefTarget -- same family as
|
|
188
|
+
// the Shape.AddRef/Release convention already documented in addStaticTrimeshAsync). RemoveConstraint's
|
|
189
|
+
// own native destructor chain drops the constraint's ref (which in turn drops its ref on the
|
|
190
|
+
// collision tester it holds) -- live-witnessed: a manual J.destroy(v.constraint) or
|
|
191
|
+
// J.destroy(v.tester) AFTER RemoveConstraint has already run is a real use-after-free ("memory
|
|
192
|
+
// access out of bounds" / "table index is out of bounds" WASM traps, not a benign double-free
|
|
193
|
+
// warning). Do NOT call J.destroy on either -- RemoveConstraint alone is the complete, correct
|
|
194
|
+
// teardown for both.
|
|
195
|
+
this.physicsSystem.RemoveConstraint(v.constraint)
|
|
196
|
+
} catch (e) { console.error('[physics] removeVehicle cleanup error:', e?.message || e) }
|
|
197
|
+
this._vehicles.delete(vehicleId)
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Real Jolt TrackedVehicleController -- vehicles-tracked-controller-follow-up, sibling to
|
|
202
|
+
// createWheeledVehicle above. jolt-physics 1.1.0's compiled WASM build was probed live this session
|
|
203
|
+
// (Object.keys(Jolt) against a real `import('jolt-physics/wasm-compat')`) and confirmed to export the
|
|
204
|
+
// full TrackedVehicleController/TrackedVehicleControllerSettings/VehicleTrack/VehicleTrackSettings
|
|
205
|
+
// surface, same undocumented-in-.d.ts situation as the wheeled case.
|
|
206
|
+
//
|
|
207
|
+
// MATERIAL DIFFERENCE from the wheeled settings shape (the row's own instruction to audit before
|
|
208
|
+
// assuming 1:1 parity): TrackedVehicleControllerSettings.mTracks is NOT a push_back-able vector like
|
|
209
|
+
// WheeledVehicleControllerSettings.mDifferentials -- it is a fixed C++ array of exactly 2
|
|
210
|
+
// VehicleTrackSettings (upstream Jolt: `VehicleTrackSettings mTracks[2]`), and VehicleTrackSettings
|
|
211
|
+
// itself has no public constructor (`new J.VehicleTrackSettings()` throws "no constructor in IDL").
|
|
212
|
+
// The embind wrapper exposes this as get_mTracks(index)/set_mTracks(index, value) COPY-semantics
|
|
213
|
+
// accessors (live-probed): get_mTracks(0) returns an independent mutable copy of track 0, mutating
|
|
214
|
+
// that copy does NOT affect get_mTracks(1)'s copy, and the mutated copy must be written back via
|
|
215
|
+
// set_mTracks(index, track) to take effect -- get-mutate-set, not get-and-keep-reference. Track index
|
|
216
|
+
// 0 = left, 1 = right (matches Jolt's own sample/doc convention and this wrapper's driveIdx classification
|
|
217
|
+
// below). Each track's mWheels IS a real push_back-able vector of wheel INDEXES (into mWheels on the
|
|
218
|
+
// parent VehicleConstraintSettings, same indexing convention as the wheeled mDifferentials wheel refs).
|
|
219
|
+
//
|
|
220
|
+
// wheelDefs: array of {position:[x,y,z] chassis-local, radius, width, suspensionMin, suspensionMax,
|
|
221
|
+
// maxBrakeTorque, side:'left'|'right', driven:bool}. Wheels use WheelSettingsTV (Tracked Vehicle) not
|
|
222
|
+
// WheelSettingsWV (Wheeled) -- no steer angle field (tracks steer via differential left/right ratio,
|
|
223
|
+
// not wheel-turn angle). At least one wheel per side must have driven:true set as that side's
|
|
224
|
+
// mDrivenWheel (the wheel index the engine torque/track tension is actually applied through) --
|
|
225
|
+
// otherwise, mirroring the wheeled mDifferentials-empty gotcha, a side with no explicit driven wheel
|
|
226
|
+
// silently defaults mDrivenWheel to 0 (this wrapper's own first-wheel-of-side fallback below), so a
|
|
227
|
+
// caller SHOULD mark one wheel per side driven:true rather than relying on the fallback.
|
|
228
|
+
proto.createTrackedVehicle = function (chassisBodyId, wheelDefs, opts = {}) {
|
|
229
|
+
if (!this.physicsSystem) return null
|
|
230
|
+
const chassis = this._getBody(chassisBodyId); if (!chassis) return null
|
|
231
|
+
if (!Array.isArray(wheelDefs) || wheelDefs.length === 0) return null
|
|
232
|
+
const J = this.Jolt
|
|
233
|
+
let vcs = null, wheelSettingsList = [], constraint = null, tester = null, stepListener = null
|
|
234
|
+
try {
|
|
235
|
+
vcs = new J.VehicleConstraintSettings()
|
|
236
|
+
const up = opts.up || [0, 1, 0], fwd = opts.forward || [0, 0, 1]
|
|
237
|
+
vcs.mUp = new J.Vec3(up[0], up[1], up[2])
|
|
238
|
+
vcs.mForward = new J.Vec3(fwd[0], fwd[1], fwd[2])
|
|
239
|
+
if (opts.maxPitchRollAngle != null) vcs.mMaxPitchRollAngle = opts.maxPitchRollAngle
|
|
240
|
+
|
|
241
|
+
const wheelsArr = vcs.mWheels
|
|
242
|
+
const leftIdx = [], rightIdx = [], leftDrivenIdx = [], rightDrivenIdx = []
|
|
243
|
+
for (let i = 0; i < wheelDefs.length; i++) {
|
|
244
|
+
const w = wheelDefs[i] || {}
|
|
245
|
+
const ws = new J.WheelSettingsTV()
|
|
246
|
+
const p = w.position || [0, 0, 0]
|
|
247
|
+
ws.mPosition = new J.Vec3(p[0], p[1], p[2])
|
|
248
|
+
if (w.suspensionDirection) { const sd = w.suspensionDirection; ws.mSuspensionDirection = new J.Vec3(sd[0], sd[1], sd[2]) }
|
|
249
|
+
ws.mRadius = w.radius ?? 0.35
|
|
250
|
+
ws.mWidth = w.width ?? 0.4
|
|
251
|
+
ws.mSuspensionMinLength = w.suspensionMin ?? 0.3
|
|
252
|
+
ws.mSuspensionMaxLength = w.suspensionMax ?? 0.5
|
|
253
|
+
if (w.maxBrakeTorque != null) ws.mMaxBrakeTorque = w.maxBrakeTorque
|
|
254
|
+
wheelSettingsList.push(ws)
|
|
255
|
+
wheelsArr.push_back(ws)
|
|
256
|
+
// side classified explicitly (w.side) if given, else by local-X sign (negative = left, matching
|
|
257
|
+
// createWheeledVehicle's own left/right convention) -- same fallback discipline as the wheeled case.
|
|
258
|
+
const side = w.side || (p[0] < 0 ? 'left' : 'right')
|
|
259
|
+
if (side === 'left') { leftIdx.push(i); if (w.driven) leftDrivenIdx.push(i) }
|
|
260
|
+
else { rightIdx.push(i); if (w.driven) rightDrivenIdx.push(i) }
|
|
261
|
+
}
|
|
262
|
+
vcs.mWheels = wheelsArr
|
|
263
|
+
|
|
264
|
+
const controllerSettings = new J.TrackedVehicleControllerSettings()
|
|
265
|
+
if (opts.engine) {
|
|
266
|
+
if (opts.engine.maxTorque != null) controllerSettings.mEngine.mMaxTorque = opts.engine.maxTorque
|
|
267
|
+
if (opts.engine.maxRPM != null) controllerSettings.mEngine.mMaxRPM = opts.engine.maxRPM
|
|
268
|
+
if (opts.engine.minRPM != null) controllerSettings.mEngine.mMinRPM = opts.engine.minRPM
|
|
269
|
+
}
|
|
270
|
+
// opts.tracks lets a caller fully hand-author both tracks (explicit wheel-index lists / driven
|
|
271
|
+
// wheel / brake torque), matching createWheeledVehicle's opts.differentials override pattern.
|
|
272
|
+
// Default: classify by side above, driven wheel = first driven:true wheel on that side, or the
|
|
273
|
+
// side's first wheel if none was marked driven (fallback documented in the header comment).
|
|
274
|
+
const buildTrack = (trackIndex, idxList, drivenList, override) => {
|
|
275
|
+
const t = controllerSettings.get_mTracks(trackIndex)
|
|
276
|
+
const wv = t.mWheels
|
|
277
|
+
const list = (override && Array.isArray(override.wheels)) ? override.wheels : idxList
|
|
278
|
+
for (const wi of list) wv.push_back(wi)
|
|
279
|
+
t.mWheels = wv
|
|
280
|
+
const drivenWheel = override && override.drivenWheel != null ? override.drivenWheel : (drivenList[0] ?? idxList[0] ?? 0)
|
|
281
|
+
t.mDrivenWheel = drivenWheel
|
|
282
|
+
if ((override && override.maxBrakeTorque != null)) t.mMaxBrakeTorque = override.maxBrakeTorque
|
|
283
|
+
if ((override && override.differentialRatio != null)) t.mDifferentialRatio = override.differentialRatio
|
|
284
|
+
return t
|
|
285
|
+
}
|
|
286
|
+
const leftOverride = opts.tracks && opts.tracks.left
|
|
287
|
+
const rightOverride = opts.tracks && opts.tracks.right
|
|
288
|
+
const leftTrack = buildTrack(0, leftIdx, leftDrivenIdx, leftOverride)
|
|
289
|
+
controllerSettings.set_mTracks(0, leftTrack)
|
|
290
|
+
const rightTrack = buildTrack(1, rightIdx, rightDrivenIdx, rightOverride)
|
|
291
|
+
controllerSettings.set_mTracks(1, rightTrack)
|
|
292
|
+
// Deliberately NOT calling J.destroy(leftTrack)/J.destroy(rightTrack) here -- live-probed: destroying
|
|
293
|
+
// either track handle AFTER set_mTracks has copied it in corrupts Jolt's WASM state, surfacing as a
|
|
294
|
+
// "memory access out of bounds" RuntimeError on the NEXT physicsSystem.Step() call (not immediately,
|
|
295
|
+
// making it easy to misattribute) -- same failure-mode CLASS as the documented trimesh-ShapeResult
|
|
296
|
+
// and vehicle-constraint-teardown use-after-free lessons above, but here the correct fix is the
|
|
297
|
+
// opposite of those: never destroy at all rather than destroy-after-use, since get_mTracks(index)
|
|
298
|
+
// copy semantics mean these two small JS wrapper handles have no Jolt-side ref to release (unlike
|
|
299
|
+
// the constraint/tester RefTarget objects, which DO need RemoveConstraint).
|
|
300
|
+
vcs.mController = controllerSettings
|
|
301
|
+
|
|
302
|
+
constraint = new J.VehicleConstraint(chassis, vcs)
|
|
303
|
+
tester = new J.VehicleCollisionTesterRay(LAYER_DYNAMIC, new J.Vec3(up[0], up[1], up[2]))
|
|
304
|
+
constraint.SetVehicleCollisionTester(tester)
|
|
305
|
+
this.physicsSystem.AddConstraint(constraint)
|
|
306
|
+
stepListener = new J.VehicleConstraintStepListener(constraint)
|
|
307
|
+
this.physicsSystem.AddStepListener(stepListener)
|
|
308
|
+
|
|
309
|
+
const controller = J.castObject(constraint.GetController(), J.TrackedVehicleController)
|
|
310
|
+
const vid = (this._nextVehicleId = (this._nextVehicleId || 0) + 1)
|
|
311
|
+
if (!this._vehicles) this._vehicles = new Map()
|
|
312
|
+
this._vehicles.set(vid, { constraint, controller, tester, stepListener, chassisBodyId, wheelCount: wheelDefs.length, tracked: true })
|
|
313
|
+
return vid
|
|
314
|
+
} catch (e) {
|
|
315
|
+
console.error('[physics] createTrackedVehicle failed:', e?.message || e)
|
|
316
|
+
return null
|
|
317
|
+
} finally { if (vcs) J.destroy(vcs) }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// driverInput for a tracked vehicle: forward in -1..1 (throttle/reverse), leftRatio/rightRatio in
|
|
321
|
+
// -1..1 (per-track power ratio -- equal ratios drive straight, differing ratios steer/pivot, matching
|
|
322
|
+
// Jolt's own TrackedVehicleController::SetDriverInput(forward, leftRatio, rightRatio, brake) signature
|
|
323
|
+
// live-confirmed via the probe this session), brake in 0..1. Deliberately a DIFFERENT shape from
|
|
324
|
+
// setVehicleDriverInput's forward/right/brake/handbrake (the row's own instruction: tracks steer via
|
|
325
|
+
// per-side power ratio, not a wheel-turn angle, so a shared signature would be misleading).
|
|
326
|
+
proto.setTrackedVehicleDriverInput = function (vehicleId, forward, leftRatio, rightRatio, brake = 0) {
|
|
327
|
+
const v = this._vehicles && this._vehicles.get(vehicleId); if (!v || !v.tracked) return false
|
|
328
|
+
// Same sleeping-body wake fix as setVehicleDriverInput above -- see that method's header comment
|
|
329
|
+
// for the full live-reproduced finding (a settled/sleeping vehicle ignores driver input silently
|
|
330
|
+
// with zero error until its body is explicitly reactivated).
|
|
331
|
+
if ((forward || leftRatio || rightRatio || brake) && this.bodyInterface?.ActivateBody) {
|
|
332
|
+
const b = this._getBody(v.chassisBodyId); if (b) this.bodyInterface.ActivateBody(b.GetID())
|
|
333
|
+
}
|
|
334
|
+
v.controller.SetDriverInput(forward, leftRatio, rightRatio, brake)
|
|
335
|
+
return true
|
|
336
|
+
}
|
|
337
|
+
}
|