@downdraft/engine 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/index.ts +2 -2
- package/core/src/platform/rpc.ts +4 -1
- package/core/src/render/game-renderer.ts +127 -64
- package/core/src/sab/grid-buffer.ts +55 -2
- package/core/src/sab/record-buffer.ts +48 -5
- package/core/src/sab/sim-channel.ts +181 -17
- package/core/src/sab/slot-buffer.ts +55 -1
- package/core/src/sab/types.ts +11 -0
- package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
- package/core/src/util/index.ts +1 -0
- package/core/src/util/session-timer.ts +23 -0
- package/core/src/worker/base-worker-host.ts +2 -1
- package/core/src/worker/buffer-sync.ts +20 -13
- package/core/src/worker/rpc.ts +54 -5
- package/core/src/worker/sim-worker-base.ts +5 -2
- package/core/src/worker/task-pool.ts +4 -0
- package/dist/.dd-manifest.json +861 -851
- package/dist/core/src/index.js +2 -2
- package/dist/core/src/platform/rpc.js +2 -1
- package/dist/core/src/render/game-renderer.js +125 -59
- package/dist/core/src/sab/grid-buffer.js +53 -1
- package/dist/core/src/sab/record-buffer.js +49 -5
- package/dist/core/src/sab/sim-channel.js +173 -17
- package/dist/core/src/sab/slot-buffer.js +54 -1
- package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
- package/dist/core/src/util/index.js +1 -0
- package/dist/core/src/util/session-timer.js +22 -0
- package/dist/core/src/worker/base-worker-host.js +2 -1
- package/dist/core/src/worker/buffer-sync.js +16 -9
- package/dist/core/src/worker/rpc.js +46 -8
- package/dist/core/src/worker/sim-worker-base.js +5 -2
- package/dist/core/src/worker/task-pool.js +5 -0
- package/dist/libraries/devtools/src/backend.js +35 -9
- package/dist/libraries/devtools/src/collectors.js +73 -22
- package/dist/modules/devtools/src/blitz/fonts.js +75 -0
- package/dist/modules/devtools/src/blitz/host.js +124 -30
- package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
- package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
- package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
- package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
- package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
- package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
- package/dist/modules/devtools/src/blitz/theme.js +26 -4
- package/dist/modules/html-ui/src/doc-backend.js +52 -3
- package/dist/modules/html-ui/src/host.js +6 -3
- package/libraries/blitz-ui/native/Cargo.toml +6 -0
- package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
- package/libraries/devtools/src/backend.ts +38 -12
- package/libraries/devtools/src/collectors.ts +75 -22
- package/modules/devtools/src/blitz/fonts.ts +78 -0
- package/modules/devtools/src/blitz/host.ts +107 -31
- package/modules/devtools/src/blitz/panels/console.ts +20 -8
- package/modules/devtools/src/blitz/panels/elements.ts +212 -31
- package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
- package/modules/devtools/src/blitz/panels/performance.ts +24 -12
- package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
- package/modules/devtools/src/blitz/panels/types.ts +2 -1
- package/modules/devtools/src/blitz/theme.ts +26 -4
- package/modules/html-ui/src/doc-backend.ts +67 -4
- package/modules/html-ui/src/host.ts +11 -3
- package/modules/html-ui/src/protocol.ts +1 -1
- package/package.json +2 -2
|
@@ -11,7 +11,7 @@ export const SIM_PLAYER_SLOT_SIZE = 256;
|
|
|
11
11
|
export const SimChannel = defineChannel({
|
|
12
12
|
name: "game-sim",
|
|
13
13
|
magic: 0x53494d42,
|
|
14
|
-
version:
|
|
14
|
+
version: 2,
|
|
15
15
|
mode: "slots",
|
|
16
16
|
header: {
|
|
17
17
|
size: 256,
|
|
@@ -53,6 +53,12 @@ export const SimChannel = defineChannel({
|
|
|
53
53
|
physTimingCcdSolver: { type: "f32" },
|
|
54
54
|
physTimingIslandConstruction: { type: "f32" },
|
|
55
55
|
physTimingUserChanges: { type: "f32" },
|
|
56
|
+
// Presentation-interpolation timing, stamped by incrementTick().
|
|
57
|
+
// publishTimeMs is wall-clock epoch ms (timeOrigin + now) so it
|
|
58
|
+
// compares correctly across the worker/renderer thread boundary —
|
|
59
|
+
// performance.now() epochs differ per thread.
|
|
60
|
+
publishTimeMs: { type: "f64" },
|
|
61
|
+
publishDeltaMs: { type: "f32" },
|
|
56
62
|
},
|
|
57
63
|
},
|
|
58
64
|
sections: [
|
|
@@ -108,10 +114,32 @@ export const SimChannel = defineChannel({
|
|
|
108
114
|
// modifying the core SAB schema.
|
|
109
115
|
},
|
|
110
116
|
},
|
|
117
|
+
// Previous-publish transforms for renderer-side interpolation. The
|
|
118
|
+
// writer snapshots pos+rot here before overwriting a slot, and fills
|
|
119
|
+
// the rest from cur at commit — so prev always equals state(t-1)
|
|
120
|
+
// and cur equals state(t). Same field layout as ENT/PLR transforms.
|
|
121
|
+
{
|
|
122
|
+
name: "entitiesPrev",
|
|
123
|
+
maxSlots: MAX_ENTITIES,
|
|
124
|
+
slotSize: 32,
|
|
125
|
+
fields: {
|
|
126
|
+
pos: { type: "f32", count: 4 },
|
|
127
|
+
rot: { type: "f32", count: 4 },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "playersPrev",
|
|
132
|
+
maxSlots: MAX_PLAYERS,
|
|
133
|
+
slotSize: 32,
|
|
134
|
+
fields: {
|
|
135
|
+
pos: { type: "f32", count: 4 },
|
|
136
|
+
rot: { type: "f32", count: 4 },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
111
139
|
],
|
|
112
140
|
});
|
|
113
141
|
export const SIM_MAGIC = 0x53494d42;
|
|
114
|
-
export const SIM_VERSION =
|
|
142
|
+
export const SIM_VERSION = 2;
|
|
115
143
|
export const SIM_HDR = {
|
|
116
144
|
MAGIC: 0,
|
|
117
145
|
VERSION: 1,
|
|
@@ -153,6 +181,8 @@ export const SIM_HDR = {
|
|
|
153
181
|
PHYS_TIMING_CCD_SOLVER: 37,
|
|
154
182
|
PHYS_TIMING_ISLAND_CONSTRUCTION: 38,
|
|
155
183
|
PHYS_TIMING_USER_CHANGES: 39,
|
|
184
|
+
PUBLISH_TIME_MS: 40, // f64 — occupies u32 words 40–41
|
|
185
|
+
PUBLISH_DELTA_MS: 42,
|
|
156
186
|
};
|
|
157
187
|
export const ENT = {
|
|
158
188
|
POS_X: 0, POS_Y: 1, POS_Z: 2, SCALE: 3,
|
|
@@ -198,6 +228,13 @@ export const PLR_FLAG = {
|
|
|
198
228
|
NOCLIP: 1 << 8,
|
|
199
229
|
GROUNDED: 1 << 9,
|
|
200
230
|
};
|
|
231
|
+
// Field offsets inside the entitiesPrev/playersPrev sections. pos holds
|
|
232
|
+
// [x, y, z, w] where w is entity scale / player heading; rot is the
|
|
233
|
+
// quaternion. Matches ENT.POS_*/ENT.ROT_* and PLR.POS_*/PLR.ROT_* layout.
|
|
234
|
+
export const PREV = {
|
|
235
|
+
POS_X: 0, POS_Y: 1, POS_Z: 2, POS_W: 3,
|
|
236
|
+
ROT_X: 4, ROT_Y: 5, ROT_Z: 6, ROT_W: 7,
|
|
237
|
+
};
|
|
201
238
|
export function allocateSimBuffer() {
|
|
202
239
|
return SimChannel.allocate();
|
|
203
240
|
}
|
|
@@ -209,10 +246,14 @@ export class SimBufferReader {
|
|
|
209
246
|
reader;
|
|
210
247
|
entitySlots;
|
|
211
248
|
playerSlots;
|
|
249
|
+
entityPrevSlots;
|
|
250
|
+
playerPrevSlots;
|
|
212
251
|
constructor(sab) {
|
|
213
252
|
this.reader = SimChannel.reader(sab);
|
|
214
253
|
this.entitySlots = this.reader.sections.entities;
|
|
215
254
|
this.playerSlots = this.reader.sections.players;
|
|
255
|
+
this.entityPrevSlots = this.reader.sections.entitiesPrev;
|
|
256
|
+
this.playerPrevSlots = this.reader.sections.playersPrev;
|
|
216
257
|
}
|
|
217
258
|
isValid() {
|
|
218
259
|
return this.reader.isValid();
|
|
@@ -222,6 +263,17 @@ export class SimBufferReader {
|
|
|
222
263
|
}
|
|
223
264
|
getTick() { return this.reader.header.u32[SimChannel.offsets.header.tick]; }
|
|
224
265
|
getSequence() { return this.reader.getSequence(); }
|
|
266
|
+
/** True while the sim worker is mid-publish (sequence word is odd). */
|
|
267
|
+
isWriteInProgress() { return this.reader.isWriteInProgress(); }
|
|
268
|
+
/**
|
|
269
|
+
* Run `fn` against a consistent snapshot of the sim state — retries
|
|
270
|
+
* while the sim worker is mid-publish or publishes during the read
|
|
271
|
+
* (seqlock). Wrap per-frame read loops (entity iteration, player slot
|
|
272
|
+
* reads) in this to avoid torn reads across a sim commit.
|
|
273
|
+
*/
|
|
274
|
+
readConsistent(fn, maxRetries) {
|
|
275
|
+
return this.reader.readConsistent(fn, maxRetries);
|
|
276
|
+
}
|
|
225
277
|
getEntityCount() { return this.reader.header.u32[SimChannel.offsets.header.entityCount]; }
|
|
226
278
|
getPlayerCount() { return this.reader.header.u32[SimChannel.offsets.header.playerCount]; }
|
|
227
279
|
getTimeOfDay() { return this.reader.header.f32[SimChannel.offsets.header.timeOfDay]; }
|
|
@@ -288,55 +340,105 @@ export class SimBufferReader {
|
|
|
288
340
|
yield { idx: i, f32: sv.f32, u32: sv.u32 };
|
|
289
341
|
}
|
|
290
342
|
}
|
|
343
|
+
// ── Presentation interpolation ──
|
|
344
|
+
// prev = state(t-1) transform, cur = state(t); the renderer lerps by
|
|
345
|
+
// getInterpolationAlpha(). Wall-clock epoch ms (timeOrigin + now) —
|
|
346
|
+
// comparable across the worker/renderer thread boundary.
|
|
347
|
+
getPublishTimeMs() {
|
|
348
|
+
const o = SimChannel.offsets.header;
|
|
349
|
+
return this.reader.header.f64[o.publishTimeMs >> 1];
|
|
350
|
+
}
|
|
351
|
+
getPublishDeltaMs() {
|
|
352
|
+
const o = SimChannel.offsets.header;
|
|
353
|
+
return this.reader.header.f32[o.publishDeltaMs];
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Interpolation alpha in [0,1] for the current frame. 0 = exactly at
|
|
357
|
+
* the last publish, 1 = a full publish interval past it (use cur).
|
|
358
|
+
* Clamped — no extrapolation.
|
|
359
|
+
*/
|
|
360
|
+
getInterpolationAlpha(nowMs) {
|
|
361
|
+
const delta = this.getPublishDeltaMs();
|
|
362
|
+
if (delta <= 0)
|
|
363
|
+
return 1;
|
|
364
|
+
const now = nowMs ?? (performance.timeOrigin ?? 0) + performance.now();
|
|
365
|
+
const a = (now - this.getPublishTimeMs()) / delta;
|
|
366
|
+
return a <= 0 ? 0 : a >= 1 ? 1 : a;
|
|
367
|
+
}
|
|
368
|
+
/** Previous-publish transform for an entity slot (prev pos+rot, PREV.*). */
|
|
369
|
+
getEntityPrevSlot(idx) {
|
|
370
|
+
const sv = this.entityPrevSlots.slot(idx);
|
|
371
|
+
return { f32: sv.f32, u32: sv.u32 };
|
|
372
|
+
}
|
|
373
|
+
/** Previous-publish transform for a player slot (prev pos+rot, PREV.*). */
|
|
374
|
+
getPlayerPrevSlot(idx) {
|
|
375
|
+
const sv = this.playerPrevSlots.slot(idx);
|
|
376
|
+
return { f32: sv.f32, u32: sv.u32 };
|
|
377
|
+
}
|
|
291
378
|
}
|
|
292
379
|
// --- Sim Buffer Writer (sim worker side) ---
|
|
293
380
|
export class SimBufferWriter {
|
|
294
381
|
writer;
|
|
295
382
|
entitySlots;
|
|
296
383
|
playerSlots;
|
|
384
|
+
entityPrevSlots;
|
|
385
|
+
playerPrevSlots;
|
|
297
386
|
// Dirty flags: 1 = entity/player slot has changed since last writeToBuffer.
|
|
298
387
|
// writeToBuffer skips slots where the flag is 0. Cleared after each writeToBuffer.
|
|
299
388
|
dirtyEntities;
|
|
300
389
|
dirtyPlayers;
|
|
390
|
+
// Snapshot flags: 1 = the slot's prev-transform was already captured this
|
|
391
|
+
// publish (on first write access). Cleared at each incrementTick commit.
|
|
392
|
+
snapEntities;
|
|
393
|
+
snapPlayers;
|
|
301
394
|
constructor(sab) {
|
|
302
395
|
this.writer = SimChannel.writer(sab);
|
|
303
396
|
this.entitySlots = this.writer.sections.entities;
|
|
304
397
|
this.playerSlots = this.writer.sections.players;
|
|
398
|
+
this.entityPrevSlots = this.writer.sections.entitiesPrev;
|
|
399
|
+
this.playerPrevSlots = this.writer.sections.playersPrev;
|
|
305
400
|
this.dirtyEntities = new Uint8Array(MAX_ENTITIES);
|
|
306
401
|
this.dirtyPlayers = new Uint8Array(MAX_PLAYERS);
|
|
402
|
+
this.snapEntities = new Uint8Array(MAX_ENTITIES);
|
|
403
|
+
this.snapPlayers = new Uint8Array(MAX_PLAYERS);
|
|
307
404
|
}
|
|
308
405
|
init() {
|
|
309
406
|
const w = this.writer;
|
|
310
407
|
const h = SimChannel.offsets.header;
|
|
408
|
+
w.beginWrite();
|
|
311
409
|
w.header.u32[h.maxEntities] = MAX_ENTITIES;
|
|
312
410
|
w.header.u32[h.maxPlayers] = MAX_PLAYERS;
|
|
313
411
|
w.header.u32[h.entityCount] = 0;
|
|
314
412
|
w.header.u32[h.playerCount] = 0;
|
|
315
413
|
w.header.u32[h.activePlayers] = 0;
|
|
414
|
+
w.endWrite();
|
|
316
415
|
}
|
|
317
|
-
setEntityCount(n) { this.writer.header.u32[SimChannel.offsets.header.entityCount] = n; }
|
|
318
|
-
setPlayerCount(n) { this.writer.header.u32[SimChannel.offsets.header.playerCount] = n; }
|
|
319
|
-
setTimeOfDay(t) { this.writer.header.f32[SimChannel.offsets.header.timeOfDay] = t; }
|
|
416
|
+
setEntityCount(n) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.entityCount] = n; }
|
|
417
|
+
setPlayerCount(n) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.playerCount] = n; }
|
|
418
|
+
setTimeOfDay(t) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.timeOfDay] = t; }
|
|
320
419
|
setWeather(type, intensity) {
|
|
420
|
+
this.writer.beginWrite();
|
|
321
421
|
this.writer.header.u32[SimChannel.offsets.header.weatherType] = type;
|
|
322
422
|
this.writer.header.f32[SimChannel.offsets.header.weatherIntensity] = intensity;
|
|
323
423
|
}
|
|
324
424
|
setWind(speed, dirX, dirZ) {
|
|
425
|
+
this.writer.beginWrite();
|
|
325
426
|
this.writer.header.f32[SimChannel.offsets.header.windSpeed] = speed;
|
|
326
427
|
this.writer.header.f32[SimChannel.offsets.header.windDirX] = dirX;
|
|
327
428
|
this.writer.header.f32[SimChannel.offsets.header.windDirZ] = dirZ;
|
|
328
429
|
}
|
|
329
|
-
setVisibility(v) { this.writer.header.f32[SimChannel.offsets.header.visibility] = v; }
|
|
330
|
-
setAmbientTemp(t) { this.writer.header.f32[SimChannel.offsets.header.ambientTemp] = t; }
|
|
331
|
-
setActivePlayers(mask) { this.writer.header.u32[SimChannel.offsets.header.activePlayers] = mask; }
|
|
332
|
-
setGamemode(mode) { this.writer.header.u32[SimChannel.offsets.header.gamemode] = mode; }
|
|
333
|
-
setPhysicsInitialized(v) { this.writer.header.u32[SimChannel.offsets.header.physicsInitialized] = v; }
|
|
334
|
-
setPhysicsFailed(v) { this.writer.header.u32[SimChannel.offsets.header.physicsFailed] = v; }
|
|
335
|
-
setPhysicsBodyCount(v) { this.writer.header.u32[SimChannel.offsets.header.physicsBodyCount] = v; }
|
|
336
|
-
setPhysicsTickCount(v) { this.writer.header.u32[SimChannel.offsets.header.physicsTickCount] = v; }
|
|
337
|
-
setChunkCount(v) { this.writer.header.u32[SimChannel.offsets.header.chunkCount] = v; }
|
|
338
|
-
setPhysicsProfilerEnabled(v) { this.writer.header.u32[SimChannel.offsets.header.physicsProfilerEnabled] = v; }
|
|
430
|
+
setVisibility(v) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.visibility] = v; }
|
|
431
|
+
setAmbientTemp(t) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.ambientTemp] = t; }
|
|
432
|
+
setActivePlayers(mask) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.activePlayers] = mask; }
|
|
433
|
+
setGamemode(mode) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.gamemode] = mode; }
|
|
434
|
+
setPhysicsInitialized(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsInitialized] = v; }
|
|
435
|
+
setPhysicsFailed(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsFailed] = v; }
|
|
436
|
+
setPhysicsBodyCount(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsBodyCount] = v; }
|
|
437
|
+
setPhysicsTickCount(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsTickCount] = v; }
|
|
438
|
+
setChunkCount(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.chunkCount] = v; }
|
|
439
|
+
setPhysicsProfilerEnabled(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsProfilerEnabled] = v; }
|
|
339
440
|
setPhysicsTiming(data) {
|
|
441
|
+
this.writer.beginWrite();
|
|
340
442
|
const h = this.writer.header.f32;
|
|
341
443
|
const o = SimChannel.offsets.header;
|
|
342
444
|
h[o.physTimingStep] = data.step;
|
|
@@ -356,10 +458,56 @@ export class SimBufferWriter {
|
|
|
356
458
|
h[o.physTimingIslandConstruction] = data.islandConstruction;
|
|
357
459
|
h[o.physTimingUserChanges] = data.userChanges;
|
|
358
460
|
}
|
|
461
|
+
/**
|
|
462
|
+
* Copy the slot's current transform (pos+rot, first 8 f32s) into its
|
|
463
|
+
* prev slot — once per publish, on first write access, so prev always
|
|
464
|
+
* holds the last committed transform for interpolation.
|
|
465
|
+
*/
|
|
466
|
+
snapshotEntityPrev(idx) {
|
|
467
|
+
if (this.snapEntities[idx])
|
|
468
|
+
return;
|
|
469
|
+
this.snapEntities[idx] = 1;
|
|
470
|
+
this.entityPrevSlots.slot(idx).f32.set(this.entitySlots.slot(idx).f32.subarray(0, 8));
|
|
471
|
+
}
|
|
472
|
+
snapshotPlayerPrev(idx) {
|
|
473
|
+
if (this.snapPlayers[idx])
|
|
474
|
+
return;
|
|
475
|
+
this.snapPlayers[idx] = 1;
|
|
476
|
+
this.playerPrevSlots.slot(idx).f32.set(this.playerSlots.slot(idx).f32.subarray(0, 8));
|
|
477
|
+
}
|
|
359
478
|
incrementTick() {
|
|
360
479
|
const h = SimChannel.offsets.header;
|
|
361
|
-
|
|
362
|
-
this
|
|
480
|
+
const w = this.writer;
|
|
481
|
+
// Prev-sync: slots not written this publish keep their old cur, so
|
|
482
|
+
// prev must equal cur (identity lerp). Slots that were written were
|
|
483
|
+
// already snapshotted on first access. Runs inside the open write
|
|
484
|
+
// window, before the commit — readers never see a mixed prev/cur.
|
|
485
|
+
const entCount = Math.min(w.header.u32[h.entityCount], MAX_ENTITIES);
|
|
486
|
+
for (let i = 0; i < entCount; i++) {
|
|
487
|
+
if (!this.snapEntities[i]) {
|
|
488
|
+
this.entityPrevSlots.slot(i).f32.set(this.entitySlots.slot(i).f32.subarray(0, 8));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
this.snapEntities.fill(0);
|
|
492
|
+
const plrCount = Math.min(w.header.u32[h.playerCount], MAX_PLAYERS);
|
|
493
|
+
for (let i = 0; i < plrCount; i++) {
|
|
494
|
+
if (!this.snapPlayers[i]) {
|
|
495
|
+
this.playerPrevSlots.slot(i).f32.set(this.playerSlots.slot(i).f32.subarray(0, 8));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
this.snapPlayers.fill(0);
|
|
499
|
+
// Wall-clock epoch ms — performance.timeOrigin is defined per thread
|
|
500
|
+
// and makes the stamp comparable on the renderer side.
|
|
501
|
+
const now = (performance.timeOrigin ?? 0) + performance.now();
|
|
502
|
+
const tIdx = h.publishTimeMs >> 1; // u32 index 40 → f64 index 20
|
|
503
|
+
const last = w.header.f64[tIdx];
|
|
504
|
+
w.header.f64[tIdx] = now;
|
|
505
|
+
w.header.f32[h.publishDeltaMs] = last > 0 ? now - last : 0;
|
|
506
|
+
Atomics.add(w.header.u32, h.tick, 1);
|
|
507
|
+
// Closes the beginWrite() window opened by the first mutation of this
|
|
508
|
+
// publish (odd→even) — or steps +2 when nothing wrote this tick, so
|
|
509
|
+
// the sequence still advances once per publish for change detection.
|
|
510
|
+
w.bumpSequence();
|
|
363
511
|
}
|
|
364
512
|
markEntityDirty(slot) {
|
|
365
513
|
this.dirtyEntities[slot] = 1;
|
|
@@ -383,15 +531,23 @@ export class SimBufferWriter {
|
|
|
383
531
|
this.dirtyPlayers.fill(1);
|
|
384
532
|
}
|
|
385
533
|
getEntityF32(idx) {
|
|
534
|
+
this.writer.beginWrite();
|
|
535
|
+
this.snapshotEntityPrev(idx);
|
|
386
536
|
return this.entitySlots.slot(idx).f32;
|
|
387
537
|
}
|
|
388
538
|
getEntityU32(idx) {
|
|
539
|
+
this.writer.beginWrite();
|
|
540
|
+
this.snapshotEntityPrev(idx);
|
|
389
541
|
return this.entitySlots.slot(idx).u32;
|
|
390
542
|
}
|
|
391
543
|
getPlayerF32(idx) {
|
|
544
|
+
this.writer.beginWrite();
|
|
545
|
+
this.snapshotPlayerPrev(idx);
|
|
392
546
|
return this.playerSlots.slot(idx).f32;
|
|
393
547
|
}
|
|
394
548
|
getPlayerU32(idx) {
|
|
549
|
+
this.writer.beginWrite();
|
|
550
|
+
this.snapshotPlayerPrev(idx);
|
|
395
551
|
return this.playerSlots.slot(idx).u32;
|
|
396
552
|
}
|
|
397
553
|
}
|
|
@@ -79,6 +79,31 @@ export class SlotReader {
|
|
|
79
79
|
hasChanged(lastSeen) {
|
|
80
80
|
return this.getSequence() !== lastSeen;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* True while the writer holds an open write window (sequence is odd).
|
|
84
|
+
* Readers can use this to cheaply defer sync work past a publish.
|
|
85
|
+
*/
|
|
86
|
+
isWriteInProgress() {
|
|
87
|
+
return (Atomics.load(this.seqArr, 0) & 1) !== 0;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Run `fn` against a consistent snapshot of the channel. The sequence
|
|
91
|
+
* word is a seqlock: odd while the writer is publishing, even when the
|
|
92
|
+
* data is stable. The read is retried while the word is odd or changes
|
|
93
|
+
* mid-read; after maxRetries it returns whatever the last attempt read
|
|
94
|
+
* rather than starving the reader (writers publish every tick).
|
|
95
|
+
*/
|
|
96
|
+
readConsistent(fn, maxRetries = 64) {
|
|
97
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
98
|
+
const s1 = Atomics.load(this.seqArr, 0);
|
|
99
|
+
if ((s1 & 1) === 0) {
|
|
100
|
+
const out = fn();
|
|
101
|
+
if (Atomics.load(this.seqArr, 0) === s1)
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return fn();
|
|
106
|
+
}
|
|
82
107
|
snapshot() {
|
|
83
108
|
const data = {};
|
|
84
109
|
data["sequence"] = this.getSequence();
|
|
@@ -116,6 +141,8 @@ export class SlotWriter {
|
|
|
116
141
|
sections;
|
|
117
142
|
seqArr;
|
|
118
143
|
layout;
|
|
144
|
+
/** True while a beginWrite() window is open (sequence is odd). */
|
|
145
|
+
writeOpen = false;
|
|
119
146
|
constructor(sab, layout) {
|
|
120
147
|
this.layout = layout;
|
|
121
148
|
this.header = createHeaderViews(sab, layout.header.size);
|
|
@@ -131,9 +158,35 @@ export class SlotWriter {
|
|
|
131
158
|
this.header.u32[this.layout.header.magicIndex] = this.layout.magic;
|
|
132
159
|
this.header.u32[this.layout.header.versionIndex] = this.layout.version;
|
|
133
160
|
}
|
|
134
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Open a write window — marks the sequence word odd so readers know a
|
|
163
|
+
* publish is in progress. Idempotent: nested/extra calls are free, so
|
|
164
|
+
* mutating accessors can call it unconditionally on every write path.
|
|
165
|
+
*/
|
|
166
|
+
beginWrite() {
|
|
167
|
+
if (this.writeOpen)
|
|
168
|
+
return;
|
|
169
|
+
this.writeOpen = true;
|
|
135
170
|
Atomics.add(this.seqArr, 0, 1);
|
|
136
171
|
}
|
|
172
|
+
/** Close the write window opened by beginWrite() — sequence back to even. */
|
|
173
|
+
endWrite() {
|
|
174
|
+
if (!this.writeOpen)
|
|
175
|
+
return;
|
|
176
|
+
this.writeOpen = false;
|
|
177
|
+
Atomics.add(this.seqArr, 0, 1);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Publish a commit. Legacy callers use this without a beginWrite()
|
|
181
|
+
* window: it then steps +2 (even→even) so the sequence always lands on
|
|
182
|
+
* a stable value while still advancing once per publish. Inside a
|
|
183
|
+
* beginWrite() window it closes the window (+1, odd→even).
|
|
184
|
+
*/
|
|
185
|
+
bumpSequence() {
|
|
186
|
+
const step = this.writeOpen ? 1 : 2;
|
|
187
|
+
this.writeOpen = false;
|
|
188
|
+
Atomics.add(this.seqArr, 0, step);
|
|
189
|
+
}
|
|
137
190
|
getSequence() {
|
|
138
191
|
return Atomics.load(this.seqArr, 0);
|
|
139
192
|
}
|
|
@@ -131,6 +131,28 @@ export class GPUResourceTracker {
|
|
|
131
131
|
* inform. Enable via constructor for leak-hunting sessions.
|
|
132
132
|
*/
|
|
133
133
|
captureCallsites;
|
|
134
|
+
/**
|
|
135
|
+
* Resources dropped without destroy() are released through the native
|
|
136
|
+
* FinalizationRegistry (platform-native/gpu/registry.ts) — the wrapped
|
|
137
|
+
* destroy() never runs, so without this second registry their tracked
|
|
138
|
+
* entries (and reported VRAM) would grow forever. The held value is the
|
|
139
|
+
* tracked id; the resource object itself is the unregister token.
|
|
140
|
+
*/
|
|
141
|
+
gcRegistry = new FinalizationRegistry((id) => {
|
|
142
|
+
const r = this.resources.get(id);
|
|
143
|
+
if (!r)
|
|
144
|
+
return;
|
|
145
|
+
this.resources.delete(id);
|
|
146
|
+
if (r.type === "texture") {
|
|
147
|
+
this.textureBytes -= r.size;
|
|
148
|
+
this.textureCount--;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.bufferBytes -= r.size;
|
|
152
|
+
this.bufferCount--;
|
|
153
|
+
}
|
|
154
|
+
this.dirty = true;
|
|
155
|
+
});
|
|
134
156
|
constructor(opts = {}) {
|
|
135
157
|
this.captureCallsites = opts.captureCallsites ?? false;
|
|
136
158
|
}
|
|
@@ -160,6 +182,7 @@ export class GPUResourceTracker {
|
|
|
160
182
|
tracker.textureBytes += bytes;
|
|
161
183
|
tracker.textureCount++;
|
|
162
184
|
tracker.dirty = true;
|
|
185
|
+
tracker.gcRegistry.register(tex, id, tex);
|
|
163
186
|
// Wrap destroy to remove from tracking
|
|
164
187
|
const origDestroy = tex.destroy.bind(tex);
|
|
165
188
|
tex.destroy = function () {
|
|
@@ -170,6 +193,7 @@ export class GPUResourceTracker {
|
|
|
170
193
|
tracker.textureCount--;
|
|
171
194
|
tracker.dirty = true;
|
|
172
195
|
}
|
|
196
|
+
tracker.gcRegistry.unregister(tex);
|
|
173
197
|
origDestroy();
|
|
174
198
|
};
|
|
175
199
|
return tex;
|
|
@@ -189,6 +213,7 @@ export class GPUResourceTracker {
|
|
|
189
213
|
tracker.bufferBytes += bytes;
|
|
190
214
|
tracker.bufferCount++;
|
|
191
215
|
tracker.dirty = true;
|
|
216
|
+
tracker.gcRegistry.register(buf, id, buf);
|
|
192
217
|
// Wrap destroy to remove from tracking
|
|
193
218
|
const origDestroy = buf.destroy.bind(buf);
|
|
194
219
|
buf.destroy = function () {
|
|
@@ -199,6 +224,7 @@ export class GPUResourceTracker {
|
|
|
199
224
|
tracker.bufferCount--;
|
|
200
225
|
tracker.dirty = true;
|
|
201
226
|
}
|
|
227
|
+
tracker.gcRegistry.unregister(buf);
|
|
202
228
|
origDestroy();
|
|
203
229
|
};
|
|
204
230
|
return buf;
|
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
export { BroadPhaseGrid } from "./broad-phase-grid.js";
|
|
3
3
|
export { condenseText, decodeFeatureLogLine, encodeFeatureLogJSON, encodeFeatureLogLine, encodeFeatureLogLines, encodeFeatures, FEATURE_LOG_SCHEMA_VERSION, featureCode, formatBytesShort } from "./feature-log.js";
|
|
4
4
|
export { addLogSink, ConsoleLogger, createLogger, getLogTheme, getRecentLogs, setThreadTag } from "./logger.js";
|
|
5
|
+
export { untrackedTimeout } from "./session-timer.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// session-timer.ts — scheduling that survives session teardown
|
|
3
|
+
//
|
|
4
|
+
// Under the native dev shell the SessionTracker wraps global setTimeout:
|
|
5
|
+
// callbacks armed while the session is dead (teardown in flight) self-disarm
|
|
6
|
+
// via WeakRef and never run — correct for session-owned work, fatal for
|
|
7
|
+
// bound/grace-period timers whose whole job is to fire DURING teardown
|
|
8
|
+
// (shutdown-RPC bounds, retire fallbacks, response timeouts). Those must
|
|
9
|
+
// route through the tracker's untrackedTimeout. Outside the dev shell
|
|
10
|
+
// __ddSession is absent and this is a plain setTimeout.
|
|
11
|
+
// ============================================================================
|
|
12
|
+
/**
|
|
13
|
+
* setTimeout that is exempt from dev-session tracking. The returned id pairs
|
|
14
|
+
* with plain clearTimeout (the wrapped clear delegates to the platform clear).
|
|
15
|
+
*/
|
|
16
|
+
export function untrackedTimeout(fn, ms) {
|
|
17
|
+
const tracker = globalThis.__ddSession;
|
|
18
|
+
if (typeof tracker?.untrackedTimeout === "function") {
|
|
19
|
+
return tracker.untrackedTimeout(fn, ms);
|
|
20
|
+
}
|
|
21
|
+
return setTimeout(fn, ms);
|
|
22
|
+
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// ============================================================================
|
|
24
24
|
import { usingRealSAB } from "../sab/sab-polyfill.js";
|
|
25
25
|
import { createLogger } from "../util/logger.js";
|
|
26
|
+
import { untrackedTimeout } from "../util/session-timer.js";
|
|
26
27
|
import { wrap } from "./rpc.js";
|
|
27
28
|
const log = createLogger();
|
|
28
29
|
export class BaseWorkerHost {
|
|
@@ -155,7 +156,7 @@ export class BaseWorkerHost {
|
|
|
155
156
|
await Promise.race([
|
|
156
157
|
this.proxy.proxy.shutdown(),
|
|
157
158
|
new Promise((_, rej) => {
|
|
158
|
-
timer =
|
|
159
|
+
timer = untrackedTimeout(() => rej(new Error("shutdown RPC timed out")), 2_000);
|
|
159
160
|
}),
|
|
160
161
|
]);
|
|
161
162
|
}
|
|
@@ -360,10 +360,11 @@ export class BufferSyncWorker {
|
|
|
360
360
|
len = Math.min(r.length, count * r.lengthMultiplier);
|
|
361
361
|
}
|
|
362
362
|
// Chunked transfer: if the region is large, split into chunks
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// and postMessage structured
|
|
363
|
+
// sent as separate postMessages so the main thread interleaves
|
|
364
|
+
// receive+apply tasks with rAF/render instead of blocking on one
|
|
365
|
+
// giant structured clone. This prevents 100-900ms main-thread
|
|
366
|
+
// blocks where SAB is unavailable (and postMessage structured
|
|
367
|
+
// clone is ~10MB/s).
|
|
367
368
|
const CHUNK_SIZE = 64 * 1024; // 64KB per chunk — small enough to avoid >50ms blocks
|
|
368
369
|
if (len > CHUNK_SIZE) {
|
|
369
370
|
const numChunks = Math.ceil(len / CHUNK_SIZE);
|
|
@@ -401,10 +402,16 @@ export class BufferSyncWorker {
|
|
|
401
402
|
// main thread to render between them.
|
|
402
403
|
const allCopies = Object.values(regions).flat();
|
|
403
404
|
if (allCopies.length > 1 && totalBytes > 256 * 1024) {
|
|
404
|
-
// Large transfer: send chunks
|
|
405
|
-
//
|
|
406
|
-
// metadata — the receiver buffers parts and
|
|
407
|
-
// once all arrive, so the buffer never
|
|
405
|
+
// Large transfer: send chunks as separate postMessages so the main
|
|
406
|
+
// thread interleaves receive+apply tasks with rAF/render. Each
|
|
407
|
+
// message carries batch metadata — the receiver buffers parts and
|
|
408
|
+
// applies them atomically once all arrive, so the buffer never
|
|
409
|
+
// renders partially-updated. A setTimeout(0) between posts yields
|
|
410
|
+
// the worker's macrotask queue once per chunk — letting urgent
|
|
411
|
+
// worker-side messages (RPC responses, other sync traffic) insert
|
|
412
|
+
// between chunks rather than queueing behind the whole batch — but
|
|
413
|
+
// adds no fixed delay: measured delivery was ~16ms/chunk with a
|
|
414
|
+
// 16ms gap (~1s for a 4MB region) vs sub-ms/chunk now.
|
|
408
415
|
// Precompute chunk→buffer ownership once (was O(chunks×regions) before).
|
|
409
416
|
const chunkOwner = [];
|
|
410
417
|
for (const [name, regionList] of Object.entries(regions)) {
|
|
@@ -423,7 +430,7 @@ export class BufferSyncWorker {
|
|
|
423
430
|
chunkIdx++;
|
|
424
431
|
self.postMessage({ __bufferSync: true, regions: batchRegions, batch: { id: batchId, index: chunkIdx - 1, total } }, [copy.data]);
|
|
425
432
|
if (chunkIdx < total) {
|
|
426
|
-
setTimeout(sendNextChunk,
|
|
433
|
+
setTimeout(sendNextChunk, 0); // yield the macrotask queue between chunk posts
|
|
427
434
|
}
|
|
428
435
|
};
|
|
429
436
|
sendNextChunk();
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Works in both Web Workers and Node.js worker_threads.
|
|
5
5
|
// ============================================================================
|
|
6
6
|
import { createLogger } from "../util/logger.js";
|
|
7
|
+
import { untrackedTimeout } from "../util/session-timer.js";
|
|
7
8
|
const log = createLogger();
|
|
8
9
|
// In Node.js/Bun worker_threads, `parentPort` is non-null only inside a
|
|
9
10
|
// worker connected to a parent — its presence IS the worker check. (The old
|
|
@@ -63,6 +64,9 @@ function isRpcResponse(msg) {
|
|
|
63
64
|
function isEventMessage(msg) {
|
|
64
65
|
return msg && msg.__event === true;
|
|
65
66
|
}
|
|
67
|
+
function isEventBatchMessage(msg) {
|
|
68
|
+
return msg && msg.__eventBatch === true;
|
|
69
|
+
}
|
|
66
70
|
export function expose(api, options) {
|
|
67
71
|
const h = options?.host ?? getWorkerHost();
|
|
68
72
|
h.onHostMessage(async (msg) => {
|
|
@@ -90,6 +94,37 @@ export function exposeEvents(host) {
|
|
|
90
94
|
},
|
|
91
95
|
};
|
|
92
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Event emitter that coalesces emit() calls into a single `__eventBatch`
|
|
99
|
+
* postMessage per macrotask. A sim emitting per-tick or per-entity events
|
|
100
|
+
* otherwise produces one structured clone + one main-thread task per event,
|
|
101
|
+
* and the task burst competes with rAF. Events emitted during a tick batch
|
|
102
|
+
* (the worker never yields to timers mid-tick) all land in one message;
|
|
103
|
+
* events emitted between ticks flush on the next macrotask, so latency for
|
|
104
|
+
* isolated events is unchanged.
|
|
105
|
+
*/
|
|
106
|
+
export function exposeBatchedEvents(host) {
|
|
107
|
+
const h = host ?? getWorkerHost();
|
|
108
|
+
let queue = [];
|
|
109
|
+
let flushScheduled = false;
|
|
110
|
+
const flush = () => {
|
|
111
|
+
flushScheduled = false;
|
|
112
|
+
if (queue.length === 0)
|
|
113
|
+
return;
|
|
114
|
+
h.postToHost({ __eventBatch: true, events: queue });
|
|
115
|
+
queue = [];
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
emit: (kind, data) => {
|
|
119
|
+
queue.push({ kind, data });
|
|
120
|
+
if (!flushScheduled) {
|
|
121
|
+
flushScheduled = true;
|
|
122
|
+
setTimeout(flush, 0);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
flush,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
93
128
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
94
129
|
export function wrap(worker, options) {
|
|
95
130
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -114,13 +149,16 @@ export function wrap(worker, options) {
|
|
|
114
149
|
p.resolve(msg.result);
|
|
115
150
|
return;
|
|
116
151
|
}
|
|
117
|
-
if (isEventMessage(msg)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
if (isEventMessage(msg) || isEventBatchMessage(msg)) {
|
|
153
|
+
const events = isEventBatchMessage(msg) ? msg.events : [msg];
|
|
154
|
+
for (const ev of events) {
|
|
155
|
+
for (const cb of eventListeners.values()) {
|
|
156
|
+
try {
|
|
157
|
+
cb(ev.kind, ev.data);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
log.error("worker-proxy", `Event listener error: ${err}`);
|
|
161
|
+
}
|
|
124
162
|
}
|
|
125
163
|
}
|
|
126
164
|
}
|
|
@@ -172,7 +210,7 @@ export function wrap(worker, options) {
|
|
|
172
210
|
const id = ++reqId;
|
|
173
211
|
const entry = { resolve, reject };
|
|
174
212
|
if (timeoutMs > 0) {
|
|
175
|
-
entry.timer =
|
|
213
|
+
entry.timer = untrackedTimeout(() => {
|
|
176
214
|
if (pending.delete(id)) {
|
|
177
215
|
reject(new Error(`RPC '${method}' timed out after ${timeoutMs}ms`));
|
|
178
216
|
}
|
|
@@ -38,7 +38,7 @@ import "../sab/sab-polyfill.js";
|
|
|
38
38
|
import { usingRealSAB } from "../sab/sab-polyfill.js";
|
|
39
39
|
import { mulberry32 } from "../math/rng.js";
|
|
40
40
|
import { createLogger } from "../util/logger.js";
|
|
41
|
-
import { expose,
|
|
41
|
+
import { expose, exposeBatchedEvents } from "./rpc.js";
|
|
42
42
|
const log = createLogger();
|
|
43
43
|
/**
|
|
44
44
|
* Detect deterministic/test mode inside a worker. `DOWNDRAFT_DETERMINISTIC`
|
|
@@ -158,7 +158,7 @@ export function createSimWorker(opts) {
|
|
|
158
158
|
let tickInFlight = null;
|
|
159
159
|
// Serializes withLoopStopped critical sections.
|
|
160
160
|
let stopChain = Promise.resolve();
|
|
161
|
-
const events =
|
|
161
|
+
const events = exposeBatchedEvents();
|
|
162
162
|
// Deterministic RNG — reseedable via control.setSeed(); the stable `rng`
|
|
163
163
|
// closure is what ctx.rng and the Math.random shim draw from.
|
|
164
164
|
let rngImpl = mulberry32(opts.seed ?? 0x9e3779b9);
|
|
@@ -423,6 +423,9 @@ export function createSimWorker(opts) {
|
|
|
423
423
|
syncWorker = null; // release sync worker reference
|
|
424
424
|
saveStore = null;
|
|
425
425
|
await opts.onShutdown?.();
|
|
426
|
+
// Drain any events queued during onShutdown so they aren't lost to
|
|
427
|
+
// a flush timer the terminating worker never runs.
|
|
428
|
+
events.flush();
|
|
426
429
|
},
|
|
427
430
|
async setSpeed(speed) {
|
|
428
431
|
speedMultiplier = Math.min(opts.maxSpeed ?? Infinity, Math.max(opts.minSpeed ?? 0, speed));
|
|
@@ -78,6 +78,11 @@ export class TaskPool {
|
|
|
78
78
|
for (let i = 0; i < this.workerCount; i++) {
|
|
79
79
|
const worker = this.createWorker();
|
|
80
80
|
worker.onerror = (e) => {
|
|
81
|
+
// Session teardown tags tracked workers __ddExpectTerminate and
|
|
82
|
+
// fires synthetic error events to settle waiters — not faults
|
|
83
|
+
// (see session-tracker.ts terminateTrackedWorker).
|
|
84
|
+
if (worker.__ddExpectTerminate || this.destroyed)
|
|
85
|
+
return;
|
|
81
86
|
if (this.onWorkerError)
|
|
82
87
|
this.onWorkerError(i, e);
|
|
83
88
|
else
|