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.
@@ -1,991 +1,682 @@
1
- import { extractMeshFromGLB, extractMeshFromGLBAsync } from './GLBLoader.js'
2
- import { CharacterManager } from './CharacterManager.js'
3
- import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js'
4
-
5
- const LAYER_STATIC = 0, LAYER_DYNAMIC = 1, NUM_LAYERS = 2
6
- const _PARK_POS = [0, -100000, 0]
7
- let joltInstance = null
8
- export async function getJolt() {
9
- if (!joltInstance) {
10
- // Edge-target seam (edge-cf-durable-object-transport-adapter-real-websocketpair): a Cloudflare
11
- // Durable Object has no node:fs (so the Node branch's `jolt-physics/wasm-compat` import is right
12
- // for AppRuntime.js's own isNode checks generally, but jolt-physics's OWN bundled Emscripten glue
13
- // independently re-checks `process.versions.node` and crashes on `createRequire(import.meta.url)`
14
- // when `nodejs_compat` is enabled -- live-reproduced via a real `wrangler dev` workerd instance)
15
- // and no static URL to fetch the browser branch's `/node_modules/...` path from either (workerd has
16
- // no filesystem route to serve that string, live-reproduced as a bundler-time unresolvable dynamic
17
- // import). Real fix (proven live against workerd): the edge worker imports jolt-physics/wasm's
18
- // native `.wasm` module binding at BUILD TIME (the only embedder-allowed way to get compiled Wasm
19
- // into a Worker -- ahead-of-time compiled, not runtime `WebAssembly.instantiate()` from raw bytes,
20
- // which workerd's embedder policy blocks outright) and instantiates it itself via Emscripten's
21
- // standard `Module.instantiateWasm` hook (checked before either of Jolt's own broken internal
22
- // branches run), then stashes the resulting live Jolt module here before any PhysicsWorld boots --
23
- // see edge/cf-do/spoint-do.js's initJoltForEdge(). This is a pure opt-in: unset in every existing
24
- // Node/browser boot path, so both of those branches are byte-unchanged from before this fix.
25
- if (typeof globalThis.__SPOINT_EDGE_JOLT__ !== 'undefined') {
26
- joltInstance = await globalThis.__SPOINT_EDGE_JOLT__
27
- return joltInstance
28
- }
29
- const _isNode = typeof process !== 'undefined' && process.versions?.node
30
- // Specifier built at runtime (not a literal passed straight to import()) so an edge/DO bundler
31
- // build (esbuild via wrangler) never tries to statically resolve the browser-only absolute
32
- // '/node_modules/...' path -- it isn't reachable there anyway (see the __SPOINT_EDGE_JOLT__
33
- // early-return above), but a bundler's static import-graph walk doesn't know that; it fails the
34
- // WHOLE build on an unresolvable literal specifier regardless of runtime reachability. Zero
35
- // behavior change for Node/browser: same two real specifiers, same ternary choice, just built as
36
- // a string first (live-confirmed via a real wrangler --dry-run build that this defeats esbuild's
37
- // static resolution while a literal ternary-in-import() does not).
38
- const _joltSpec = _isNode ? 'jolt-physics/wasm-compat' : ('/node_modules/' + 'jolt-physics/dist/jolt-physics.wasm.js')
39
- const { default: init } = await import(_joltSpec)
40
- joltInstance = await init()
41
- }
42
- return joltInstance
43
- }
44
-
45
- export class PhysicsWorld {
46
- constructor(config = {}) {
47
- this.gravity = config.gravity || [0, -9.81, 0]
48
- this.Jolt = null; this.jolt = null; this.physicsSystem = null; this.bodyInterface = null
49
- this.bodies = new Map(); this.bodyMeta = new Map(); this.bodyIds = new Map()
50
- this._objFilter = null; this._ovbp = null
51
- this._shapeCache = new Map(); this._convexQueue = Promise.resolve()
52
- this._trimeshCache = new Map(); this._trimeshInflight = new Map()
53
- this._bodyPool = new Map(); this._bodyShapeKey = new Map()
54
- this._bodyQueue = []
55
- this._tmpVec3 = null; this._tmpRVec3 = null
56
- this._bulkOutP = null; this._bulkOutR = null; this._bulkOutLV = null; this._bulkOutAV = null
57
- this._charMgr = new CharacterManager(this.gravity, config.crouchHalfHeight || 0.45)
58
- }
59
-
60
- async init() {
61
- const J = await getJolt(); this.Jolt = J
62
- const objFilter = new J.ObjectLayerPairFilterTable(NUM_LAYERS)
63
- objFilter.EnableCollision(LAYER_STATIC, LAYER_DYNAMIC); objFilter.EnableCollision(LAYER_DYNAMIC, LAYER_DYNAMIC)
64
- const bpI = new J.BroadPhaseLayerInterfaceTable(NUM_LAYERS, 2)
65
- bpI.MapObjectToBroadPhaseLayer(LAYER_STATIC, new J.BroadPhaseLayer(0))
66
- bpI.MapObjectToBroadPhaseLayer(LAYER_DYNAMIC, new J.BroadPhaseLayer(1))
67
- const ovbp = new J.ObjectVsBroadPhaseLayerFilterTable(bpI, 2, objFilter, NUM_LAYERS)
68
- const settings = new J.JoltSettings()
69
- settings.mObjectLayerPairFilter = objFilter; settings.mBroadPhaseLayerInterface = bpI
70
- settings.mObjectVsBroadPhaseLayerFilter = ovbp
71
- this._objFilter = objFilter; this._ovbp = ovbp
72
- this.jolt = new J.JoltInterface(settings); J.destroy(settings)
73
- this.physicsSystem = this.jolt.GetPhysicsSystem(); this.bodyInterface = this.physicsSystem.GetBodyInterface()
74
- this._tmpVec3 = new J.Vec3(0, 0, 0); this._tmpRVec3 = new J.RVec3(0, 0, 0); this._tmpQuat = new J.Quat(0, 0, 0, 1)
75
- this._bulkOutP = new J.RVec3(0, 0, 0); this._bulkOutR = new J.Quat(0, 0, 0, 1)
76
- this._bulkOutLV = new J.Vec3(0, 0, 0); this._bulkOutAV = new J.Vec3(0, 0, 0)
77
- const [gx, gy, gz] = this.gravity
78
- const gv = new J.Vec3(gx, gy, gz); this.physicsSystem.SetGravity(gv); J.destroy(gv)
79
- this._heap32 = new Int32Array(J.HEAP8.buffer)
80
- this._activationListener = new J.BodyActivationListenerJS()
81
- this._activationListener.OnBodyActivated = (ptr) => { if (this.onBodyActivated) this.onBodyActivated(this._heap32[ptr >> 2]) }
82
- this._activationListener.OnBodyDeactivated = (ptr) => { if (this.onBodyDeactivated) this.onBodyDeactivated(this._heap32[ptr >> 2]) }
83
- this.physicsSystem.SetBodyActivationListener(this._activationListener)
84
- // Aggressive body-sleep tuning: Jolt's own defaults (mTimeBeforeSleep=0.5s, mPointVelocitySleepThreshold=0.03)
85
- // were left untouched -- for a large scene of mostly-static-once-settled dynamic props (the 30k-model
86
- // budget target) a shorter settle time + slightly higher velocity floor means far more of the active-body
87
- // set self-sleeps via Jolt's own island-based sleep logic BEFORE the hard-activation-ring/global-budget
88
- // logic in AppRuntimePhysics even has to intervene -- the two mechanisms are complementary, not redundant:
89
- // this lowers the steady-state active count, the ring/budget logic bounds the worst case under load.
90
- if (typeof this.physicsSystem.GetPhysicsSettings === 'function' && typeof this.physicsSystem.SetPhysicsSettings === 'function') {
91
- const ps = this.physicsSystem.GetPhysicsSettings()
92
- ps.mTimeBeforeSleep = 0.25 // was Jolt default 0.5s -- settle twice as fast
93
- ps.mPointVelocitySleepThreshold = 0.05 // was Jolt default 0.03 -- sleep at a slightly higher residual jitter
94
- this.physicsSystem.SetPhysicsSettings(ps)
95
- }
96
- this._charMgr.init(J, this.jolt, this.physicsSystem)
97
- return this
98
- }
99
-
100
- _addBody(shape, position, motionType, layer, opts = {}) {
101
- const J = this.Jolt
102
- const pos = new J.RVec3(position[0], position[1], position[2])
103
- const rot = opts.rotation ? new J.Quat(...opts.rotation) : new J.Quat(0, 0, 0, 1)
104
- const cs = new J.BodyCreationSettings(shape, pos, rot, motionType, layer)
105
- J.destroy(pos); J.destroy(rot)
106
- if (opts.mass) { cs.mMassPropertiesOverride.mMass = opts.mass; cs.mOverrideMassProperties = J.EOverrideMassProperties_CalculateInertia }
107
- if (opts.friction !== undefined) cs.mFriction = opts.friction
108
- if (opts.restitution !== undefined) cs.mRestitution = opts.restitution // bounciness 0..1
109
- if (opts.gravityFactor !== undefined) cs.mGravityFactor = opts.gravityFactor // 0 = float, <0 = anti-gravity
110
- if (opts.linearDamping !== undefined) cs.mLinearDamping = opts.linearDamping
111
- if (opts.angularDamping !== undefined) cs.mAngularDamping = opts.angularDamping
112
- if (opts.linearCast) cs.mMotionQuality = J.EMotionQuality_LinearCast
113
- const activate = motionType === J.EMotionType_Static ? J.EActivation_DontActivate : J.EActivation_Activate
114
- const body = this.bodyInterface.CreateBody(cs); this.bodyInterface.AddBody(body.GetID(), activate)
115
- J.destroy(cs)
116
- this._createCount = (this._createCount | 0) + 1
117
- const id = body.GetID().GetIndexAndSequenceNumber()
118
- this.bodies.set(id, body); this.bodyMeta.set(id, opts.meta || {}); this.bodyIds.set(id, body.GetID())
119
- if (opts.shapeKey) this._bodyShapeKey.set(id, opts.shapeKey)
120
- return id
121
- }
122
-
123
- addStaticBox(halfExtents, position, rotation) {
124
- const J = this.Jolt
125
- const hv = new J.Vec3(halfExtents[0], halfExtents[1], halfExtents[2])
126
- const bs = new J.BoxShape(hv, 0.05, null); J.destroy(hv)
127
- return this._addBody(bs, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'box' } })
128
- }
129
-
130
- // activate: null (default) = EActivation_DontActivate (original behavior, correct for the STATIC
131
- // shapeKey pool users this was written for -- terrain colliders etc, which never simulate dynamics
132
- // either way). Pass true/false explicitly to force-activate or force-deactivate a DYNAMIC body being
133
- // parked/revived through the pool -- see removeBody/addBody's pool paths below, and the header comment
134
- // on why a dynamic body needs this (a merely-repositioned park with DontActivate does NOT deactivate an
135
- // already-active body -- it keeps simulating/falling forever at the park position, a real measured
136
- // per-tick cost live-witnessed while pooling destructible debris: a "parked" dynamic body fell
137
- // continuously the whole time it sat in the pool, 6.46m of drift across 1s of ticks in one probe).
138
- _repositionBody(id, position, rotation, activate = null) {
139
- const b = this._getBody(id); if (!b) return
140
- this._tmpRVec3.Set(position[0], position[1], position[2])
141
- const act = activate === true ? this.Jolt.EActivation_Activate : this.Jolt.EActivation_DontActivate
142
- if (rotation) {
143
- this._tmpQuat.Set(rotation[0], rotation[1], rotation[2], rotation[3])
144
- this.bodyInterface.SetPositionAndRotation(b.GetID(), this._tmpRVec3, this._tmpQuat, act)
145
- } else {
146
- this.bodyInterface.SetPosition(b.GetID(), this._tmpRVec3, act)
147
- }
148
- if (activate === false && this.bodyInterface.DeactivateBody) this.bodyInterface.DeactivateBody(b.GetID())
149
- }
150
-
151
- addBody(shapeType, params, position, motionType, opts = {}) {
152
- const J = this.Jolt; let shape
153
- const sk = opts.shapeKey || null
154
- if (sk) {
155
- const free = this._bodyPool.get(sk)
156
- if (free && free.length) {
157
- const id = free.pop()
158
- // Dynamic revive: reactivate + wipe stale linear/angular velocity from the piece's PREVIOUS life
159
- // (live-witnessed carrying over: a body removeBody'd mid-fall at -4.8m/s kept that exact velocity
160
- // into its next life at a totally different position, a real correctness bug for pooled debris --
161
- // a freshly "destroyed" piece would otherwise inherit whatever momentum the last occupant of this
162
- // pool slot happened to have when it despawned). Static/kinematic reuse (terrain colliders, the
163
- // pool's original use case) is unaffected since motionType there is never 'dynamic'.
164
- const isDynamic = motionType === 'dynamic'
165
- this._repositionBody(id, position, opts.rotation, isDynamic ? true : null)
166
- if (isDynamic) {
167
- this.setBodyVelocity(id, [0, 0, 0])
168
- this.setBodyAngularVelocity(id, [0, 0, 0])
169
- }
170
- return id
171
- }
172
- }
173
- if (shapeType === 'box') {
174
- const bk = opts.shapeKey || null
175
- if (bk && this._shapeCache.has(bk)) shape = this._shapeCache.get(bk)
176
- else { const cr = Math.min(0.05, Math.min(params[0], params[1], params[2]) * 0.1); const bv = new J.Vec3(params[0], params[1], params[2]); shape = new J.BoxShape(bv, cr, null); J.destroy(bv); if (bk) this._shapeCache.set(bk, shape) }
177
- }
178
- else if (shapeType === 'sphere') shape = new J.SphereShape(params)
179
- else if (shapeType === 'capsule') {
180
- const ck = opts.shapeKey || null
181
- if (ck && this._shapeCache.has(ck)) shape = this._shapeCache.get(ck)
182
- else { shape = new J.CapsuleShape(params[1], params[0]); if (ck) this._shapeCache.set(ck, shape) }
183
- }
184
- else if (shapeType === 'convex') {
185
- // sr must outlive the _addBody call that consumes cvxShape -- see ShapeBuilder.js's buildConvexShape
186
- // header comment (a real, live-reproduced WASM state-corruption bug found+fixed while wiring
187
- // destructibles-fractured-glb-shape-wiring's dynamic convex debris bodies).
188
- const { shape: cvxShape, sr } = buildConvexShape(J, params, this._shapeCache, opts.shapeKey || null)
189
- const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
190
- const id = this._addBody(cvxShape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } })
191
- if (sr) J.destroy(sr)
192
- return id
193
- }
194
- else return null
195
- const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
196
- return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } })
197
- }
198
-
199
- preallocatePool(shapeType, params, shapeKey, count) {
200
- if (!this.bodyInterface || !shapeKey || !(count > 0)) return 0
201
- let free = this._bodyPool.get(shapeKey); if (!free) this._bodyPool.set(shapeKey, free = [])
202
- const need = count - free.length
203
- if (need <= 0) return 0
204
- const ids = []
205
- for (let i = 0; i < need; i++) {
206
- const id = this.addBody(shapeType, params, _PARK_POS, 'static', { shapeKey })
207
- if (id == null) break
208
- ids.push(id)
209
- }
210
- for (const id of ids) { this._repositionBody(id, _PARK_POS, null); free.push(id) }
211
- return ids.length
212
- }
213
-
214
- addConvexBodyAsync(params, position, motionType, opts = {}) {
215
- const J = this.Jolt, cacheKey = opts.shapeKey || null
216
- if (cacheKey && this._shapeCache.has(cacheKey)) {
217
- const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
218
- return Promise.resolve(this._addBody(this._shapeCache.get(cacheKey), position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } }))
219
- }
220
- const result = this._convexQueue.then(() => {
221
- // sr must outlive the _addBody call below -- see ShapeBuilder.js's buildConvexShape header comment.
222
- const { shape, sr } = buildConvexShape(J, params, this._shapeCache, cacheKey)
223
- const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
224
- const id = this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } })
225
- if (sr) J.destroy(sr)
226
- return id
227
- })
228
- this._convexQueue = result.then(() => {}, () => {}); return result
229
- }
230
-
231
- // Shape caching/welding: a static trimesh cooked from a GLB (extractAllMeshesFromGLBAsync + Jolt
232
- // MeshShapeSettings.Create()) is real, measurable per-call cost -- full mesh extraction plus native
233
- // triangle-list construction -- yet maps commonly place the SAME model many times (rocks, crates,
234
- // barrels, props). Every prior call re-extracted and re-cooked from scratch even for an identical
235
- // glbPath+scale pair. Cache key is glbPath+scale (buildTrimeshShape pre-scales vertices into world
236
- // space, so two different scales of the same model genuinely need two different cooked shapes; a
237
- // rotation-only difference does NOT, since rotation is applied at the body level via _addBody's
238
- // BodyCreationSettings, not baked into the shape). The cached Shape is a real Jolt-side ref-counted
239
- // object (Shape.AddRef/Release/GetRefCount, confirmed in jolt-physics.wasm-compat.d.ts) -- sharing one
240
- // cooked shape across many bodies is Jolt's own supported "welding" pattern, same trust level as the
241
- // pre-existing box/capsule/convex shapeKey cache in addBody/buildConvexShape above (which also never
242
- // destroys a cached shape, relying on Jolt's own refcounting under each BodyCreationSettings/body).
243
- // In-flight dedupe (_trimeshInflight) additionally prevents two concurrent placements of the same
244
- // model+scale from racing two independent cook operations before either populates the cache.
245
- async addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) {
246
- if (!glbPath) throw new Error('addStaticTrimeshAsync: no glbPath (resolveAssetPath rejected or returned an empty path)')
247
- const J = this.Jolt
248
- const key = `${glbPath}|${scale[0]},${scale[1]},${scale[2]}`
249
- let shape = this._trimeshCache.get(key)
250
- let srToDestroyAfterFirstUse = null
251
- if (!shape) {
252
- let inflight = this._trimeshInflight.get(key)
253
- if (!inflight) {
254
- inflight = buildTrimeshShape(J, glbPath, scale).then(built => {
255
- this._trimeshCache.set(key, built.shape)
256
- this._trimeshInflight.delete(key)
257
- return built
258
- }, err => { this._trimeshInflight.delete(key); throw err })
259
- this._trimeshInflight.set(key, inflight)
260
- }
261
- const built = await inflight
262
- shape = built.shape
263
- // Live-witnessed hard rule (WASM "null function or function signature mismatch" crash on the
264
- // NEXT distinct trimesh add otherwise): the ShapeResult (`sr`) must be destroyed only AFTER a
265
- // real _addBody call has consumed/reffed the Shape it wraps -- destroying it any earlier (e.g.
266
- // inside the .then() before the first body exists) corrupts Jolt's WASM state for subsequent
267
- // shape creation, even though `shape` itself looks like a valid JS object at that point.
268
- // `built` is the SAME object handed to every concurrent `await inflight` caller (a resolved
269
- // Promise shares its value, it does not clone it) -- when N callers raced the same fresh key
270
- // (the in-flight-dedupe case _trimeshInflight exists for), naively checking `built.sr` would
271
- // have every one of them see it truthy and each call J.destroy(built.sr), a double-destroy of
272
- // the same native object. Null it out on first claim so only ONE of the N awaiters (whichever
273
- // microtask runs first, harmless which) actually owns and performs the destroy.
274
- if (built.sr) { srToDestroyAfterFirstUse = built.sr; built.sr = null }
275
- }
276
- const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'trimesh', shapeKey: key } })
277
- if (srToDestroyAfterFirstUse) J.destroy(srToDestroyAfterFirstUse)
278
- return id
279
- }
280
-
281
- addHeightField(samples, sampleCount, scale, position) {
282
- const J = this.Jolt
283
- const settings = new J.HeightFieldShapeSettings()
284
- const offset = new J.Vec3(0, 0, 0); settings.set_mOffset(offset); J.destroy(offset)
285
- const sv = new J.Vec3(scale[0], scale[1], scale[2]); settings.set_mScale(sv); J.destroy(sv)
286
- settings.set_mSampleCount(sampleCount)
287
- if (typeof settings.set_mBlockSize === 'function') settings.set_mBlockSize(2)
288
- const heights = settings.get_mHeightSamples()
289
- heights.resize(samples.length)
290
- let bulkOk = false
291
- if (typeof heights.data === 'function' && typeof J.getPointer === 'function' && J.HEAPF32) {
292
- const ref = heights.data()
293
- const ptr = J.getPointer(ref)
294
- if (ptr) {
295
- const view = samples instanceof Float32Array ? samples : Float32Array.from(samples)
296
- J.HEAPF32.set(view, ptr >> 2)
297
- bulkOk = true
298
- }
299
- }
300
- if (!bulkOk) {
301
- heights.clear(); heights.reserve(samples.length)
302
- for (let i = 0; i < samples.length; i++) heights.push_back(samples[i])
303
- }
304
- const sr = settings.Create()
305
- if (!sr.IsValid()) { console.error('[heightfield] shape invalid:', sr.GetError()); J.destroy(settings); J.destroy(sr); return null }
306
- const shape = sr.Get()
307
- const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'heightfield' } })
308
- J.destroy(settings); J.destroy(sr)
309
- return id
310
- }
311
-
312
- addStaticTrimeshFromData(entityId,v,ix,pos,rot=[0,0,0,1]){const J=this.Jolt,tc=ix.length/3,tl=new J.TriangleList(),f3=new J.Float3(0,0,0);tl.resize(tc);for(let t=0;t<tc;t++){const tri=tl.at(t);for(let k=0;k<3;k++){const i=ix[t*3+k];f3.x=v[i*3];f3.y=v[i*3+1];f3.z=v[i*3+2];tri.set_mV(k,f3)}}const ms=new J.MeshShapeSettings(tl),sr=ms.Create();if(!sr.IsValid()){console.error('[trimesh] shape invalid for',entityId,sr.GetError());J.destroy(f3);J.destroy(tl);J.destroy(ms);return null}const shape=sr.Get();J.destroy(f3);J.destroy(tl);const id=this._addBody(shape,pos,J.EMotionType_Static,LAYER_STATIC,{rotation:rot,meta:{type:'static',shape:'trimesh'}});J.destroy(ms);J.destroy(sr);console.log('[trimesh] body created for',entityId,'id='+id,'tris='+tc);return id}
313
-
314
- addPlayerCharacter(radius, halfHeight, position, mass) { return this._charMgr.addCharacter(radius, halfHeight, position, mass) }
315
- setCharacterCrouch(id, v) { this._charMgr.setCrouch(id, v) }
316
- updateCharacter(id, dt) { this._charMgr.update(id, dt) }
317
- getCharacterPosition(id) { return this._charMgr.getPosition(id) }
318
- readCharacterPosition(id, out) { this._charMgr.readPosition(id, out) }
319
- getCharacterVelocity(id) { return this._charMgr.getVelocity(id) }
320
- readCharacterVelocity(id, out) { this._charMgr.readVelocity(id, out) }
321
- setCharacterVelocity(id, v) { this._charMgr.setVelocity(id, v) }
322
- setCharacterPosition(id, p) { this._charMgr.setPosition(id, p) }
323
- getCharacterGroundState(id) { return this._charMgr.getGroundState(id) }
324
- removeCharacter(id) { this._charMgr.removeCharacter(id) }
325
- get characters() { return this._charMgr.characters }
326
- // Rollback-netcode primitive, character-body half of snapshotBodies/restoreBodies (players use
327
- // CharacterVirtual, not regular Jolt bodies -- see CharacterManager.js's own snapshotAll/restoreAll
328
- // header comment for why only position+velocity round-trip).
329
- snapshotCharacters() { return this._charMgr.snapshotAll() }
330
- restoreCharacters(snap) { this._charMgr.restoreAll(snap) }
331
-
332
- _getBody(id) { return this.bodies.get(id) }
333
- isBodyActive(id) { const b = this._getBody(id); return b ? b.IsActive() : false }
334
-
335
- syncDynamicBody(bodyId, entity) {
336
- const b = this._getBody(bodyId); if (!b || !b.IsActive()) return false
337
- const id = this.bodyIds.get(bodyId), bi = this.bodyInterface
338
- bi.GetPositionAndRotation(id, this._bulkOutP, this._bulkOutR)
339
- bi.GetLinearAndAngularVelocity(id, this._bulkOutLV, this._bulkOutAV)
340
- entity.position[0] = this._bulkOutP.GetX(); entity.position[1] = this._bulkOutP.GetY(); entity.position[2] = this._bulkOutP.GetZ()
341
- entity.rotation[0] = this._bulkOutR.GetX(); entity.rotation[1] = this._bulkOutR.GetY(); entity.rotation[2] = this._bulkOutR.GetZ(); entity.rotation[3] = this._bulkOutR.GetW()
342
- entity.velocity[0] = this._bulkOutLV.GetX(); entity.velocity[1] = this._bulkOutLV.GetY(); entity.velocity[2] = this._bulkOutLV.GetZ()
343
- return true
344
- }
345
-
346
- // Rollback-netcode primitive (rollback-netcode-ggpo-style-input-rollback first slice): capture every
347
- // non-static body's full dynamics state (position, rotation, linear+angular velocity) for later exact
348
- // restore, the save/rewind half of a GGPO-style save-state -> resimulate-forward loop. Static bodies
349
- // (terrain, placed props with autoTrimesh, etc) are skipped entirely -- by construction a static body
350
- // never moves under simulation, so capturing/restoring it is pure waste on every single rollback save,
351
- // which per this row's own architecture happens on a tight per-tick budget. Uses the SAME
352
- // GetPositionAndRotation/GetLinearAndAngularVelocity bulk-read convention syncDynamicBody already
353
- // proved safe every tick in production (see the getBodyPosition/getBodyRotation header comment above
354
- // for why the two single-field getters are NOT safe to call back-to-back -- this reuses the safe path).
355
- snapshotBodies() {
356
- const out = new Map()
357
- const bi = this.bodyInterface
358
- for (const [id, meta] of this.bodyMeta) {
359
- if (meta && meta.type === 'static') continue
360
- const jid = this.bodyIds.get(id); if (!jid) continue
361
- bi.GetPositionAndRotation(jid, this._bulkOutP, this._bulkOutR)
362
- bi.GetLinearAndAngularVelocity(jid, this._bulkOutLV, this._bulkOutAV)
363
- out.set(id, {
364
- position: [this._bulkOutP.GetX(), this._bulkOutP.GetY(), this._bulkOutP.GetZ()],
365
- rotation: [this._bulkOutR.GetX(), this._bulkOutR.GetY(), this._bulkOutR.GetZ(), this._bulkOutR.GetW()],
366
- velocity: [this._bulkOutLV.GetX(), this._bulkOutLV.GetY(), this._bulkOutLV.GetZ()],
367
- angularVelocity: [this._bulkOutAV.GetX(), this._bulkOutAV.GetY(), this._bulkOutAV.GetZ()],
368
- })
369
- }
370
- return out
371
- }
372
-
373
- // Restores exactly the bodies present in `snap` (a Map from snapshotBodies, or a plain object with the
374
- // same per-entry shape for a wire-deserialized snapshot). A body present in `snap` but since removed
375
- // from the live world (removeBody'd between save and rollback -- e.g. a debris piece that despawned) is
376
- // silently skipped, matching CharacterManager.restoreAll's same-set assumption: a rollback caller always
377
- // restores against the identical body population it saved, so this is a defensive skip, not a real path.
378
- // EActivation_Activate: a rolled-back body must be simulating again even if the pre-restore Jolt state
379
- // happened to have it asleep (a resimulate pass needs every body live for the physics.step() calls that
380
- // follow, or Jolt will not integrate a sleeping body and the resimulation silently diverges from a truly
381
- // deterministic replay where that body was awake throughout).
382
- restoreBodies(snap) {
383
- const bi = this.bodyInterface, J = this.Jolt
384
- const entries = snap instanceof Map ? snap.entries() : Object.entries(snap)
385
- for (const [idKey, s] of entries) {
386
- const id = typeof idKey === 'number' ? idKey : Number(idKey)
387
- const jid = this.bodyIds.get(id); if (!jid) continue
388
- this._bulkOutP.Set(s.position[0], s.position[1], s.position[2])
389
- this._bulkOutR.Set(s.rotation[0], s.rotation[1], s.rotation[2], s.rotation[3])
390
- bi.SetPositionAndRotation(jid, this._bulkOutP, this._bulkOutR, J.EActivation_Activate)
391
- this._bulkOutLV.Set(s.velocity[0], s.velocity[1], s.velocity[2])
392
- this._bulkOutAV.Set(s.angularVelocity[0], s.angularVelocity[1], s.angularVelocity[2])
393
- bi.SetLinearAndAngularVelocity(jid, this._bulkOutLV, this._bulkOutAV)
394
- }
395
- }
396
-
397
- // NOTE: routed through GetPositionAndRotation + the pre-allocated, never-destroyed _bulkOutP/_bulkOutR
398
- // scratch pair (the same buffers syncDynamicBody already used safely), NOT the single-field
399
- // GetPosition/GetRotation calls the two used to make independently. Real bug found+fixed this session
400
- // (deterministic-simulation-jolt-fixed-point-rollback probe): calling getBodyPosition(id) then
401
- // getBodyRotation(id) for the same body in the same tick -- in EITHER order, even across two separate
402
- // loops over the same body set (not just interleaved per-body) -- crashed with a real, 100% reproducible
403
- // "RuntimeError: memory access out of bounds" WASM trap, live-isolated down to a single dynamic body,
404
- // first tick, fresh process (not a multi-world/heap-accumulation artifact). Root cause: GetPosition's and
405
- // GetRotation's own embind wrappers each return a value via an embind by-value-return convention that,
406
- // like the already-documented GetAngularVelocity buffer below, is NOT safe to Jolt.destroy() when a sibling
407
- // getter's return value is live in the same synchronous scope -- calling BOTH getters (each individually
408
- // safe when called alone, confirmed via a 600-tick isolation run) then destroying either return value
409
- // corrupts shared WASM-side state the other getter's wrapper also touches. GetPositionAndRotation's own
410
- // out-param convention was already proven safe under the identical 24-body/600-tick stress (syncDynamicBody
411
- // uses it every tick in production) -- reusing it here fixes both getters without changing either's public
412
- // signature or return shape. This is a real fix, not exemption: nothing new is heap-allocated per call to
413
- // the reused _bulkOutP/_bulkOutR pair, same discipline as getBodyAngularVelocity's no-destroy fix.
414
- getBodyPosition(id) { const b = this._getBody(id); if (!b) return [0,0,0]; this.bodyInterface.GetPositionAndRotation(b.GetID(), this._bulkOutP, this._bulkOutR); return [this._bulkOutP.GetX(),this._bulkOutP.GetY(),this._bulkOutP.GetZ()] }
415
- getBodyRotation(id) { const b = this._getBody(id); if (!b) return [0,0,0,1]; this.bodyInterface.GetPositionAndRotation(b.GetID(), this._bulkOutP, this._bulkOutR); return [this._bulkOutR.GetX(),this._bulkOutR.GetY(),this._bulkOutR.GetZ(),this._bulkOutR.GetW()] }
416
- getBodyVelocity(id) { const b = this._getBody(id); if (!b) return [0,0,0]; const v = this.bodyInterface.GetLinearVelocity(b.GetID()); const r=[v.GetX(),v.GetY(),v.GetZ()]; this.Jolt.destroy(v); return r }
417
- // NOTE: deliberately does NOT Jolt.destroy() the returned Vec3, unlike every sibling getter above.
418
- // Live-reproduced real bug (destructibles-debris-lifetime-lod session): BodyInterface.GetAngularVelocity's
419
- // embind wrapper returns a reference into a Jolt-internal reusable temp buffer (not a fresh heap Vec3 the
420
- // way GetPosition/GetRotation/GetLinearVelocity's OWN return values behave when called in isolation) --
421
- // destroying it here, then calling GetLinearVelocity (or GetAngularVelocity again) in the SAME tick during
422
- // a body's collision-response step, corrupted that shared buffer: a real "RuntimeError: memory access out
423
- // of bounds" WASM trap, deterministically reproduced at the exact tick a falling body first contacts the
424
- // ground (collision resolution touches the same internal velocity buffer Jolt is about to hand back out).
425
- // Isolated via paired probes: GetLinearVelocity-only (destroyed every tick) survives 500 ticks fine;
426
- // GetAngularVelocity-only (destroyed every tick) ALSO survives fine; only the INTERLEAVED linear+angular
427
- // sequence in one tick crashes -- and skipping the destroy() on angular's result alone (leaving linear's
428
- // existing destroy() untouched) fully fixes it. A one-time-per-call skipped destroy on a reused Jolt-side
429
- // temp buffer is not a real leak (nothing new is allocated per call to begin with).
430
- getBodyAngularVelocity(id) { const b = this._getBody(id); if (!b || !this.bodyInterface.GetAngularVelocity) return [0,0,0]; const v = this.bodyInterface.GetAngularVelocity(b.GetID()); return [v.GetX(),v.GetY(),v.GetZ()] }
431
- setBodyFriction(id, f) { const b = this._getBody(id); if (!b || !this.bodyInterface.SetFriction) return false; this.bodyInterface.SetFriction(b.GetID(), f); return true }
432
- setBodyRestitution(id, r) { const b = this._getBody(id); if (!b || !this.bodyInterface.SetRestitution) return false; this.bodyInterface.SetRestitution(b.GetID(), r); return true }
433
- setBodyPosition(id, p) { const b = this._getBody(id); if (!b) return; this._tmpRVec3.Set(p[0],p[1],p[2]); this.bodyInterface.SetPosition(b.GetID(), this._tmpRVec3, this.Jolt.EActivation_Activate) }
434
- // Flip an existing body's Jolt motion type in place (Dynamic<->Kinematic) for the hard-activation-ring
435
- // 30-100m tier -- reuses the same body/shape rather than destroy+recreate, so a ring crossing is one
436
- // Jolt call instead of a full shape rebuild. EActivation_DontActivate: caller decides activation separately.
437
- setBodyMotionType(id, motionType) {
438
- const b = this._getBody(id); if (!b || !this.bodyInterface.SetMotionType) return false
439
- const J = this.Jolt
440
- const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
441
- this.bodyInterface.SetMotionType(b.GetID(), mt, J.EActivation_DontActivate)
442
- return true
443
- }
444
- // Proximity-priority sleep: put an active body to sleep without destroying it (cheap to reactivate,
445
- // unlike removeBody which frees the Jolt shape). DeactivateBody fires the same OnBodyDeactivated
446
- // listener a natural velocity-threshold sleep would, so AppRuntimePhysics' bookkeeping (active/sleeping
447
- // sets) stays correct via the existing listener, no separate code path needed downstream.
448
- deactivateBody(id) {
449
- const b = this._getBody(id); if (!b || !this.bodyInterface.DeactivateBody) return false
450
- this.bodyInterface.DeactivateBody(b.GetID())
451
- return true
452
- }
453
- setBodyVelocity(id, v) { const b = this._getBody(id); if (!b) return; this._tmpVec3.Set(v[0],v[1],v[2]); this.bodyInterface.SetLinearVelocity(b.GetID(), this._tmpVec3) }
454
- // Undocumented-in-.d.ts but real, compiled-WASM-confirmed binding (same class of gap as the
455
- // Vehicle* surface -- see project/vehicles-jolt-constraint-available-not-just-twobody in AGENTS.md).
456
- // Needed to fully reset a REUSED dynamic body (pooled debris revival): SetLinearVelocity alone leaves
457
- // stale angular velocity/spin from the body's PREVIOUS life on the pooled Jolt body, since Jolt does
458
- // not reset angular velocity as a side effect of SetPosition/SetLinearVelocity.
459
- setBodyAngularVelocity(id, v) { const b = this._getBody(id); if (!b || !this.bodyInterface.SetAngularVelocity) return false; this._tmpVec3.Set(v[0],v[1],v[2]); this.bodyInterface.SetAngularVelocity(b.GetID(), this._tmpVec3); return true }
460
- addForce(id, f) { const b = this._getBody(id); if (!b) return; this._tmpVec3.Set(f[0],f[1],f[2]); this.bodyInterface.AddForce(b.GetID(), this._tmpVec3) }
461
- // Optional worldPoint applies the impulse OFF-CENTRE (Jolt AddImpulse(id, impulse, point)) so it
462
- // imparts spin/torque -- a ball curves, a kick tumbles a prop. Without it the impulse is centre-of-mass.
463
- addImpulse(id, im, worldPoint) { const b = this._getBody(id); if (!b) return; this._tmpVec3.Set(im[0],im[1],im[2]); if (worldPoint) { this._tmpRVec3.Set(worldPoint[0],worldPoint[1],worldPoint[2]); this.bodyInterface.AddImpulse(b.GetID(), this._tmpVec3, this._tmpRVec3) } else this.bodyInterface.AddImpulse(b.GetID(), this._tmpVec3) }
464
- setBodyGravityFactor(id, f) { const b = this._getBody(id); if (!b || typeof f !== 'number' || !Number.isFinite(f)) return; this.bodyInterface.SetGravityFactor(b.GetID(), f) }
465
-
466
- // Join two bodies with a Jolt TwoBodyConstraint. type: 'fixed' (weld -- lock relative transform),
467
- // 'point' (ball joint -- share a point, free rotation), 'distance' (rigid rod between anchors),
468
- // 'hinge' (rotate about an axis). anchorA/anchorB are WORLD-space attach points (default both bodies'
469
- // current positions). Returns a constraintId for removeConstraint, or null if a body is unknown.
470
- addConstraint(bodyIdA, bodyIdB, opts = {}) {
471
- if (!this.physicsSystem) return null
472
- const ba = this._getBody(bodyIdA), bb = this._getBody(bodyIdB)
473
- if (!ba || !bb) return null
474
- const J = this.Jolt, type = opts.type || 'fixed'
475
- const pa = this.bodyInterface.GetPosition(ba.GetID()), pb = this.bodyInterface.GetPosition(bb.GetID())
476
- const aA = opts.anchorA || [pa.GetX(), pa.GetY(), pa.GetZ()]
477
- const aB = opts.anchorB || [pb.GetX(), pb.GetY(), pb.GetZ()]
478
- J.destroy(pa); J.destroy(pb)
479
- let settings = null
480
- try {
481
- if (type === 'point') {
482
- settings = new J.PointConstraintSettings()
483
- settings.mSpace = J.EConstraintSpace_WorldSpace
484
- settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
485
- } else if (type === 'distance') {
486
- settings = new J.DistanceConstraintSettings()
487
- settings.mSpace = J.EConstraintSpace_WorldSpace
488
- settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
489
- if (opts.minDistance != null) settings.mMinDistance = opts.minDistance
490
- if (opts.maxDistance != null) settings.mMaxDistance = opts.maxDistance
491
- } else if (type === 'hinge') {
492
- settings = new J.HingeConstraintSettings()
493
- settings.mSpace = J.EConstraintSpace_WorldSpace
494
- settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
495
- const ax = opts.axis || [0, 1, 0]
496
- settings.mHingeAxis1 = new J.Vec3(ax[0], ax[1], ax[2]); settings.mHingeAxis2 = new J.Vec3(ax[0], ax[1], ax[2])
497
- settings.mNormalAxis1 = new J.Vec3(1, 0, 0); settings.mNormalAxis2 = new J.Vec3(1, 0, 0)
498
- } else { // fixed / weld
499
- settings = new J.FixedConstraintSettings()
500
- settings.mSpace = J.EConstraintSpace_WorldSpace
501
- settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
502
- }
503
- const c = settings.Create(ba, bb)
504
- this.physicsSystem.AddConstraint(c)
505
- const cid = (this._nextConstraintId = (this._nextConstraintId || 0) + 1)
506
- if (!this._constraints) this._constraints = new Map()
507
- this._constraints.set(cid, c)
508
- return cid
509
- } catch (e) { console.error('[physics] addConstraint failed:', e?.message || e); return null }
510
- finally { if (settings) J.destroy(settings) }
511
- }
512
- removeConstraint(constraintId) {
513
- const c = this._constraints && this._constraints.get(constraintId)
514
- if (!c || !this.physicsSystem) return false
515
- this.physicsSystem.RemoveConstraint(c); this.Jolt.destroy(c); this._constraints.delete(constraintId)
516
- return true
517
- }
518
-
519
- // Real Jolt VehicleConstraint (WheeledVehicleController) -- vehicles-jolt-wheeled-constraints-app.
520
- // AGENTS.md's ragdoll-brawl-arena-no-joint-api caveat (2026-07-07) said "no joint/constraint
521
- // primitive anywhere in World.js" -- that was already stale by the time addConstraint (TwoBody
522
- // fixed/point/distance/hinge, above) landed, and a live probe against the ACTUAL jolt-physics 1.1.0
523
- // WASM build this session (both wasm and wasm-compat -- the .d.ts ships with zero Vehicle* entries,
524
- // a real type-definition gap, but the compiled WASM module itself exports the full upstream Jolt
525
- // VehicleConstraint/WheeledVehicleController/TrackedVehicleController surface, ~280 distinct
526
- // Vehicle*-prefixed bindings) confirms it IS available. A minimal real vehicle (box chassis body +
527
- // 4 WheelSettingsWV + one rear-wheel-drive VehicleDifferentialSettings + VehicleCollisionTesterRay)
528
- // was built, stepped 120 real ticks, and drove forward ~4.65m under sustained throttle -- see
529
- // AGENTS.md audit log entry for this session for the full probe transcript. The single sharpest
530
- // real gotcha found: WheeledVehicleControllerSettings.mDifferentials defaults to an EMPTY array --
531
- // with zero differentials configured, engine torque never reaches ANY wheel (a silent no-op: the
532
- // constraint builds fine, the wheels spin at 0 RPM, the chassis never moves) -- at least one
533
- // differential entry (mLeftWheel/mRightWheel wheel INDEXES into mWheels, matching the order wheels
534
- // were push_back'd) is mandatory for a driveable vehicle, not merely a tuning nicety.
535
- //
536
- // createWheeledVehicle(chassisBodyId, wheelDefs, opts): wheelDefs is an array of
537
- // {position:[x,y,z] (chassis-local), radius, width, suspensionMin, suspensionMax, maxSteerAngle,
538
- // maxBrakeTorque, maxHandBrakeTorque, steer:bool, drive:bool}. opts.up/opts.forward default to
539
- // [0,1,0]/[0,0,1] (matches this project's Z-forward convention already used by player rotation/yaw
540
- // elsewhere in this file's caller). Returns a vehicleId (opaque, keyed into this._vehicles) or null.
541
- createWheeledVehicle(chassisBodyId, wheelDefs, opts = {}) {
542
- if (!this.physicsSystem) return null
543
- const chassis = this._getBody(chassisBodyId); if (!chassis) return null
544
- if (!Array.isArray(wheelDefs) || wheelDefs.length === 0) return null
545
- const J = this.Jolt
546
- let vcs = null, wheelSettingsList = [], constraint = null, tester = null, stepListener = null
547
- try {
548
- vcs = new J.VehicleConstraintSettings()
549
- const up = opts.up || [0, 1, 0], fwd = opts.forward || [0, 0, 1]
550
- vcs.mUp = new J.Vec3(up[0], up[1], up[2])
551
- vcs.mForward = new J.Vec3(fwd[0], fwd[1], fwd[2])
552
- if (opts.maxPitchRollAngle != null) vcs.mMaxPitchRollAngle = opts.maxPitchRollAngle
553
-
554
- const wheelsArr = vcs.mWheels
555
- const driveIdxL = [], driveIdxR = []
556
- for (let i = 0; i < wheelDefs.length; i++) {
557
- const w = wheelDefs[i] || {}
558
- const ws = new J.WheelSettingsWV()
559
- const p = w.position || [0, 0, 0]
560
- ws.mPosition = new J.Vec3(p[0], p[1], p[2])
561
- if (w.suspensionDirection) { const sd = w.suspensionDirection; ws.mSuspensionDirection = new J.Vec3(sd[0], sd[1], sd[2]) }
562
- ws.mRadius = w.radius ?? 0.35
563
- ws.mWidth = w.width ?? 0.25
564
- ws.mSuspensionMinLength = w.suspensionMin ?? 0.3
565
- ws.mSuspensionMaxLength = w.suspensionMax ?? 0.5
566
- ws.mMaxSteerAngle = w.steer ? (w.maxSteerAngle ?? 0.6) : 0
567
- ws.mMaxBrakeTorque = w.maxBrakeTorque ?? 1500
568
- ws.mMaxHandBrakeTorque = w.maxHandBrakeTorque ?? 0
569
- wheelSettingsList.push(ws)
570
- wheelsArr.push_back(ws)
571
- // Left/right classified by local X sign (chassis-local wheel position) -- matches the probe's
572
- // convention and every real 4-wheel layout (negative X = left, positive X = right).
573
- if (w.drive) { if (p[0] < 0) driveIdxL.push(i); else driveIdxR.push(i) }
574
- }
575
- vcs.mWheels = wheelsArr
576
-
577
- const controllerSettings = new J.WheeledVehicleControllerSettings()
578
- const diffs = controllerSettings.mDifferentials
579
- // opts.differentials lets a caller fully hand-author the diff list (tracked-style split-per-axle
580
- // setups); default is one differential per drive axle pairing left/right drive wheels 1:1 by
581
- // position order (covers the common RWD/FWD/AWD single-or-dual-axle case with zero caller config).
582
- if (Array.isArray(opts.differentials) && opts.differentials.length) {
583
- for (const d of opts.differentials) {
584
- const vd = new J.VehicleDifferentialSettings()
585
- vd.mLeftWheel = d.leftWheel ?? -1; vd.mRightWheel = d.rightWheel ?? -1
586
- if (d.differentialRatio != null) vd.mDifferentialRatio = d.differentialRatio
587
- if (d.limitedSlipRatio != null) vd.mLimitedSlipRatio = d.limitedSlipRatio
588
- if (d.engineTorqueRatio != null) vd.mEngineTorqueRatio = d.engineTorqueRatio
589
- diffs.push_back(vd)
590
- }
591
- } else {
592
- const n = Math.max(driveIdxL.length, driveIdxR.length)
593
- for (let i = 0; i < n; i++) {
594
- const vd = new J.VehicleDifferentialSettings()
595
- vd.mLeftWheel = driveIdxL[i] ?? -1; vd.mRightWheel = driveIdxR[i] ?? -1
596
- vd.mEngineTorqueRatio = 1 / n
597
- diffs.push_back(vd)
598
- }
599
- }
600
- controllerSettings.mDifferentials = diffs
601
- if (opts.engine) {
602
- if (opts.engine.maxTorque != null) controllerSettings.mEngine.mMaxTorque = opts.engine.maxTorque
603
- if (opts.engine.maxRPM != null) controllerSettings.mEngine.mMaxRPM = opts.engine.maxRPM
604
- if (opts.engine.minRPM != null) controllerSettings.mEngine.mMinRPM = opts.engine.minRPM
605
- }
606
- vcs.mController = controllerSettings
607
-
608
- constraint = new J.VehicleConstraint(chassis, vcs)
609
- tester = new J.VehicleCollisionTesterRay(LAYER_DYNAMIC, new J.Vec3(up[0], up[1], up[2]))
610
- constraint.SetVehicleCollisionTester(tester)
611
- this.physicsSystem.AddConstraint(constraint)
612
- stepListener = new J.VehicleConstraintStepListener(constraint)
613
- this.physicsSystem.AddStepListener(stepListener)
614
-
615
- const controller = J.castObject(constraint.GetController(), J.WheeledVehicleController)
616
- const vid = (this._nextVehicleId = (this._nextVehicleId || 0) + 1)
617
- if (!this._vehicles) this._vehicles = new Map()
618
- this._vehicles.set(vid, { constraint, controller, tester, stepListener, chassisBodyId, wheelCount: wheelDefs.length })
619
- return vid
620
- } catch (e) {
621
- console.error('[physics] createWheeledVehicle failed:', e?.message || e)
622
- return null
623
- } finally { if (vcs) J.destroy(vcs) }
624
- }
625
-
626
- // driverInput: forward/right in -1..1, brake/handbrake in 0..1 -- straight passthrough to Jolt's own
627
- // WheeledVehicleController.SetDriverInput, which internally handles engine RPM/torque/transmission
628
- // gear-shift simulation every physics step via the registered VehicleConstraintStepListener.
629
- //
630
- // REAL BUG independently found+fixed by two sibling sessions the same day (vehicles-tracked-controller-
631
- // follow-up and vehicles-wheel-visual-wire-sync, both live-reproduced via a real booted server +
632
- // Playwright drive test): a parked vehicle that settles onto the ground goes to sleep via Jolt's own
633
- // island-based sleep logic (this project's own aggressive World.js init() sleep tuning --
634
- // mTimeBeforeSleep=0.25s -- makes a resting chassis fall asleep FAST) same as any other dynamic body,
635
- // and SetDriverInput alone does NOT wake a sleeping body -- driver input reaches the controller every
636
- // tick (confirmed via a call-count probe) but a sleeping VehicleConstraint's step listener still runs
637
- // against an inactive body and produces zero motion, silently -- no error, no thrown exception, the
638
- // constraint simply has nothing to move. A real player mounting a vehicle that already settled to sleep
639
- // before they pressed a drive key (the overwhelmingly common case -- a vehicle sits parked for more
640
- // than ~0.25s before anyone drives it) would find it completely unresponsive. Fix: wake the body on any
641
- // driver-input call carrying real forward/right/handbrake input -- brake alone is intentionally
642
- // excluded, since braking an already-sleeping/at-rest vehicle has nothing to do and must not fight the
643
- // sleep optimization by re-waking it every tick a parked driver holds the brake. Gated on isActive()
644
- // first so an already-awake vehicle (the common case, mid-drive) pays zero extra native call per tick.
645
- setVehicleDriverInput(vehicleId, forward, right, brake = 0, handbrake = 0) {
646
- const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return false
647
- if ((forward || right || handbrake) && this.bodyInterface.ActivateBody) {
648
- const chassis = this._getBody(v.chassisBodyId)
649
- if (chassis && !chassis.IsActive()) this.bodyInterface.ActivateBody(chassis.GetID())
650
- }
651
- v.controller.SetDriverInput(forward, right, brake, handbrake)
652
- return true
653
- }
654
- getVehicleWheelTransform(vehicleId, wheelIndex) {
655
- const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return null
656
- const J = this.Jolt
657
- // (bodyRotation, wheelRotationAxis) -- GetWheelWorldTransform's 3rd param is the local rotation
658
- // axis wheels spin about; [1,0,0] matches the WheelSettingsWV convention (wheel spin axis = local X).
659
- const t = v.constraint.GetWheelWorldTransform(wheelIndex, new J.Vec3(1, 0, 0), new J.Vec3(0, 1, 0))
660
- const pos = t.GetTranslation(), rot = t.GetQuaternion()
661
- const out = { position: [pos.GetX(), pos.GetY(), pos.GetZ()], rotation: [rot.GetX(), rot.GetY(), rot.GetZ(), rot.GetW()] }
662
- J.destroy(t)
663
- return out
664
- }
665
- getVehicleWheelSpeed(vehicleId, wheelIndex) {
666
- const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return 0
667
- const w = v.constraint.GetWheel(wheelIndex)
668
- return w ? w.GetAngularVelocity() : 0
669
- }
670
- isVehicleWheelGrounded(vehicleId, wheelIndex) {
671
- const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return false
672
- const w = v.constraint.GetWheel(wheelIndex)
673
- return w ? w.HasContact() : false
674
- }
675
- removeVehicle(vehicleId) {
676
- const v = this._vehicles && this._vehicles.get(vehicleId); if (!v || !this.physicsSystem) return false
677
- const J = this.Jolt
678
- try {
679
- this.physicsSystem.RemoveStepListener(v.stepListener)
680
- J.destroy(v.stepListener)
681
- // v.constraint and v.tester are BOTH Jolt-side ref-counted objects (RefTarget -- same family as
682
- // the Shape.AddRef/Release convention already documented in addStaticTrimeshAsync above).
683
- // RemoveConstraint's own native destructor chain drops the constraint's ref (which in turn drops
684
- // its ref on the collision tester it holds) -- live-witnessed (this session's probe): a manual
685
- // J.destroy(v.constraint) or J.destroy(v.tester) AFTER RemoveConstraint has already run is a real
686
- // use-after-free ("memory access out of bounds" / "table index is out of bounds" WASM traps, not
687
- // a benign double-free warning). Do NOT call J.destroy on either -- RemoveConstraint alone is the
688
- // complete, correct teardown for both.
689
- this.physicsSystem.RemoveConstraint(v.constraint)
690
- } catch (e) { console.error('[physics] removeVehicle cleanup error:', e?.message || e) }
691
- this._vehicles.delete(vehicleId)
692
- return true
693
- }
694
-
695
- // Real Jolt TrackedVehicleController -- vehicles-tracked-controller-follow-up, sibling to
696
- // createWheeledVehicle above. jolt-physics 1.1.0's compiled WASM build was probed live this session
697
- // (Object.keys(Jolt) against a real `import('jolt-physics/wasm-compat')`) and confirmed to export the
698
- // full TrackedVehicleController/TrackedVehicleControllerSettings/VehicleTrack/VehicleTrackSettings
699
- // surface, same undocumented-in-.d.ts situation as the wheeled case.
700
- //
701
- // MATERIAL DIFFERENCE from the wheeled settings shape (the row's own instruction to audit before
702
- // assuming 1:1 parity): TrackedVehicleControllerSettings.mTracks is NOT a push_back-able vector like
703
- // WheeledVehicleControllerSettings.mDifferentials -- it is a fixed C++ array of exactly 2
704
- // VehicleTrackSettings (upstream Jolt: `VehicleTrackSettings mTracks[2]`), and VehicleTrackSettings
705
- // itself has no public constructor (`new J.VehicleTrackSettings()` throws "no constructor in IDL").
706
- // The embind wrapper exposes this as get_mTracks(index)/set_mTracks(index, value) COPY-semantics
707
- // accessors (live-probed): get_mTracks(0) returns an independent mutable copy of track 0, mutating
708
- // that copy does NOT affect get_mTracks(1)'s copy, and the mutated copy must be written back via
709
- // set_mTracks(index, track) to take effect -- get-mutate-set, not get-and-keep-reference. Track index
710
- // 0 = left, 1 = right (matches Jolt's own sample/doc convention and this wrapper's driveIdx classification
711
- // below). Each track's mWheels IS a real push_back-able vector of wheel INDEXES (into mWheels on the
712
- // parent VehicleConstraintSettings, same indexing convention as the wheeled mDifferentials wheel refs).
713
- //
714
- // wheelDefs: array of {position:[x,y,z] chassis-local, radius, width, suspensionMin, suspensionMax,
715
- // maxBrakeTorque, side:'left'|'right', driven:bool}. Wheels use WheelSettingsTV (Tracked Vehicle) not
716
- // WheelSettingsWV (Wheeled) -- no steer angle field (tracks steer via differential left/right ratio,
717
- // not wheel-turn angle). At least one wheel per side must have driven:true set as that side's
718
- // mDrivenWheel (the wheel index the engine torque/track tension is actually applied through) --
719
- // otherwise, mirroring the wheeled mDifferentials-empty gotcha, a side with no explicit driven wheel
720
- // silently defaults mDrivenWheel to 0 (this wrapper's own first-wheel-of-side fallback below), so a
721
- // caller SHOULD mark one wheel per side driven:true rather than relying on the fallback.
722
- createTrackedVehicle(chassisBodyId, wheelDefs, opts = {}) {
723
- if (!this.physicsSystem) return null
724
- const chassis = this._getBody(chassisBodyId); if (!chassis) return null
725
- if (!Array.isArray(wheelDefs) || wheelDefs.length === 0) return null
726
- const J = this.Jolt
727
- let vcs = null, wheelSettingsList = [], constraint = null, tester = null, stepListener = null
728
- try {
729
- vcs = new J.VehicleConstraintSettings()
730
- const up = opts.up || [0, 1, 0], fwd = opts.forward || [0, 0, 1]
731
- vcs.mUp = new J.Vec3(up[0], up[1], up[2])
732
- vcs.mForward = new J.Vec3(fwd[0], fwd[1], fwd[2])
733
- if (opts.maxPitchRollAngle != null) vcs.mMaxPitchRollAngle = opts.maxPitchRollAngle
734
-
735
- const wheelsArr = vcs.mWheels
736
- const leftIdx = [], rightIdx = [], leftDrivenIdx = [], rightDrivenIdx = []
737
- for (let i = 0; i < wheelDefs.length; i++) {
738
- const w = wheelDefs[i] || {}
739
- const ws = new J.WheelSettingsTV()
740
- const p = w.position || [0, 0, 0]
741
- ws.mPosition = new J.Vec3(p[0], p[1], p[2])
742
- if (w.suspensionDirection) { const sd = w.suspensionDirection; ws.mSuspensionDirection = new J.Vec3(sd[0], sd[1], sd[2]) }
743
- ws.mRadius = w.radius ?? 0.35
744
- ws.mWidth = w.width ?? 0.4
745
- ws.mSuspensionMinLength = w.suspensionMin ?? 0.3
746
- ws.mSuspensionMaxLength = w.suspensionMax ?? 0.5
747
- if (w.maxBrakeTorque != null) ws.mMaxBrakeTorque = w.maxBrakeTorque
748
- wheelSettingsList.push(ws)
749
- wheelsArr.push_back(ws)
750
- // side classified explicitly (w.side) if given, else by local-X sign (negative = left, matching
751
- // createWheeledVehicle's own left/right convention) -- same fallback discipline as the wheeled case.
752
- const side = w.side || (p[0] < 0 ? 'left' : 'right')
753
- if (side === 'left') { leftIdx.push(i); if (w.driven) leftDrivenIdx.push(i) }
754
- else { rightIdx.push(i); if (w.driven) rightDrivenIdx.push(i) }
755
- }
756
- vcs.mWheels = wheelsArr
757
-
758
- const controllerSettings = new J.TrackedVehicleControllerSettings()
759
- if (opts.engine) {
760
- if (opts.engine.maxTorque != null) controllerSettings.mEngine.mMaxTorque = opts.engine.maxTorque
761
- if (opts.engine.maxRPM != null) controllerSettings.mEngine.mMaxRPM = opts.engine.maxRPM
762
- if (opts.engine.minRPM != null) controllerSettings.mEngine.mMinRPM = opts.engine.minRPM
763
- }
764
- // opts.tracks lets a caller fully hand-author both tracks (explicit wheel-index lists / driven
765
- // wheel / brake torque), matching createWheeledVehicle's opts.differentials override pattern.
766
- // Default: classify by side above, driven wheel = first driven:true wheel on that side, or the
767
- // side's first wheel if none was marked driven (fallback documented in the header comment).
768
- // VehicleTrackSettings has NO public constructor (`new J.VehicleTrackSettings()` throws "no
769
- // constructor in IDL", live-confirmed) -- the only way to get one is get_mTracks(index), which
770
- // returns an independent mutable COPY (not a live reference; live-confirmed mutating index 0's
771
- // copy does not affect index 1's), mutated in place then written back via set_mTracks(index, t).
772
- const buildTrack = (trackIndex, idxList, drivenList, override) => {
773
- const t = controllerSettings.get_mTracks(trackIndex)
774
- const wv = t.mWheels
775
- const list = (override && Array.isArray(override.wheels)) ? override.wheels : idxList
776
- for (const wi of list) wv.push_back(wi)
777
- t.mWheels = wv
778
- const drivenWheel = override && override.drivenWheel != null ? override.drivenWheel : (drivenList[0] ?? idxList[0] ?? 0)
779
- t.mDrivenWheel = drivenWheel
780
- if ((override && override.maxBrakeTorque != null)) t.mMaxBrakeTorque = override.maxBrakeTorque
781
- if ((override && override.differentialRatio != null)) t.mDifferentialRatio = override.differentialRatio
782
- return t
783
- }
784
- const leftOverride = opts.tracks && opts.tracks.left
785
- const rightOverride = opts.tracks && opts.tracks.right
786
- const leftTrack = buildTrack(0, leftIdx, leftDrivenIdx, leftOverride)
787
- controllerSettings.set_mTracks(0, leftTrack)
788
- const rightTrack = buildTrack(1, rightIdx, rightDrivenIdx, rightOverride)
789
- controllerSettings.set_mTracks(1, rightTrack)
790
- // Deliberately NOT calling J.destroy(leftTrack)/J.destroy(rightTrack) here -- live-probed this
791
- // session: destroying either track handle AFTER set_mTracks has copied it in corrupts Jolt's WASM
792
- // state, surfacing as a "memory access out of bounds" RuntimeError on the NEXT physicsSystem.Step()
793
- // call (not immediately, making it easy to misattribute) -- same failure-mode CLASS as the
794
- // documented trimesh-ShapeResult and vehicle-constraint-teardown use-after-free lessons above, but
795
- // here the correct fix is the opposite of those: never destroy at all rather than destroy-after-use,
796
- // since get_mTracks(index) copy semantics mean these two small JS wrapper handles have no Jolt-side
797
- // ref to release (unlike the constraint/tester RefTarget objects, which DO need RemoveConstraint).
798
- vcs.mController = controllerSettings
799
-
800
- constraint = new J.VehicleConstraint(chassis, vcs)
801
- tester = new J.VehicleCollisionTesterRay(LAYER_DYNAMIC, new J.Vec3(up[0], up[1], up[2]))
802
- constraint.SetVehicleCollisionTester(tester)
803
- this.physicsSystem.AddConstraint(constraint)
804
- stepListener = new J.VehicleConstraintStepListener(constraint)
805
- this.physicsSystem.AddStepListener(stepListener)
806
-
807
- const controller = J.castObject(constraint.GetController(), J.TrackedVehicleController)
808
- const vid = (this._nextVehicleId = (this._nextVehicleId || 0) + 1)
809
- if (!this._vehicles) this._vehicles = new Map()
810
- this._vehicles.set(vid, { constraint, controller, tester, stepListener, chassisBodyId, wheelCount: wheelDefs.length, tracked: true })
811
- return vid
812
- } catch (e) {
813
- console.error('[physics] createTrackedVehicle failed:', e?.message || e)
814
- return null
815
- } finally { if (vcs) J.destroy(vcs) }
816
- }
817
-
818
- // driverInput for a tracked vehicle: forward in -1..1 (throttle/reverse), leftRatio/rightRatio in
819
- // -1..1 (per-track power ratio -- equal ratios drive straight, differing ratios steer/pivot, matching
820
- // Jolt's own TrackedVehicleController::SetDriverInput(forward, leftRatio, rightRatio, brake) signature
821
- // live-confirmed via the probe this session), brake in 0..1. Deliberately a DIFFERENT shape from
822
- // setVehicleDriverInput's forward/right/brake/handbrake (the row's own instruction: tracks steer via
823
- // per-side power ratio, not a wheel-turn angle, so a shared signature would be misleading).
824
- setTrackedVehicleDriverInput(vehicleId, forward, leftRatio, rightRatio, brake = 0) {
825
- const v = this._vehicles && this._vehicles.get(vehicleId); if (!v || !v.tracked) return false
826
- // Same sleeping-body wake fix as setVehicleDriverInput above -- see that method's header comment
827
- // for the full live-reproduced finding (a settled/sleeping vehicle ignores driver input silently
828
- // with zero error until its body is explicitly reactivated).
829
- if ((forward || leftRatio || rightRatio || brake) && this.bodyInterface?.ActivateBody) {
830
- const b = this._getBody(v.chassisBodyId); if (b) this.bodyInterface.ActivateBody(b.GetID())
831
- }
832
- v.controller.SetDriverInput(forward, leftRatio, rightRatio, brake)
833
- return true
834
- }
835
-
836
- enqueueAdd(shapeType, params, position, motionType, opts, onAdded) {
837
- this._bodyQueue.push({ op: 'add', shapeType, params, position, motionType, opts: opts || {}, onAdded })
838
- }
839
-
840
- enqueueRemove(id, force = false) {
841
- this._bodyQueue.push({ op: 'remove', id, force })
842
- }
843
-
844
- // drainBodyQueue must run before physics.step() each tick: adds before removes.
845
- drainBodyQueue() {
846
- const q = this._bodyQueue
847
- if (q.length === 0) return 0
848
- this._bodyQueue = []
849
- let applied = 0
850
- for (let i = 0; i < q.length; i++) {
851
- const r = q[i]; if (r.op !== 'add') continue
852
- try { const id = this.addBody(r.shapeType, r.params, r.position, r.motionType, r.opts); if (r.onAdded) r.onAdded(id); applied++ }
853
- catch (e) { console.error('[physics] queued add error:', e?.message || e); if (r.onAdded) try { r.onAdded(null) } catch (_) {} }
854
- }
855
- for (let i = 0; i < q.length; i++) {
856
- const r = q[i]; if (r.op !== 'remove') continue
857
- try { this.removeBody(r.id, r.force); applied++ }
858
- catch (e) { console.error('[physics] queued remove error:', e?.message || e) }
859
- }
860
- return applied
861
- }
862
-
863
- get bodyQueueLength() { return this._bodyQueue.length }
864
-
865
- setTrunkColliderIds(set) { return (this._trunkColliderIds = set) }
866
- getTrunkColliderIds() { return this._trunkColliderIds }
867
- setRockColliderIds(set) { return (this._rockColliderIds = set) }
868
- getRockColliderIds() { return this._rockColliderIds }
869
- setTerrainBodyId(id) { return (this._terrainBodyId = id) }
870
- getTerrainBodyId() { return this._terrainBodyId ?? null }
871
- setTerrainHeightSource(fn, frame, offsetY = 0) { this._terrainHeightAt = fn; this._planetFrame = frame; this._terrainOffsetY = offsetY }
872
- getTerrainHeightFn() { return this._terrainHeightAt }
873
- getTerrainOffsetY() { return this._terrainOffsetY || 0 }
874
- terrainHeightAt(x, z) { return typeof this._terrainHeightAt === 'function' ? this._terrainHeightAt(x, z) + (this._terrainOffsetY || 0) : null }
875
-
876
- // collisionSteps is Jolt's own real Step(deltaTime, inCollisionSteps) sub-stepping parameter --
877
- // more collision steps per physics tick catch fast-moving bodies that would otherwise tunnel
878
- // through thin colliders within a single tick's motion. Default stays 2 (unchanged from before
879
- // this option existed) since quadrupling it unconditionally for every world would be a real,
880
- // needless per-tick cost for the common case (most bodies are slow enough that 2 is already
881
- // sufficient) -- a caller with genuinely fast projectiles/characters (the CCD-policy-per-entity-
882
- // class need this pairs with) passes a higher value explicitly instead.
883
- step(dt, collisionSteps = 2) { if (this.jolt) this.jolt.Step(dt, collisionSteps) }
884
-
885
- removeBody(id, force = false) {
886
- const b = this._getBody(id); if (!b) return
887
- const sk = !force && this._bodyShapeKey.get(id)
888
- if (sk) {
889
- // Force-deactivate a DYNAMIC body on park (see addBody's pool-hit revive comment above for the
890
- // measured cost of NOT doing this): merely repositioning with DontActivate does not stop an
891
- // already-active body from continuing to simulate/fall at the park position for however long it
892
- // sits pooled. Static/kinematic park (the pool's original terrain-collider use case) is unaffected
893
- // -- those never simulate dynamics regardless of active/inactive state.
894
- const isDynamic = this.bodyMeta.get(id)?.type === 'dynamic'
895
- this._repositionBody(id, _PARK_POS, null, isDynamic ? false : null)
896
- if (isDynamic) { this.setBodyVelocity(id, [0, 0, 0]); this.setBodyAngularVelocity(id, [0, 0, 0]) }
897
- let free = this._bodyPool.get(sk); if (!free) this._bodyPool.set(sk, free = [])
898
- free.push(id)
899
- return
900
- }
901
- this.bodyInterface.RemoveBody(b.GetID()); this.bodyInterface.DestroyBody(b.GetID())
902
- this.bodies.delete(id); this.bodyMeta.delete(id); this.bodyIds.delete(id); this._bodyShapeKey.delete(id)
903
- }
904
-
905
- asyncQuery(queries) {
906
- if (!Array.isArray(queries) || queries.length === 0) return Promise.resolve([])
907
- return new Promise(resolve => {
908
- if (!this._asyncQueryQueue) this._asyncQueryQueue = []
909
- if (!this._asyncQueryResolves) this._asyncQueryResolves = []
910
- const idx = this._asyncQueryQueue.length
911
- this._asyncQueryQueue.push(queries)
912
- this._asyncQueryResolves.push(resolve)
913
- if (!this._asyncQueryScheduled) {
914
- this._asyncQueryScheduled = true
915
- Promise.resolve().then(() => {
916
- this._asyncQueryScheduled = false
917
- const batch = this._asyncQueryQueue.splice(0)
918
- const resolvers = this._asyncQueryResolves.splice(0)
919
- const results = batch.map(qs => qs.map(q => {
920
- try {
921
- return this.raycast(q.origin, q.direction, q.maxDistance || 1000, q.excludeBodyId)
922
- } catch (e) {
923
- return { hit: false, distance: q.maxDistance || 1000, body: null, position: null, error: e.message }
924
- }
925
- }))
926
- for (let i = 0; i < resolvers.length; i++) resolvers[i](results[i])
927
- })
928
- }
929
- })
930
- }
931
-
932
- raycast(origin, direction, maxDistance = 1000, excludeBodyId = null) {
933
- if (!this.physicsSystem) return { hit: false, distance: maxDistance, body: null, position: null }
934
- const J = this.Jolt
935
- const len = Math.hypot(direction[0], direction[1], direction[2])
936
- const dir = len > 0 ? [direction[0]/len, direction[1]/len, direction[2]/len] : direction
937
- const ray = new J.RRayCast(new J.RVec3(origin[0], origin[1], origin[2]), new J.Vec3(dir[0]*maxDistance, dir[1]*maxDistance, dir[2]*maxDistance))
938
- const rs = new J.RayCastSettings(), col = new J.CastRayClosestHitCollisionCollector()
939
- const bp = new J.DefaultBroadPhaseLayerFilter(this.jolt.GetObjectVsBroadPhaseLayerFilter(), LAYER_DYNAMIC)
940
- const ol = new J.DefaultObjectLayerFilter(this.jolt.GetObjectLayerPairFilter(), LAYER_DYNAMIC)
941
- const eb = excludeBodyId != null ? this._getBody(excludeBodyId) : null
942
- const bf = eb ? new J.IgnoreSingleBodyFilter(eb.GetID()) : new J.BodyFilter()
943
- const sf = new J.ShapeFilter()
944
- this.physicsSystem.GetNarrowPhaseQuery().CastRay(ray, rs, col, bp, ol, bf, sf)
945
- let result
946
- if (col.HadHit()) {
947
- const hit = col.get_mHit()
948
- const dist = hit.mFraction * maxDistance
949
- const position = [origin[0]+dir[0]*dist, origin[1]+dir[1]*dist, origin[2]+dir[2]*dist]
950
- // Resolve the hit body back to a World body id -- the World id IS the Jolt
951
- // GetIndexAndSequenceNumber() (see addBody), so this keys the same bodyMeta / the runtime's
952
- // _physicsBodyToEntityId reverse map directly. Callers get an ATTRIBUTED hit (which entity/body),
953
- // not just a point -- this is the primitive that makes shoot/click-a-target games authorable.
954
- let bodyId = null, normal = null
955
- try {
956
- const bid = hit.mBodyID
957
- if (bid) bodyId = bid.GetIndexAndSequenceNumber()
958
- // Surface normal at the hit point (world space), for oriented decals / bounce / aim feedback.
959
- const b = bodyId != null ? this._getBody(bodyId) : null
960
- if (b) {
961
- this._tmpRVec3.Set(position[0], position[1], position[2])
962
- const n = b.GetWorldSpaceSurfaceNormal(hit.mSubShapeID2, this._tmpRVec3)
963
- normal = [n.GetX(), n.GetY(), n.GetZ()]
964
- J.destroy(n)
965
- }
966
- } catch (_) { /* normal/body extraction is best-effort; position always returns */ }
967
- result = { hit: true, distance: dist, body: null, bodyId, normal, position }
968
- } else result = { hit: false, distance: maxDistance, body: null, bodyId: null, normal: null, position: null }
969
- J.destroy(ray); J.destroy(rs); J.destroy(col); J.destroy(bp); J.destroy(ol); J.destroy(bf); J.destroy(sf)
970
- return result
971
- }
972
-
973
- destroy() {
974
- if (!this.Jolt) return
975
- this._charMgr.destroy()
976
- if (this._vehicles) for (const [id] of this._vehicles) this.removeVehicle(id)
977
- for (const [id] of this.bodies) this.removeBody(id, true)
978
- this._bodyPool.clear(); this._bodyShapeKey.clear()
979
- this._trimeshCache.clear(); this._trimeshInflight.clear()
980
- const J = this.Jolt
981
- if (this._tmpVec3) { J.destroy(this._tmpVec3); this._tmpVec3 = null }
982
- if (this._tmpRVec3) { J.destroy(this._tmpRVec3); this._tmpRVec3 = null }
983
- if (this._tmpQuat) { J.destroy(this._tmpQuat); this._tmpQuat = null }
984
- if (this._bulkOutP) { J.destroy(this._bulkOutP); this._bulkOutP = null }
985
- if (this._bulkOutR) { J.destroy(this._bulkOutR); this._bulkOutR = null }
986
- if (this._bulkOutLV) { J.destroy(this._bulkOutLV); this._bulkOutLV = null }
987
- if (this._bulkOutAV) { J.destroy(this._bulkOutAV); this._bulkOutAV = null }
988
- if (this.jolt) { J.destroy(this.jolt); this.jolt = null }
989
- this.physicsSystem = null; this.bodyInterface = null
990
- }
991
- }
1
+ import { extractMeshFromGLB, extractMeshFromGLBAsync } from './GLBLoader.js'
2
+ import { CharacterManager } from './CharacterManager.js'
3
+ import { installVehiclePhysics } from './VehiclePhysics.js'
4
+ import { buildConvexShape, buildTrimeshShape } from './ShapeBuilder.js'
5
+
6
+ const LAYER_STATIC = 0, LAYER_DYNAMIC = 1, NUM_LAYERS = 2
7
+ const _PARK_POS = [0, -100000, 0]
8
+ let joltInstance = null
9
+ export async function getJolt() {
10
+ if (!joltInstance) {
11
+ // Edge-target seam (edge-cf-durable-object-transport-adapter-real-websocketpair): a Cloudflare
12
+ // Durable Object has no node:fs (so the Node branch's `jolt-physics/wasm-compat` import is right
13
+ // for AppRuntime.js's own isNode checks generally, but jolt-physics's OWN bundled Emscripten glue
14
+ // independently re-checks `process.versions.node` and crashes on `createRequire(import.meta.url)`
15
+ // when `nodejs_compat` is enabled -- live-reproduced via a real `wrangler dev` workerd instance)
16
+ // and no static URL to fetch the browser branch's `/node_modules/...` path from either (workerd has
17
+ // no filesystem route to serve that string, live-reproduced as a bundler-time unresolvable dynamic
18
+ // import). Real fix (proven live against workerd): the edge worker imports jolt-physics/wasm's
19
+ // native `.wasm` module binding at BUILD TIME (the only embedder-allowed way to get compiled Wasm
20
+ // into a Worker -- ahead-of-time compiled, not runtime `WebAssembly.instantiate()` from raw bytes,
21
+ // which workerd's embedder policy blocks outright) and instantiates it itself via Emscripten's
22
+ // standard `Module.instantiateWasm` hook (checked before either of Jolt's own broken internal
23
+ // branches run), then stashes the resulting live Jolt module here before any PhysicsWorld boots --
24
+ // see edge/cf-do/spoint-do.js's initJoltForEdge(). This is a pure opt-in: unset in every existing
25
+ // Node/browser boot path, so both of those branches are byte-unchanged from before this fix.
26
+ if (typeof globalThis.__SPOINT_EDGE_JOLT__ !== 'undefined') {
27
+ joltInstance = await globalThis.__SPOINT_EDGE_JOLT__
28
+ return joltInstance
29
+ }
30
+ const _isNode = typeof process !== 'undefined' && process.versions?.node
31
+ // Specifier built at runtime (not a literal passed straight to import()) so an edge/DO bundler
32
+ // build (esbuild via wrangler) never tries to statically resolve the browser-only absolute
33
+ // '/node_modules/...' path -- it isn't reachable there anyway (see the __SPOINT_EDGE_JOLT__
34
+ // early-return above), but a bundler's static import-graph walk doesn't know that; it fails the
35
+ // WHOLE build on an unresolvable literal specifier regardless of runtime reachability. Zero
36
+ // behavior change for Node/browser: same two real specifiers, same ternary choice, just built as
37
+ // a string first (live-confirmed via a real wrangler --dry-run build that this defeats esbuild's
38
+ // static resolution while a literal ternary-in-import() does not).
39
+ const _joltSpec = _isNode ? 'jolt-physics/wasm-compat' : ('/node_modules/' + 'jolt-physics/dist/jolt-physics.wasm.js')
40
+ const { default: init } = await import(_joltSpec)
41
+ joltInstance = await init()
42
+ }
43
+ return joltInstance
44
+ }
45
+
46
+ export class PhysicsWorld {
47
+ constructor(config = {}) {
48
+ this.gravity = config.gravity || [0, -9.81, 0]
49
+ this.Jolt = null; this.jolt = null; this.physicsSystem = null; this.bodyInterface = null
50
+ this.bodies = new Map(); this.bodyMeta = new Map(); this.bodyIds = new Map()
51
+ this._objFilter = null; this._ovbp = null
52
+ this._shapeCache = new Map(); this._convexQueue = Promise.resolve()
53
+ this._trimeshCache = new Map(); this._trimeshInflight = new Map()
54
+ this._bodyPool = new Map(); this._bodyShapeKey = new Map()
55
+ this._bodyQueue = []
56
+ this._tmpVec3 = null; this._tmpRVec3 = null
57
+ this._bulkOutP = null; this._bulkOutR = null; this._bulkOutLV = null; this._bulkOutAV = null
58
+ this._charMgr = new CharacterManager(this.gravity, config.crouchHalfHeight || 0.45)
59
+ }
60
+
61
+ async init() {
62
+ const J = await getJolt(); this.Jolt = J
63
+ const objFilter = new J.ObjectLayerPairFilterTable(NUM_LAYERS)
64
+ objFilter.EnableCollision(LAYER_STATIC, LAYER_DYNAMIC); objFilter.EnableCollision(LAYER_DYNAMIC, LAYER_DYNAMIC)
65
+ const bpI = new J.BroadPhaseLayerInterfaceTable(NUM_LAYERS, 2)
66
+ bpI.MapObjectToBroadPhaseLayer(LAYER_STATIC, new J.BroadPhaseLayer(0))
67
+ bpI.MapObjectToBroadPhaseLayer(LAYER_DYNAMIC, new J.BroadPhaseLayer(1))
68
+ const ovbp = new J.ObjectVsBroadPhaseLayerFilterTable(bpI, 2, objFilter, NUM_LAYERS)
69
+ const settings = new J.JoltSettings()
70
+ settings.mObjectLayerPairFilter = objFilter; settings.mBroadPhaseLayerInterface = bpI
71
+ settings.mObjectVsBroadPhaseLayerFilter = ovbp
72
+ this._objFilter = objFilter; this._ovbp = ovbp
73
+ this.jolt = new J.JoltInterface(settings); J.destroy(settings)
74
+ this.physicsSystem = this.jolt.GetPhysicsSystem(); this.bodyInterface = this.physicsSystem.GetBodyInterface()
75
+ this._tmpVec3 = new J.Vec3(0, 0, 0); this._tmpRVec3 = new J.RVec3(0, 0, 0); this._tmpQuat = new J.Quat(0, 0, 0, 1)
76
+ this._bulkOutP = new J.RVec3(0, 0, 0); this._bulkOutR = new J.Quat(0, 0, 0, 1)
77
+ this._bulkOutLV = new J.Vec3(0, 0, 0); this._bulkOutAV = new J.Vec3(0, 0, 0)
78
+ const [gx, gy, gz] = this.gravity
79
+ const gv = new J.Vec3(gx, gy, gz); this.physicsSystem.SetGravity(gv); J.destroy(gv)
80
+ this._heap32 = new Int32Array(J.HEAP8.buffer)
81
+ this._activationListener = new J.BodyActivationListenerJS()
82
+ this._activationListener.OnBodyActivated = (ptr) => { if (this.onBodyActivated) this.onBodyActivated(this._heap32[ptr >> 2]) }
83
+ this._activationListener.OnBodyDeactivated = (ptr) => { if (this.onBodyDeactivated) this.onBodyDeactivated(this._heap32[ptr >> 2]) }
84
+ this.physicsSystem.SetBodyActivationListener(this._activationListener)
85
+ // Aggressive body-sleep tuning: Jolt's own defaults (mTimeBeforeSleep=0.5s, mPointVelocitySleepThreshold=0.03)
86
+ // were left untouched -- for a large scene of mostly-static-once-settled dynamic props (the 30k-model
87
+ // budget target) a shorter settle time + slightly higher velocity floor means far more of the active-body
88
+ // set self-sleeps via Jolt's own island-based sleep logic BEFORE the hard-activation-ring/global-budget
89
+ // logic in AppRuntimePhysics even has to intervene -- the two mechanisms are complementary, not redundant:
90
+ // this lowers the steady-state active count, the ring/budget logic bounds the worst case under load.
91
+ if (typeof this.physicsSystem.GetPhysicsSettings === 'function' && typeof this.physicsSystem.SetPhysicsSettings === 'function') {
92
+ const ps = this.physicsSystem.GetPhysicsSettings()
93
+ ps.mTimeBeforeSleep = 0.25 // was Jolt default 0.5s -- settle twice as fast
94
+ ps.mPointVelocitySleepThreshold = 0.05 // was Jolt default 0.03 -- sleep at a slightly higher residual jitter
95
+ this.physicsSystem.SetPhysicsSettings(ps)
96
+ }
97
+ this._charMgr.init(J, this.jolt, this.physicsSystem)
98
+ return this
99
+ }
100
+
101
+ _addBody(shape, position, motionType, layer, opts = {}) {
102
+ const J = this.Jolt
103
+ const pos = new J.RVec3(position[0], position[1], position[2])
104
+ const rot = opts.rotation ? new J.Quat(...opts.rotation) : new J.Quat(0, 0, 0, 1)
105
+ const cs = new J.BodyCreationSettings(shape, pos, rot, motionType, layer)
106
+ J.destroy(pos); J.destroy(rot)
107
+ if (opts.mass) { cs.mMassPropertiesOverride.mMass = opts.mass; cs.mOverrideMassProperties = J.EOverrideMassProperties_CalculateInertia }
108
+ if (opts.friction !== undefined) cs.mFriction = opts.friction
109
+ if (opts.restitution !== undefined) cs.mRestitution = opts.restitution // bounciness 0..1
110
+ if (opts.gravityFactor !== undefined) cs.mGravityFactor = opts.gravityFactor // 0 = float, <0 = anti-gravity
111
+ if (opts.linearDamping !== undefined) cs.mLinearDamping = opts.linearDamping
112
+ if (opts.angularDamping !== undefined) cs.mAngularDamping = opts.angularDamping
113
+ if (opts.linearCast) cs.mMotionQuality = J.EMotionQuality_LinearCast
114
+ const activate = motionType === J.EMotionType_Static ? J.EActivation_DontActivate : J.EActivation_Activate
115
+ const body = this.bodyInterface.CreateBody(cs); this.bodyInterface.AddBody(body.GetID(), activate)
116
+ J.destroy(cs)
117
+ this._createCount = (this._createCount | 0) + 1
118
+ const id = body.GetID().GetIndexAndSequenceNumber()
119
+ this.bodies.set(id, body); this.bodyMeta.set(id, opts.meta || {}); this.bodyIds.set(id, body.GetID())
120
+ if (opts.shapeKey) this._bodyShapeKey.set(id, opts.shapeKey)
121
+ return id
122
+ }
123
+
124
+ addStaticBox(halfExtents, position, rotation) {
125
+ const J = this.Jolt
126
+ const hv = new J.Vec3(halfExtents[0], halfExtents[1], halfExtents[2])
127
+ const bs = new J.BoxShape(hv, 0.05, null); J.destroy(hv)
128
+ return this._addBody(bs, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'box' } })
129
+ }
130
+
131
+ // activate: null (default) = EActivation_DontActivate (original behavior, correct for the STATIC
132
+ // shapeKey pool users this was written for -- terrain colliders etc, which never simulate dynamics
133
+ // either way). Pass true/false explicitly to force-activate or force-deactivate a DYNAMIC body being
134
+ // parked/revived through the pool -- see removeBody/addBody's pool paths below, and the header comment
135
+ // on why a dynamic body needs this (a merely-repositioned park with DontActivate does NOT deactivate an
136
+ // already-active body -- it keeps simulating/falling forever at the park position, a real measured
137
+ // per-tick cost live-witnessed while pooling destructible debris: a "parked" dynamic body fell
138
+ // continuously the whole time it sat in the pool, 6.46m of drift across 1s of ticks in one probe).
139
+ _repositionBody(id, position, rotation, activate = null) {
140
+ const b = this._getBody(id); if (!b) return
141
+ this._tmpRVec3.Set(position[0], position[1], position[2])
142
+ const act = activate === true ? this.Jolt.EActivation_Activate : this.Jolt.EActivation_DontActivate
143
+ if (rotation) {
144
+ this._tmpQuat.Set(rotation[0], rotation[1], rotation[2], rotation[3])
145
+ this.bodyInterface.SetPositionAndRotation(b.GetID(), this._tmpRVec3, this._tmpQuat, act)
146
+ } else {
147
+ this.bodyInterface.SetPosition(b.GetID(), this._tmpRVec3, act)
148
+ }
149
+ if (activate === false && this.bodyInterface.DeactivateBody) this.bodyInterface.DeactivateBody(b.GetID())
150
+ }
151
+
152
+ addBody(shapeType, params, position, motionType, opts = {}) {
153
+ const J = this.Jolt; let shape
154
+ const sk = opts.shapeKey || null
155
+ if (sk) {
156
+ const free = this._bodyPool.get(sk)
157
+ if (free && free.length) {
158
+ const id = free.pop()
159
+ // Dynamic revive: reactivate + wipe stale linear/angular velocity from the piece's PREVIOUS life
160
+ // (live-witnessed carrying over: a body removeBody'd mid-fall at -4.8m/s kept that exact velocity
161
+ // into its next life at a totally different position, a real correctness bug for pooled debris --
162
+ // a freshly "destroyed" piece would otherwise inherit whatever momentum the last occupant of this
163
+ // pool slot happened to have when it despawned). Static/kinematic reuse (terrain colliders, the
164
+ // pool's original use case) is unaffected since motionType there is never 'dynamic'.
165
+ const isDynamic = motionType === 'dynamic'
166
+ this._repositionBody(id, position, opts.rotation, isDynamic ? true : null)
167
+ if (isDynamic) {
168
+ this.setBodyVelocity(id, [0, 0, 0])
169
+ this.setBodyAngularVelocity(id, [0, 0, 0])
170
+ }
171
+ return id
172
+ }
173
+ }
174
+ if (shapeType === 'box') {
175
+ const bk = opts.shapeKey || null
176
+ if (bk && this._shapeCache.has(bk)) shape = this._shapeCache.get(bk)
177
+ else { const cr = Math.min(0.05, Math.min(params[0], params[1], params[2]) * 0.1); const bv = new J.Vec3(params[0], params[1], params[2]); shape = new J.BoxShape(bv, cr, null); J.destroy(bv); if (bk) this._shapeCache.set(bk, shape) }
178
+ }
179
+ else if (shapeType === 'sphere') shape = new J.SphereShape(params)
180
+ else if (shapeType === 'capsule') {
181
+ const ck = opts.shapeKey || null
182
+ if (ck && this._shapeCache.has(ck)) shape = this._shapeCache.get(ck)
183
+ else { shape = new J.CapsuleShape(params[1], params[0]); if (ck) this._shapeCache.set(ck, shape) }
184
+ }
185
+ else if (shapeType === 'convex') {
186
+ // sr must outlive the _addBody call that consumes cvxShape -- see ShapeBuilder.js's buildConvexShape
187
+ // header comment (a real, live-reproduced WASM state-corruption bug found+fixed while wiring
188
+ // destructibles-fractured-glb-shape-wiring's dynamic convex debris bodies).
189
+ const { shape: cvxShape, sr } = buildConvexShape(J, params, this._shapeCache, opts.shapeKey || null)
190
+ const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
191
+ const id = this._addBody(cvxShape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } })
192
+ if (sr) J.destroy(sr)
193
+ return id
194
+ }
195
+ else return null
196
+ const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
197
+ return this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: shapeType } })
198
+ }
199
+
200
+ preallocatePool(shapeType, params, shapeKey, count) {
201
+ if (!this.bodyInterface || !shapeKey || !(count > 0)) return 0
202
+ let free = this._bodyPool.get(shapeKey); if (!free) this._bodyPool.set(shapeKey, free = [])
203
+ const need = count - free.length
204
+ if (need <= 0) return 0
205
+ const ids = []
206
+ for (let i = 0; i < need; i++) {
207
+ const id = this.addBody(shapeType, params, _PARK_POS, 'static', { shapeKey })
208
+ if (id == null) break
209
+ ids.push(id)
210
+ }
211
+ for (const id of ids) { this._repositionBody(id, _PARK_POS, null); free.push(id) }
212
+ return ids.length
213
+ }
214
+
215
+ addConvexBodyAsync(params, position, motionType, opts = {}) {
216
+ const J = this.Jolt, cacheKey = opts.shapeKey || null
217
+ if (cacheKey && this._shapeCache.has(cacheKey)) {
218
+ const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
219
+ return Promise.resolve(this._addBody(this._shapeCache.get(cacheKey), position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } }))
220
+ }
221
+ const result = this._convexQueue.then(() => {
222
+ // sr must outlive the _addBody call below -- see ShapeBuilder.js's buildConvexShape header comment.
223
+ const { shape, sr } = buildConvexShape(J, params, this._shapeCache, cacheKey)
224
+ const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
225
+ const id = this._addBody(shape, position, mt, motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC, { ...opts, meta: { type: motionType, shape: 'convex' } })
226
+ if (sr) J.destroy(sr)
227
+ return id
228
+ })
229
+ this._convexQueue = result.then(() => {}, () => {}); return result
230
+ }
231
+
232
+ // Shape caching/welding: a static trimesh cooked from a GLB (extractAllMeshesFromGLBAsync + Jolt
233
+ // MeshShapeSettings.Create()) is real, measurable per-call cost -- full mesh extraction plus native
234
+ // triangle-list construction -- yet maps commonly place the SAME model many times (rocks, crates,
235
+ // barrels, props). Every prior call re-extracted and re-cooked from scratch even for an identical
236
+ // glbPath+scale pair. Cache key is glbPath+scale (buildTrimeshShape pre-scales vertices into world
237
+ // space, so two different scales of the same model genuinely need two different cooked shapes; a
238
+ // rotation-only difference does NOT, since rotation is applied at the body level via _addBody's
239
+ // BodyCreationSettings, not baked into the shape). The cached Shape is a real Jolt-side ref-counted
240
+ // object (Shape.AddRef/Release/GetRefCount, confirmed in jolt-physics.wasm-compat.d.ts) -- sharing one
241
+ // cooked shape across many bodies is Jolt's own supported "welding" pattern, same trust level as the
242
+ // pre-existing box/capsule/convex shapeKey cache in addBody/buildConvexShape above (which also never
243
+ // destroys a cached shape, relying on Jolt's own refcounting under each BodyCreationSettings/body).
244
+ // In-flight dedupe (_trimeshInflight) additionally prevents two concurrent placements of the same
245
+ // model+scale from racing two independent cook operations before either populates the cache.
246
+ async addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) {
247
+ if (!glbPath) throw new Error('addStaticTrimeshAsync: no glbPath (resolveAssetPath rejected or returned an empty path)')
248
+ const J = this.Jolt
249
+ const key = `${glbPath}|${scale[0]},${scale[1]},${scale[2]}`
250
+ let shape = this._trimeshCache.get(key)
251
+ let srToDestroyAfterFirstUse = null
252
+ if (!shape) {
253
+ let inflight = this._trimeshInflight.get(key)
254
+ if (!inflight) {
255
+ inflight = buildTrimeshShape(J, glbPath, scale).then(built => {
256
+ this._trimeshCache.set(key, built.shape)
257
+ this._trimeshInflight.delete(key)
258
+ return built
259
+ }, err => { this._trimeshInflight.delete(key); throw err })
260
+ this._trimeshInflight.set(key, inflight)
261
+ }
262
+ const built = await inflight
263
+ shape = built.shape
264
+ // Live-witnessed hard rule (WASM "null function or function signature mismatch" crash on the
265
+ // NEXT distinct trimesh add otherwise): the ShapeResult (`sr`) must be destroyed only AFTER a
266
+ // real _addBody call has consumed/reffed the Shape it wraps -- destroying it any earlier (e.g.
267
+ // inside the .then() before the first body exists) corrupts Jolt's WASM state for subsequent
268
+ // shape creation, even though `shape` itself looks like a valid JS object at that point.
269
+ // `built` is the SAME object handed to every concurrent `await inflight` caller (a resolved
270
+ // Promise shares its value, it does not clone it) -- when N callers raced the same fresh key
271
+ // (the in-flight-dedupe case _trimeshInflight exists for), naively checking `built.sr` would
272
+ // have every one of them see it truthy and each call J.destroy(built.sr), a double-destroy of
273
+ // the same native object. Null it out on first claim so only ONE of the N awaiters (whichever
274
+ // microtask runs first, harmless which) actually owns and performs the destroy.
275
+ if (built.sr) { srToDestroyAfterFirstUse = built.sr; built.sr = null }
276
+ }
277
+ const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { rotation, meta: { type: 'static', shape: 'trimesh', shapeKey: key } })
278
+ if (srToDestroyAfterFirstUse) J.destroy(srToDestroyAfterFirstUse)
279
+ return id
280
+ }
281
+
282
+ addHeightField(samples, sampleCount, scale, position) {
283
+ const J = this.Jolt
284
+ const settings = new J.HeightFieldShapeSettings()
285
+ const offset = new J.Vec3(0, 0, 0); settings.set_mOffset(offset); J.destroy(offset)
286
+ const sv = new J.Vec3(scale[0], scale[1], scale[2]); settings.set_mScale(sv); J.destroy(sv)
287
+ settings.set_mSampleCount(sampleCount)
288
+ if (typeof settings.set_mBlockSize === 'function') settings.set_mBlockSize(2)
289
+ const heights = settings.get_mHeightSamples()
290
+ heights.resize(samples.length)
291
+ let bulkOk = false
292
+ if (typeof heights.data === 'function' && typeof J.getPointer === 'function' && J.HEAPF32) {
293
+ const ref = heights.data()
294
+ const ptr = J.getPointer(ref)
295
+ if (ptr) {
296
+ const view = samples instanceof Float32Array ? samples : Float32Array.from(samples)
297
+ J.HEAPF32.set(view, ptr >> 2)
298
+ bulkOk = true
299
+ }
300
+ }
301
+ if (!bulkOk) {
302
+ heights.clear(); heights.reserve(samples.length)
303
+ for (let i = 0; i < samples.length; i++) heights.push_back(samples[i])
304
+ }
305
+ const sr = settings.Create()
306
+ if (!sr.IsValid()) { console.error('[heightfield] shape invalid:', sr.GetError()); J.destroy(settings); J.destroy(sr); return null }
307
+ const shape = sr.Get()
308
+ const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'heightfield' } })
309
+ J.destroy(settings); J.destroy(sr)
310
+ return id
311
+ }
312
+
313
+ addStaticTrimeshFromData(entityId,v,ix,pos,rot=[0,0,0,1]){const J=this.Jolt,tc=ix.length/3,tl=new J.TriangleList(),f3=new J.Float3(0,0,0);tl.resize(tc);for(let t=0;t<tc;t++){const tri=tl.at(t);for(let k=0;k<3;k++){const i=ix[t*3+k];f3.x=v[i*3];f3.y=v[i*3+1];f3.z=v[i*3+2];tri.set_mV(k,f3)}}const ms=new J.MeshShapeSettings(tl),sr=ms.Create();if(!sr.IsValid()){console.error('[trimesh] shape invalid for',entityId,sr.GetError());J.destroy(f3);J.destroy(tl);J.destroy(ms);return null}const shape=sr.Get();J.destroy(f3);J.destroy(tl);const id=this._addBody(shape,pos,J.EMotionType_Static,LAYER_STATIC,{rotation:rot,meta:{type:'static',shape:'trimesh'}});J.destroy(ms);J.destroy(sr);console.log('[trimesh] body created for',entityId,'id='+id,'tris='+tc);return id}
314
+
315
+ addPlayerCharacter(radius, halfHeight, position, mass) { return this._charMgr.addCharacter(radius, halfHeight, position, mass) }
316
+ setCharacterCrouch(id, v) { this._charMgr.setCrouch(id, v) }
317
+ updateCharacter(id, dt) { this._charMgr.update(id, dt) }
318
+ getCharacterPosition(id) { return this._charMgr.getPosition(id) }
319
+ readCharacterPosition(id, out) { this._charMgr.readPosition(id, out) }
320
+ getCharacterVelocity(id) { return this._charMgr.getVelocity(id) }
321
+ readCharacterVelocity(id, out) { this._charMgr.readVelocity(id, out) }
322
+ setCharacterVelocity(id, v) { this._charMgr.setVelocity(id, v) }
323
+ setCharacterPosition(id, p) { this._charMgr.setPosition(id, p) }
324
+ getCharacterGroundState(id) { return this._charMgr.getGroundState(id) }
325
+ removeCharacter(id) { this._charMgr.removeCharacter(id) }
326
+ get characters() { return this._charMgr.characters }
327
+ // Rollback-netcode primitive, character-body half of snapshotBodies/restoreBodies (players use
328
+ // CharacterVirtual, not regular Jolt bodies -- see CharacterManager.js's own snapshotAll/restoreAll
329
+ // header comment for why only position+velocity round-trip).
330
+ snapshotCharacters() { return this._charMgr.snapshotAll() }
331
+ restoreCharacters(snap) { this._charMgr.restoreAll(snap) }
332
+
333
+ _getBody(id) { return this.bodies.get(id) }
334
+ isBodyActive(id) { const b = this._getBody(id); return b ? b.IsActive() : false }
335
+
336
+ syncDynamicBody(bodyId, entity) {
337
+ const b = this._getBody(bodyId); if (!b || !b.IsActive()) return false
338
+ const id = this.bodyIds.get(bodyId), bi = this.bodyInterface
339
+ bi.GetPositionAndRotation(id, this._bulkOutP, this._bulkOutR)
340
+ bi.GetLinearAndAngularVelocity(id, this._bulkOutLV, this._bulkOutAV)
341
+ entity.position[0] = this._bulkOutP.GetX(); entity.position[1] = this._bulkOutP.GetY(); entity.position[2] = this._bulkOutP.GetZ()
342
+ entity.rotation[0] = this._bulkOutR.GetX(); entity.rotation[1] = this._bulkOutR.GetY(); entity.rotation[2] = this._bulkOutR.GetZ(); entity.rotation[3] = this._bulkOutR.GetW()
343
+ entity.velocity[0] = this._bulkOutLV.GetX(); entity.velocity[1] = this._bulkOutLV.GetY(); entity.velocity[2] = this._bulkOutLV.GetZ()
344
+ return true
345
+ }
346
+
347
+ // Rollback-netcode primitive (rollback-netcode-ggpo-style-input-rollback first slice): capture every
348
+ // non-static body's full dynamics state (position, rotation, linear+angular velocity) for later exact
349
+ // restore, the save/rewind half of a GGPO-style save-state -> resimulate-forward loop. Static bodies
350
+ // (terrain, placed props with autoTrimesh, etc) are skipped entirely -- by construction a static body
351
+ // never moves under simulation, so capturing/restoring it is pure waste on every single rollback save,
352
+ // which per this row's own architecture happens on a tight per-tick budget. Uses the SAME
353
+ // GetPositionAndRotation/GetLinearAndAngularVelocity bulk-read convention syncDynamicBody already
354
+ // proved safe every tick in production (see the getBodyPosition/getBodyRotation header comment above
355
+ // for why the two single-field getters are NOT safe to call back-to-back -- this reuses the safe path).
356
+ snapshotBodies() {
357
+ const out = new Map()
358
+ const bi = this.bodyInterface
359
+ for (const [id, meta] of this.bodyMeta) {
360
+ if (meta && meta.type === 'static') continue
361
+ const jid = this.bodyIds.get(id); if (!jid) continue
362
+ bi.GetPositionAndRotation(jid, this._bulkOutP, this._bulkOutR)
363
+ bi.GetLinearAndAngularVelocity(jid, this._bulkOutLV, this._bulkOutAV)
364
+ out.set(id, {
365
+ position: [this._bulkOutP.GetX(), this._bulkOutP.GetY(), this._bulkOutP.GetZ()],
366
+ rotation: [this._bulkOutR.GetX(), this._bulkOutR.GetY(), this._bulkOutR.GetZ(), this._bulkOutR.GetW()],
367
+ velocity: [this._bulkOutLV.GetX(), this._bulkOutLV.GetY(), this._bulkOutLV.GetZ()],
368
+ angularVelocity: [this._bulkOutAV.GetX(), this._bulkOutAV.GetY(), this._bulkOutAV.GetZ()],
369
+ })
370
+ }
371
+ return out
372
+ }
373
+
374
+ // Restores exactly the bodies present in `snap` (a Map from snapshotBodies, or a plain object with the
375
+ // same per-entry shape for a wire-deserialized snapshot). A body present in `snap` but since removed
376
+ // from the live world (removeBody'd between save and rollback -- e.g. a debris piece that despawned) is
377
+ // silently skipped, matching CharacterManager.restoreAll's same-set assumption: a rollback caller always
378
+ // restores against the identical body population it saved, so this is a defensive skip, not a real path.
379
+ // EActivation_Activate: a rolled-back body must be simulating again even if the pre-restore Jolt state
380
+ // happened to have it asleep (a resimulate pass needs every body live for the physics.step() calls that
381
+ // follow, or Jolt will not integrate a sleeping body and the resimulation silently diverges from a truly
382
+ // deterministic replay where that body was awake throughout).
383
+ restoreBodies(snap) {
384
+ const bi = this.bodyInterface, J = this.Jolt
385
+ const entries = snap instanceof Map ? snap.entries() : Object.entries(snap)
386
+ for (const [idKey, s] of entries) {
387
+ const id = typeof idKey === 'number' ? idKey : Number(idKey)
388
+ const jid = this.bodyIds.get(id); if (!jid) continue
389
+ this._bulkOutP.Set(s.position[0], s.position[1], s.position[2])
390
+ this._bulkOutR.Set(s.rotation[0], s.rotation[1], s.rotation[2], s.rotation[3])
391
+ bi.SetPositionAndRotation(jid, this._bulkOutP, this._bulkOutR, J.EActivation_Activate)
392
+ this._bulkOutLV.Set(s.velocity[0], s.velocity[1], s.velocity[2])
393
+ this._bulkOutAV.Set(s.angularVelocity[0], s.angularVelocity[1], s.angularVelocity[2])
394
+ bi.SetLinearAndAngularVelocity(jid, this._bulkOutLV, this._bulkOutAV)
395
+ }
396
+ }
397
+
398
+ // NOTE: routed through GetPositionAndRotation + the pre-allocated, never-destroyed _bulkOutP/_bulkOutR
399
+ // scratch pair (the same buffers syncDynamicBody already used safely), NOT the single-field
400
+ // GetPosition/GetRotation calls the two used to make independently. Real bug found+fixed this session
401
+ // (deterministic-simulation-jolt-fixed-point-rollback probe): calling getBodyPosition(id) then
402
+ // getBodyRotation(id) for the same body in the same tick -- in EITHER order, even across two separate
403
+ // loops over the same body set (not just interleaved per-body) -- crashed with a real, 100% reproducible
404
+ // "RuntimeError: memory access out of bounds" WASM trap, live-isolated down to a single dynamic body,
405
+ // first tick, fresh process (not a multi-world/heap-accumulation artifact). Root cause: GetPosition's and
406
+ // GetRotation's own embind wrappers each return a value via an embind by-value-return convention that,
407
+ // like the already-documented GetAngularVelocity buffer below, is NOT safe to Jolt.destroy() when a sibling
408
+ // getter's return value is live in the same synchronous scope -- calling BOTH getters (each individually
409
+ // safe when called alone, confirmed via a 600-tick isolation run) then destroying either return value
410
+ // corrupts shared WASM-side state the other getter's wrapper also touches. GetPositionAndRotation's own
411
+ // out-param convention was already proven safe under the identical 24-body/600-tick stress (syncDynamicBody
412
+ // uses it every tick in production) -- reusing it here fixes both getters without changing either's public
413
+ // signature or return shape. This is a real fix, not exemption: nothing new is heap-allocated per call to
414
+ // the reused _bulkOutP/_bulkOutR pair, same discipline as getBodyAngularVelocity's no-destroy fix.
415
+ getBodyPosition(id) { const b = this._getBody(id); if (!b) return [0,0,0]; this.bodyInterface.GetPositionAndRotation(b.GetID(), this._bulkOutP, this._bulkOutR); return [this._bulkOutP.GetX(),this._bulkOutP.GetY(),this._bulkOutP.GetZ()] }
416
+ getBodyRotation(id) { const b = this._getBody(id); if (!b) return [0,0,0,1]; this.bodyInterface.GetPositionAndRotation(b.GetID(), this._bulkOutP, this._bulkOutR); return [this._bulkOutR.GetX(),this._bulkOutR.GetY(),this._bulkOutR.GetZ(),this._bulkOutR.GetW()] }
417
+ getBodyVelocity(id) { const b = this._getBody(id); if (!b) return [0,0,0]; const v = this.bodyInterface.GetLinearVelocity(b.GetID()); const r=[v.GetX(),v.GetY(),v.GetZ()]; this.Jolt.destroy(v); return r }
418
+ // NOTE: deliberately does NOT Jolt.destroy() the returned Vec3, unlike every sibling getter above.
419
+ // Live-reproduced real bug (destructibles-debris-lifetime-lod session): BodyInterface.GetAngularVelocity's
420
+ // embind wrapper returns a reference into a Jolt-internal reusable temp buffer (not a fresh heap Vec3 the
421
+ // way GetPosition/GetRotation/GetLinearVelocity's OWN return values behave when called in isolation) --
422
+ // destroying it here, then calling GetLinearVelocity (or GetAngularVelocity again) in the SAME tick during
423
+ // a body's collision-response step, corrupted that shared buffer: a real "RuntimeError: memory access out
424
+ // of bounds" WASM trap, deterministically reproduced at the exact tick a falling body first contacts the
425
+ // ground (collision resolution touches the same internal velocity buffer Jolt is about to hand back out).
426
+ // Isolated via paired probes: GetLinearVelocity-only (destroyed every tick) survives 500 ticks fine;
427
+ // GetAngularVelocity-only (destroyed every tick) ALSO survives fine; only the INTERLEAVED linear+angular
428
+ // sequence in one tick crashes -- and skipping the destroy() on angular's result alone (leaving linear's
429
+ // existing destroy() untouched) fully fixes it. A one-time-per-call skipped destroy on a reused Jolt-side
430
+ // temp buffer is not a real leak (nothing new is allocated per call to begin with).
431
+ getBodyAngularVelocity(id) { const b = this._getBody(id); if (!b || !this.bodyInterface.GetAngularVelocity) return [0,0,0]; const v = this.bodyInterface.GetAngularVelocity(b.GetID()); return [v.GetX(),v.GetY(),v.GetZ()] }
432
+ setBodyFriction(id, f) { const b = this._getBody(id); if (!b || !this.bodyInterface.SetFriction) return false; this.bodyInterface.SetFriction(b.GetID(), f); return true }
433
+ setBodyRestitution(id, r) { const b = this._getBody(id); if (!b || !this.bodyInterface.SetRestitution) return false; this.bodyInterface.SetRestitution(b.GetID(), r); return true }
434
+ setBodyPosition(id, p) { const b = this._getBody(id); if (!b) return; this._tmpRVec3.Set(p[0],p[1],p[2]); this.bodyInterface.SetPosition(b.GetID(), this._tmpRVec3, this.Jolt.EActivation_Activate) }
435
+ // Flip an existing body's Jolt motion type in place (Dynamic<->Kinematic) for the hard-activation-ring
436
+ // 30-100m tier -- reuses the same body/shape rather than destroy+recreate, so a ring crossing is one
437
+ // Jolt call instead of a full shape rebuild. EActivation_DontActivate: caller decides activation separately.
438
+ setBodyMotionType(id, motionType) {
439
+ const b = this._getBody(id); if (!b || !this.bodyInterface.SetMotionType) return false
440
+ const J = this.Jolt
441
+ const mt = motionType === 'dynamic' ? J.EMotionType_Dynamic : motionType === 'kinematic' ? J.EMotionType_Kinematic : J.EMotionType_Static
442
+ this.bodyInterface.SetMotionType(b.GetID(), mt, J.EActivation_DontActivate)
443
+ return true
444
+ }
445
+ // Proximity-priority sleep: put an active body to sleep without destroying it (cheap to reactivate,
446
+ // unlike removeBody which frees the Jolt shape). DeactivateBody fires the same OnBodyDeactivated
447
+ // listener a natural velocity-threshold sleep would, so AppRuntimePhysics' bookkeeping (active/sleeping
448
+ // sets) stays correct via the existing listener, no separate code path needed downstream.
449
+ deactivateBody(id) {
450
+ const b = this._getBody(id); if (!b || !this.bodyInterface.DeactivateBody) return false
451
+ this.bodyInterface.DeactivateBody(b.GetID())
452
+ return true
453
+ }
454
+ setBodyVelocity(id, v) { const b = this._getBody(id); if (!b) return; this._tmpVec3.Set(v[0],v[1],v[2]); this.bodyInterface.SetLinearVelocity(b.GetID(), this._tmpVec3) }
455
+ // Undocumented-in-.d.ts but real, compiled-WASM-confirmed binding (same class of gap as the
456
+ // Vehicle* surface -- see project/vehicles-jolt-constraint-available-not-just-twobody in AGENTS.md).
457
+ // Needed to fully reset a REUSED dynamic body (pooled debris revival): SetLinearVelocity alone leaves
458
+ // stale angular velocity/spin from the body's PREVIOUS life on the pooled Jolt body, since Jolt does
459
+ // not reset angular velocity as a side effect of SetPosition/SetLinearVelocity.
460
+ setBodyAngularVelocity(id, v) { const b = this._getBody(id); if (!b || !this.bodyInterface.SetAngularVelocity) return false; this._tmpVec3.Set(v[0],v[1],v[2]); this.bodyInterface.SetAngularVelocity(b.GetID(), this._tmpVec3); return true }
461
+ addForce(id, f) { const b = this._getBody(id); if (!b) return; this._tmpVec3.Set(f[0],f[1],f[2]); this.bodyInterface.AddForce(b.GetID(), this._tmpVec3) }
462
+ // Optional worldPoint applies the impulse OFF-CENTRE (Jolt AddImpulse(id, impulse, point)) so it
463
+ // imparts spin/torque -- a ball curves, a kick tumbles a prop. Without it the impulse is centre-of-mass.
464
+ addImpulse(id, im, worldPoint) { const b = this._getBody(id); if (!b) return; this._tmpVec3.Set(im[0],im[1],im[2]); if (worldPoint) { this._tmpRVec3.Set(worldPoint[0],worldPoint[1],worldPoint[2]); this.bodyInterface.AddImpulse(b.GetID(), this._tmpVec3, this._tmpRVec3) } else this.bodyInterface.AddImpulse(b.GetID(), this._tmpVec3) }
465
+ setBodyGravityFactor(id, f) { const b = this._getBody(id); if (!b || typeof f !== 'number' || !Number.isFinite(f)) return; this.bodyInterface.SetGravityFactor(b.GetID(), f) }
466
+
467
+ // Join two bodies with a Jolt TwoBodyConstraint. type: 'fixed' (weld -- lock relative transform),
468
+ // 'point' (ball joint -- share a point, free rotation), 'distance' (rigid rod between anchors),
469
+ // 'hinge' (rotate about an axis). anchorA/anchorB are WORLD-space attach points (default both bodies'
470
+ // current positions). Returns a constraintId for removeConstraint, or null if a body is unknown.
471
+ addConstraint(bodyIdA, bodyIdB, opts = {}) {
472
+ if (!this.physicsSystem) return null
473
+ const ba = this._getBody(bodyIdA), bb = this._getBody(bodyIdB)
474
+ if (!ba || !bb) return null
475
+ const J = this.Jolt, type = opts.type || 'fixed'
476
+ const pa = this.bodyInterface.GetPosition(ba.GetID()), pb = this.bodyInterface.GetPosition(bb.GetID())
477
+ const aA = opts.anchorA || [pa.GetX(), pa.GetY(), pa.GetZ()]
478
+ const aB = opts.anchorB || [pb.GetX(), pb.GetY(), pb.GetZ()]
479
+ J.destroy(pa); J.destroy(pb)
480
+ let settings = null
481
+ try {
482
+ if (type === 'point') {
483
+ settings = new J.PointConstraintSettings()
484
+ settings.mSpace = J.EConstraintSpace_WorldSpace
485
+ settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
486
+ } else if (type === 'distance') {
487
+ settings = new J.DistanceConstraintSettings()
488
+ settings.mSpace = J.EConstraintSpace_WorldSpace
489
+ settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
490
+ if (opts.minDistance != null) settings.mMinDistance = opts.minDistance
491
+ if (opts.maxDistance != null) settings.mMaxDistance = opts.maxDistance
492
+ } else if (type === 'hinge') {
493
+ settings = new J.HingeConstraintSettings()
494
+ settings.mSpace = J.EConstraintSpace_WorldSpace
495
+ settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
496
+ const ax = opts.axis || [0, 1, 0]
497
+ settings.mHingeAxis1 = new J.Vec3(ax[0], ax[1], ax[2]); settings.mHingeAxis2 = new J.Vec3(ax[0], ax[1], ax[2])
498
+ settings.mNormalAxis1 = new J.Vec3(1, 0, 0); settings.mNormalAxis2 = new J.Vec3(1, 0, 0)
499
+ } else { // fixed / weld
500
+ settings = new J.FixedConstraintSettings()
501
+ settings.mSpace = J.EConstraintSpace_WorldSpace
502
+ settings.mPoint1 = new J.RVec3(aA[0], aA[1], aA[2]); settings.mPoint2 = new J.RVec3(aB[0], aB[1], aB[2])
503
+ }
504
+ const c = settings.Create(ba, bb)
505
+ this.physicsSystem.AddConstraint(c)
506
+ const cid = (this._nextConstraintId = (this._nextConstraintId || 0) + 1)
507
+ if (!this._constraints) this._constraints = new Map()
508
+ this._constraints.set(cid, c)
509
+ return cid
510
+ } catch (e) { console.error('[physics] addConstraint failed:', e?.message || e); return null }
511
+ finally { if (settings) J.destroy(settings) }
512
+ }
513
+ removeConstraint(constraintId) {
514
+ const c = this._constraints && this._constraints.get(constraintId)
515
+ if (!c || !this.physicsSystem) return false
516
+ this.physicsSystem.RemoveConstraint(c); this.Jolt.destroy(c); this._constraints.delete(constraintId)
517
+ return true
518
+ }
519
+
520
+
521
+ // Vehicle constraint methods (createWheeledVehicle, createTrackedVehicle, driver-input, wheel
522
+ // accessors, removeVehicle) live in VehiclePhysics.js, mixed onto this prototype below the class
523
+ // body -- see that file's header comment for the full WASM-crash-avoidance rationale.
524
+
525
+ enqueueAdd(shapeType, params, position, motionType, opts, onAdded) {
526
+ this._bodyQueue.push({ op: 'add', shapeType, params, position, motionType, opts: opts || {}, onAdded })
527
+ }
528
+
529
+ enqueueRemove(id, force = false) {
530
+ this._bodyQueue.push({ op: 'remove', id, force })
531
+ }
532
+
533
+ // drainBodyQueue must run before physics.step() each tick: adds before removes.
534
+ drainBodyQueue() {
535
+ const q = this._bodyQueue
536
+ if (q.length === 0) return 0
537
+ this._bodyQueue = []
538
+ let applied = 0
539
+ for (let i = 0; i < q.length; i++) {
540
+ const r = q[i]; if (r.op !== 'add') continue
541
+ try { const id = this.addBody(r.shapeType, r.params, r.position, r.motionType, r.opts); if (r.onAdded) r.onAdded(id); applied++ }
542
+ catch (e) { console.error('[physics] queued add error:', e?.message || e); if (r.onAdded) try { r.onAdded(null) } catch (_) {} }
543
+ }
544
+ for (let i = 0; i < q.length; i++) {
545
+ const r = q[i]; if (r.op !== 'remove') continue
546
+ try { this.removeBody(r.id, r.force); applied++ }
547
+ catch (e) { console.error('[physics] queued remove error:', e?.message || e) }
548
+ }
549
+ return applied
550
+ }
551
+
552
+ get bodyQueueLength() { return this._bodyQueue.length }
553
+
554
+ setTrunkColliderIds(set) { return (this._trunkColliderIds = set) }
555
+ getTrunkColliderIds() { return this._trunkColliderIds }
556
+ setRockColliderIds(set) { return (this._rockColliderIds = set) }
557
+ getRockColliderIds() { return this._rockColliderIds }
558
+ setTerrainBodyId(id) { return (this._terrainBodyId = id) }
559
+ getTerrainBodyId() { return this._terrainBodyId ?? null }
560
+ setTerrainHeightSource(fn, frame, offsetY = 0) { this._terrainHeightAt = fn; this._planetFrame = frame; this._terrainOffsetY = offsetY }
561
+ getTerrainHeightFn() { return this._terrainHeightAt }
562
+ getTerrainOffsetY() { return this._terrainOffsetY || 0 }
563
+ terrainHeightAt(x, z) { return typeof this._terrainHeightAt === 'function' ? this._terrainHeightAt(x, z) + (this._terrainOffsetY || 0) : null }
564
+
565
+ // collisionSteps is Jolt's own real Step(deltaTime, inCollisionSteps) sub-stepping parameter --
566
+ // more collision steps per physics tick catch fast-moving bodies that would otherwise tunnel
567
+ // through thin colliders within a single tick's motion. Default stays 2 (unchanged from before
568
+ // this option existed) since quadrupling it unconditionally for every world would be a real,
569
+ // needless per-tick cost for the common case (most bodies are slow enough that 2 is already
570
+ // sufficient) -- a caller with genuinely fast projectiles/characters (the CCD-policy-per-entity-
571
+ // class need this pairs with) passes a higher value explicitly instead.
572
+ step(dt, collisionSteps = 2) { if (this.jolt) this.jolt.Step(dt, collisionSteps) }
573
+
574
+ removeBody(id, force = false) {
575
+ const b = this._getBody(id); if (!b) return
576
+ const sk = !force && this._bodyShapeKey.get(id)
577
+ if (sk) {
578
+ // Force-deactivate a DYNAMIC body on park (see addBody's pool-hit revive comment above for the
579
+ // measured cost of NOT doing this): merely repositioning with DontActivate does not stop an
580
+ // already-active body from continuing to simulate/fall at the park position for however long it
581
+ // sits pooled. Static/kinematic park (the pool's original terrain-collider use case) is unaffected
582
+ // -- those never simulate dynamics regardless of active/inactive state.
583
+ const isDynamic = this.bodyMeta.get(id)?.type === 'dynamic'
584
+ this._repositionBody(id, _PARK_POS, null, isDynamic ? false : null)
585
+ if (isDynamic) { this.setBodyVelocity(id, [0, 0, 0]); this.setBodyAngularVelocity(id, [0, 0, 0]) }
586
+ let free = this._bodyPool.get(sk); if (!free) this._bodyPool.set(sk, free = [])
587
+ free.push(id)
588
+ return
589
+ }
590
+ this.bodyInterface.RemoveBody(b.GetID()); this.bodyInterface.DestroyBody(b.GetID())
591
+ this.bodies.delete(id); this.bodyMeta.delete(id); this.bodyIds.delete(id); this._bodyShapeKey.delete(id)
592
+ }
593
+
594
+ asyncQuery(queries) {
595
+ if (!Array.isArray(queries) || queries.length === 0) return Promise.resolve([])
596
+ return new Promise(resolve => {
597
+ if (!this._asyncQueryQueue) this._asyncQueryQueue = []
598
+ if (!this._asyncQueryResolves) this._asyncQueryResolves = []
599
+ const idx = this._asyncQueryQueue.length
600
+ this._asyncQueryQueue.push(queries)
601
+ this._asyncQueryResolves.push(resolve)
602
+ if (!this._asyncQueryScheduled) {
603
+ this._asyncQueryScheduled = true
604
+ Promise.resolve().then(() => {
605
+ this._asyncQueryScheduled = false
606
+ const batch = this._asyncQueryQueue.splice(0)
607
+ const resolvers = this._asyncQueryResolves.splice(0)
608
+ const results = batch.map(qs => qs.map(q => {
609
+ try {
610
+ return this.raycast(q.origin, q.direction, q.maxDistance || 1000, q.excludeBodyId)
611
+ } catch (e) {
612
+ return { hit: false, distance: q.maxDistance || 1000, body: null, position: null, error: e.message }
613
+ }
614
+ }))
615
+ for (let i = 0; i < resolvers.length; i++) resolvers[i](results[i])
616
+ })
617
+ }
618
+ })
619
+ }
620
+
621
+ raycast(origin, direction, maxDistance = 1000, excludeBodyId = null) {
622
+ if (!this.physicsSystem) return { hit: false, distance: maxDistance, body: null, position: null }
623
+ const J = this.Jolt
624
+ const len = Math.hypot(direction[0], direction[1], direction[2])
625
+ const dir = len > 0 ? [direction[0]/len, direction[1]/len, direction[2]/len] : direction
626
+ const ray = new J.RRayCast(new J.RVec3(origin[0], origin[1], origin[2]), new J.Vec3(dir[0]*maxDistance, dir[1]*maxDistance, dir[2]*maxDistance))
627
+ const rs = new J.RayCastSettings(), col = new J.CastRayClosestHitCollisionCollector()
628
+ const bp = new J.DefaultBroadPhaseLayerFilter(this.jolt.GetObjectVsBroadPhaseLayerFilter(), LAYER_DYNAMIC)
629
+ const ol = new J.DefaultObjectLayerFilter(this.jolt.GetObjectLayerPairFilter(), LAYER_DYNAMIC)
630
+ const eb = excludeBodyId != null ? this._getBody(excludeBodyId) : null
631
+ const bf = eb ? new J.IgnoreSingleBodyFilter(eb.GetID()) : new J.BodyFilter()
632
+ const sf = new J.ShapeFilter()
633
+ this.physicsSystem.GetNarrowPhaseQuery().CastRay(ray, rs, col, bp, ol, bf, sf)
634
+ let result
635
+ if (col.HadHit()) {
636
+ const hit = col.get_mHit()
637
+ const dist = hit.mFraction * maxDistance
638
+ const position = [origin[0]+dir[0]*dist, origin[1]+dir[1]*dist, origin[2]+dir[2]*dist]
639
+ // Resolve the hit body back to a World body id -- the World id IS the Jolt
640
+ // GetIndexAndSequenceNumber() (see addBody), so this keys the same bodyMeta / the runtime's
641
+ // _physicsBodyToEntityId reverse map directly. Callers get an ATTRIBUTED hit (which entity/body),
642
+ // not just a point -- this is the primitive that makes shoot/click-a-target games authorable.
643
+ let bodyId = null, normal = null
644
+ try {
645
+ const bid = hit.mBodyID
646
+ if (bid) bodyId = bid.GetIndexAndSequenceNumber()
647
+ // Surface normal at the hit point (world space), for oriented decals / bounce / aim feedback.
648
+ const b = bodyId != null ? this._getBody(bodyId) : null
649
+ if (b) {
650
+ this._tmpRVec3.Set(position[0], position[1], position[2])
651
+ const n = b.GetWorldSpaceSurfaceNormal(hit.mSubShapeID2, this._tmpRVec3)
652
+ normal = [n.GetX(), n.GetY(), n.GetZ()]
653
+ J.destroy(n)
654
+ }
655
+ } catch (_) { /* normal/body extraction is best-effort; position always returns */ }
656
+ result = { hit: true, distance: dist, body: null, bodyId, normal, position }
657
+ } else result = { hit: false, distance: maxDistance, body: null, bodyId: null, normal: null, position: null }
658
+ J.destroy(ray); J.destroy(rs); J.destroy(col); J.destroy(bp); J.destroy(ol); J.destroy(bf); J.destroy(sf)
659
+ return result
660
+ }
661
+
662
+ destroy() {
663
+ if (!this.Jolt) return
664
+ this._charMgr.destroy()
665
+ if (this._vehicles) for (const [id] of this._vehicles) this.removeVehicle(id)
666
+ for (const [id] of this.bodies) this.removeBody(id, true)
667
+ this._bodyPool.clear(); this._bodyShapeKey.clear()
668
+ this._trimeshCache.clear(); this._trimeshInflight.clear()
669
+ const J = this.Jolt
670
+ if (this._tmpVec3) { J.destroy(this._tmpVec3); this._tmpVec3 = null }
671
+ if (this._tmpRVec3) { J.destroy(this._tmpRVec3); this._tmpRVec3 = null }
672
+ if (this._tmpQuat) { J.destroy(this._tmpQuat); this._tmpQuat = null }
673
+ if (this._bulkOutP) { J.destroy(this._bulkOutP); this._bulkOutP = null }
674
+ if (this._bulkOutR) { J.destroy(this._bulkOutR); this._bulkOutR = null }
675
+ if (this._bulkOutLV) { J.destroy(this._bulkOutLV); this._bulkOutLV = null }
676
+ if (this._bulkOutAV) { J.destroy(this._bulkOutAV); this._bulkOutAV = null }
677
+ if (this.jolt) { J.destroy(this.jolt); this.jolt = null }
678
+ this.physicsSystem = null; this.bodyInterface = null
679
+ }
680
+ }
681
+
682
+ installVehiclePhysics(PhysicsWorld)