@irtio/client 0.5.2 → 0.7.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/dist/{physics-H2VDQLAU.js → chunk-DABQDR3S.js} +417 -269
- package/dist/{chunk-7UTJ7RSF.js → chunk-XWVXZRBS.js} +73 -8
- package/dist/index.d.ts +1496 -179
- package/dist/index.js +1417 -47
- package/dist/physics-RT5T36P5.js +219 -0
- package/dist/physics2d-HOFMWPZV.js +255 -0
- package/package.json +15 -6
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MAX_PREDICTED_BODIES,
|
|
3
|
+
MAX_PROXY_BODIES,
|
|
3
4
|
PREDICTION_EPSILON,
|
|
4
5
|
RESIM_DEPTH,
|
|
5
6
|
SMOOTHING_HALF_LIFE_MS,
|
|
6
7
|
SMOOTHING_SNAP_UNITS
|
|
7
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-XWVXZRBS.js";
|
|
8
9
|
|
|
9
|
-
// src/
|
|
10
|
+
// src/predictor.ts
|
|
10
11
|
var MAX_FREE_STEPS_PER_FRAME = 5;
|
|
11
12
|
var FREE_RUN_GRACE = 0.5;
|
|
12
13
|
var POSE_RING = 4;
|
|
@@ -14,6 +15,7 @@ var LAG_TARGET_MIN = 0.7;
|
|
|
14
15
|
var LAG_TARGET_MAX = 1.4;
|
|
15
16
|
var CLOCK_TRIM = 0.12;
|
|
16
17
|
var GAP_ALPHA = 0.3;
|
|
18
|
+
var MAX_LEAD = 40;
|
|
17
19
|
function emptyPose() {
|
|
18
20
|
return {
|
|
19
21
|
t: { x: 0, y: 0, z: 0 },
|
|
@@ -22,25 +24,6 @@ function emptyPose() {
|
|
|
22
24
|
w: { x: 0, y: 0, z: 0 }
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
|
-
function readPose(body, into) {
|
|
26
|
-
const t = body.translation();
|
|
27
|
-
const r = body.rotation();
|
|
28
|
-
const v = body.linvel();
|
|
29
|
-
const w = body.angvel();
|
|
30
|
-
into.t.x = t.x;
|
|
31
|
-
into.t.y = t.y;
|
|
32
|
-
into.t.z = t.z;
|
|
33
|
-
into.r.x = r.x;
|
|
34
|
-
into.r.y = r.y;
|
|
35
|
-
into.r.z = r.z;
|
|
36
|
-
into.r.w = r.w;
|
|
37
|
-
into.v.x = v.x;
|
|
38
|
-
into.v.y = v.y;
|
|
39
|
-
into.v.z = v.z;
|
|
40
|
-
into.w.x = w.x;
|
|
41
|
-
into.w.y = w.y;
|
|
42
|
-
into.w.z = w.z;
|
|
43
|
-
}
|
|
44
27
|
function copyPose(from, into) {
|
|
45
28
|
into.t.x = from.t.x;
|
|
46
29
|
into.t.y = from.t.y;
|
|
@@ -125,42 +108,11 @@ function poseAt(entry, tick) {
|
|
|
125
108
|
function key(collection, id) {
|
|
126
109
|
return `${collection}\0${id}`;
|
|
127
110
|
}
|
|
128
|
-
var
|
|
129
|
-
|
|
130
|
-
async function loadEngine() {
|
|
131
|
-
if (engine) return engine;
|
|
132
|
-
loading ??= (async () => {
|
|
133
|
-
const mod = await import("@dimforge/rapier3d-compat");
|
|
134
|
-
const ns = mod.default ?? mod;
|
|
135
|
-
await ns.init();
|
|
136
|
-
engine = ns;
|
|
137
|
-
return ns;
|
|
138
|
-
})();
|
|
139
|
-
return loading;
|
|
140
|
-
}
|
|
141
|
-
function planarLockWarning(spec) {
|
|
142
|
-
const boxed = (spec.colliders ?? []).some(
|
|
143
|
-
(c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
|
|
144
|
-
);
|
|
145
|
-
if (!boxed) return void 0;
|
|
146
|
-
const b = spec.body;
|
|
147
|
-
const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
|
|
148
|
-
const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
|
|
149
|
-
const axes = ["x", "y", "z"];
|
|
150
|
-
for (let i = 0; i < 3; i++) {
|
|
151
|
-
if (t[i]) continue;
|
|
152
|
-
if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
|
|
153
|
-
const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
|
|
154
|
-
return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
|
|
155
|
-
}
|
|
156
|
-
return void 0;
|
|
157
|
-
}
|
|
158
|
-
var CUBOID_SHAPE = 1;
|
|
159
|
-
var ROUND_CUBOID_SHAPE = 12;
|
|
160
|
-
var PhysicsPredictor = class _PhysicsPredictor {
|
|
161
|
-
constructor(ext, store, options, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
|
|
111
|
+
var Predictor = class _Predictor {
|
|
112
|
+
constructor(ext, store, adapter, tuning, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
|
|
162
113
|
this.store = store;
|
|
163
|
-
this.
|
|
114
|
+
this.adapter = adapter;
|
|
115
|
+
this.tuning = tuning;
|
|
164
116
|
this.meOf = meOf;
|
|
165
117
|
this.rttOf = rttOf;
|
|
166
118
|
this.tickIntervalOf = tickIntervalOf;
|
|
@@ -170,7 +122,8 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
170
122
|
);
|
|
171
123
|
}
|
|
172
124
|
store;
|
|
173
|
-
|
|
125
|
+
adapter;
|
|
126
|
+
tuning;
|
|
174
127
|
meOf;
|
|
175
128
|
rttOf;
|
|
176
129
|
tickIntervalOf;
|
|
@@ -182,18 +135,33 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
182
135
|
snaps: 0,
|
|
183
136
|
suppressed: 0,
|
|
184
137
|
overCap: 0,
|
|
138
|
+
proxies: 0,
|
|
139
|
+
absent: 0,
|
|
185
140
|
lastResimMicros: 0,
|
|
186
141
|
smoothing: 0,
|
|
187
|
-
stampGap: 0
|
|
142
|
+
stampGap: 0,
|
|
143
|
+
stampGapRaw: 0,
|
|
144
|
+
lastStampTick: 0,
|
|
145
|
+
lastAppliedTick: 0
|
|
188
146
|
};
|
|
189
|
-
|
|
190
|
-
|
|
147
|
+
/** True once the adapter's world exists. The `world !== undefined` of the one-engine version. */
|
|
148
|
+
worldReady = false;
|
|
149
|
+
/** Seconds per step, fixed for the life of the world. */
|
|
150
|
+
timestepSeconds;
|
|
191
151
|
bodies = /* @__PURE__ */ new Map();
|
|
192
|
-
/**
|
|
152
|
+
/** D71: kinematic proxies, keyed like `bodies`. The two maps are disjoint by construction. */
|
|
153
|
+
proxies = /* @__PURE__ */ new Map();
|
|
154
|
+
/** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
|
|
193
155
|
collections;
|
|
194
156
|
warned = /* @__PURE__ */ new Set();
|
|
195
157
|
/** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
|
|
196
158
|
overCapHigh = /* @__PURE__ */ new Map();
|
|
159
|
+
/** The same, for the proxy cap: `absent` is the count a game has to act on (D71-d). */
|
|
160
|
+
absentHigh = /* @__PURE__ */ new Map();
|
|
161
|
+
/** D71: where a proxy goes. Wired by the session; see {@link DrawnReader}. */
|
|
162
|
+
drawnReader;
|
|
163
|
+
/** `drawnReader`'s output buffer, reused: it is called once per proxy per frame. */
|
|
164
|
+
drawnChannels = {};
|
|
197
165
|
/** Authority arrived since the last frame: rebase before free-running. */
|
|
198
166
|
authorityDirty = false;
|
|
199
167
|
accumulatorMs = 0;
|
|
@@ -217,6 +185,8 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
217
185
|
renderTick;
|
|
218
186
|
/** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
|
|
219
187
|
scratch = emptyPose();
|
|
188
|
+
/** Scratch for a direct body reading, so it never races `scratch`'s interpolated one. */
|
|
189
|
+
live = emptyPose();
|
|
220
190
|
/** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
|
|
221
191
|
qa = { x: 0, y: 0, z: 0, w: 1 };
|
|
222
192
|
qb = { x: 0, y: 0, z: 0, w: 1 };
|
|
@@ -235,45 +205,85 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
235
205
|
gapMeasured = false;
|
|
236
206
|
/** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
|
|
237
207
|
gapSampledThrough = 0;
|
|
208
|
+
/** The lead the last rebase re-stepped, so a change in it can be folded into `stampGap`. */
|
|
209
|
+
lastLead;
|
|
238
210
|
predictedTicks = /* @__PURE__ */ new Map();
|
|
239
211
|
history = /* @__PURE__ */ new Map();
|
|
240
|
-
/**
|
|
241
|
-
|
|
212
|
+
/**
|
|
213
|
+
* History kept per body: a correction for tick T is judged against the prediction recorded for
|
|
214
|
+
* T, and T is `lead` ticks behind the head when it arrives, so this has to clear `MAX_LEAD`
|
|
215
|
+
* with room for delivery jitter.
|
|
216
|
+
*/
|
|
217
|
+
static HISTORY_TICKS = MAX_LEAD + 24;
|
|
218
|
+
/**
|
|
219
|
+
* D50: re-derive the predicted-collection list from a rebuilt schema.
|
|
220
|
+
*
|
|
221
|
+
* This is deliberately shallow, and it is safe to be shallow because of the scope rule the
|
|
222
|
+
* supervisor enforces: **any change touching a physics collection classifies breaking**
|
|
223
|
+
* (`packages/supervisor/src/schema-swap.ts`), so a swap that reaches this method is guaranteed
|
|
224
|
+
* to leave every physics collection structurally identical. What changes is descriptor
|
|
225
|
+
* *identity*, not content, and this brings the predictor's references back in line with the
|
|
226
|
+
* store's rather than leaving two equal-but-distinct descriptor trees in play.
|
|
227
|
+
*
|
|
228
|
+
* The live bodies are dropped instead of retargeted, for the same reason the render buffers
|
|
229
|
+
* are: `reset()` follows immediately, the resync WELCOME re-seeds authority, and a body carried
|
|
230
|
+
* across a schema boundary is exactly the kind of thing that would look fine and be wrong.
|
|
231
|
+
*
|
|
232
|
+
* When the physics scope rule is lifted, this is where the real work goes: rebuilding each
|
|
233
|
+
* `PredictedBody.desc` and re-deriving collider shapes from the new descriptors.
|
|
234
|
+
*/
|
|
235
|
+
swapSchema(newExt) {
|
|
236
|
+
this.collections = newExt.collections.filter(
|
|
237
|
+
(c) => c.kind === "entity" && c.physics !== void 0
|
|
238
|
+
);
|
|
239
|
+
for (const entry of this.bodies.values()) this.adapter.removeBody(entry.body);
|
|
240
|
+
this.bodies.clear();
|
|
241
|
+
for (const entry of this.proxies.values()) this.adapter.removeBody(entry.body);
|
|
242
|
+
this.proxies.clear();
|
|
243
|
+
this.reset();
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* D71: tell the predictor where its proxies go. The session calls this once, with a reader over
|
|
247
|
+
* the D20 interpolation buffer; without it a proxy holds the pose it was built at.
|
|
248
|
+
*/
|
|
249
|
+
setDrawnReader(reader) {
|
|
250
|
+
this.drawnReader = reader;
|
|
251
|
+
}
|
|
242
252
|
get epsilon() {
|
|
243
|
-
return this.
|
|
253
|
+
return this.tuning.epsilon ?? PREDICTION_EPSILON;
|
|
244
254
|
}
|
|
245
255
|
/** `true` once the engine is loaded and the local world exists. */
|
|
246
256
|
get ready() {
|
|
247
|
-
return this.
|
|
257
|
+
return this.worldReady;
|
|
248
258
|
}
|
|
249
259
|
/**
|
|
250
260
|
* Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
|
|
251
261
|
* interpolation/authority — joining is never blocked on 2.9 MB of WASM.
|
|
252
262
|
*/
|
|
253
263
|
start() {
|
|
254
|
-
if (this.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (this.options.setup) this.options.setup(world, rapier);
|
|
262
|
-
this.world = world;
|
|
264
|
+
if (this.worldReady || this.failed) return Promise.resolve();
|
|
265
|
+
const tickMs = this.tickIntervalOf();
|
|
266
|
+
const dt = this.adapter.timestep ?? (tickMs > 0 ? tickMs / 1e3 : 1 / 30);
|
|
267
|
+
return this.adapter.start(dt).then(
|
|
268
|
+
() => {
|
|
269
|
+
this.timestepSeconds = dt;
|
|
270
|
+
this.worldReady = true;
|
|
263
271
|
this.authorityDirty = true;
|
|
264
272
|
},
|
|
265
273
|
(err) => {
|
|
266
274
|
this.failed = true;
|
|
267
275
|
this.log(
|
|
268
|
-
`irtio: physics prediction disabled \u2014
|
|
276
|
+
`irtio: physics prediction disabled \u2014 ${this.adapter.engineName} failed to load: ${err instanceof Error ? err.message : String(err)}`
|
|
269
277
|
);
|
|
270
278
|
}
|
|
271
279
|
);
|
|
272
280
|
}
|
|
273
281
|
free() {
|
|
274
282
|
this.bodies.clear();
|
|
275
|
-
this.
|
|
276
|
-
this.
|
|
283
|
+
this.proxies.clear();
|
|
284
|
+
this.stats.proxies = 0;
|
|
285
|
+
this.adapter.free();
|
|
286
|
+
this.worldReady = false;
|
|
277
287
|
}
|
|
278
288
|
/** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
|
|
279
289
|
reset() {
|
|
@@ -290,7 +300,9 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
290
300
|
this.stampGap = 0;
|
|
291
301
|
this.gapMeasured = false;
|
|
292
302
|
this.gapSampledThrough = 0;
|
|
303
|
+
this.lastLead = void 0;
|
|
293
304
|
this.stats.stampGap = 0;
|
|
305
|
+
this.stats.stampGapRaw = 0;
|
|
294
306
|
}
|
|
295
307
|
/**
|
|
296
308
|
* Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one).
|
|
@@ -321,30 +333,54 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
321
333
|
* write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
|
|
322
334
|
* authority is on the session's bare write counter rather than the server's tick stream, and
|
|
323
335
|
* differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
|
|
336
|
+
*
|
|
337
|
+
* The floor is also load-bearing in a way that is not obvious, and `bugs.md` #45 is the
|
|
338
|
+
* measurement: a stamp can *trail* the server's application, when the lead over-estimates the
|
|
339
|
+
* transit, and correcting for that by letting the gap go negative delays every write's replay by
|
|
340
|
+
* the same amount. It buys an accurate stop and pays for it with a late start. The lever for
|
|
341
|
+
* that is the lead, not this.
|
|
324
342
|
*/
|
|
325
343
|
noteWriteApplied(stampTick, appliedTick) {
|
|
326
344
|
if (stampTick <= 0 || appliedTick <= 0 || stampTick <= this.gapSampledThrough) return;
|
|
327
345
|
this.gapSampledThrough = stampTick;
|
|
328
|
-
const
|
|
346
|
+
const raw = stampTick - appliedTick;
|
|
347
|
+
const sample = Math.min(RESIM_DEPTH, Math.max(0, raw));
|
|
329
348
|
this.stampGap = this.gapMeasured ? this.stampGap + (sample - this.stampGap) * GAP_ALPHA : sample;
|
|
349
|
+
this.stats.stampGapRaw = this.gapMeasured ? this.stats.stampGapRaw + (raw - this.stats.stampGapRaw) * GAP_ALPHA : raw;
|
|
330
350
|
this.gapMeasured = true;
|
|
331
351
|
this.stats.stampGap = this.stampGap;
|
|
352
|
+
this.stats.lastStampTick = stampTick;
|
|
353
|
+
this.stats.lastAppliedTick = appliedTick;
|
|
332
354
|
}
|
|
333
|
-
/**
|
|
355
|
+
/**
|
|
356
|
+
* Does the local world currently **simulate** `collection[id]`?
|
|
357
|
+
*
|
|
358
|
+
* Deliberately still false for a proxied instance (D71). Every caller of this asks it to decide
|
|
359
|
+
* whether the local world is the better answer than authority — the render read path, the
|
|
360
|
+
* correction classifier, `room.prediction.predicts` — and for a proxy it is not: a proxy is
|
|
361
|
+
* authority, one interpolation delay old, put into the world so that other bodies can touch it.
|
|
362
|
+
* Reading it back would be a round trip through the physics engine to learn what the render
|
|
363
|
+
* buffer already said. {@link proxied} is the question about proxies.
|
|
364
|
+
*/
|
|
334
365
|
has(collection, id) {
|
|
335
366
|
return this.bodies.has(key(collection, id));
|
|
336
367
|
}
|
|
368
|
+
/** D71: does `collection[id]` have a kinematic proxy in the local world right now? */
|
|
369
|
+
hasProxy(collection, id) {
|
|
370
|
+
return this.proxies.has(key(collection, id));
|
|
371
|
+
}
|
|
337
372
|
/**
|
|
338
373
|
* Is a correction's every value within the suppression tolerance of the prediction it judges?
|
|
339
374
|
* Position (and rotation) channels compare against `epsilon` directly; velocity and angular
|
|
340
|
-
* channels against
|
|
341
|
-
* moves in one tick.
|
|
375
|
+
* channels against the adapter's per-engine velocity tolerance, because a velocity
|
|
376
|
+
* disagreement matters by what it moves in one tick.
|
|
342
377
|
*/
|
|
343
378
|
withinEpsilon(desc, fields, patch, predicted) {
|
|
344
379
|
const physics = desc.physics;
|
|
345
380
|
if (!physics || fields.length === 0) return false;
|
|
346
381
|
const eps = this.epsilon;
|
|
347
|
-
const dt = this.
|
|
382
|
+
const dt = this.timestepSeconds ?? 1 / 30;
|
|
383
|
+
const velocityTolerance = this.adapter.velocityTolerance(eps, dt);
|
|
348
384
|
const channelOf = new Map(physics.channels.map(([c, f]) => [f, c]));
|
|
349
385
|
return fields.every((f) => {
|
|
350
386
|
const a = predicted[f];
|
|
@@ -352,7 +388,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
352
388
|
if (typeof a !== "number" || typeof b !== "number") return false;
|
|
353
389
|
const channel = channelOf.get(f);
|
|
354
390
|
const velocity = channel?.startsWith("v") === true || channel?.startsWith("w") === true;
|
|
355
|
-
const tolerance = velocity ?
|
|
391
|
+
const tolerance = velocity ? velocityTolerance : eps;
|
|
356
392
|
return Math.abs(a - b) <= tolerance;
|
|
357
393
|
});
|
|
358
394
|
}
|
|
@@ -365,7 +401,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
365
401
|
* draw loop — or a bot's render sampling — is the clock; there is no timer.
|
|
366
402
|
*/
|
|
367
403
|
frame(now) {
|
|
368
|
-
if (!this.
|
|
404
|
+
if (!this.worldReady || now === this.lastFrameNow && !this.authorityDirty) return;
|
|
369
405
|
const elapsedMs = this.lastFrameNow === void 0 ? 0 : Math.max(0, now - this.lastFrameNow);
|
|
370
406
|
this.advanceRenderClock(now);
|
|
371
407
|
this.lastFrameNow = now;
|
|
@@ -400,7 +436,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
400
436
|
const tick = this.headTick;
|
|
401
437
|
const slot = slotOf(tick);
|
|
402
438
|
for (const entry of this.bodies.values()) {
|
|
403
|
-
readPose(entry.body, entry.poses[slot]);
|
|
439
|
+
this.adapter.readPose(entry.body, entry.poses[slot]);
|
|
404
440
|
entry.poseTicks[slot] = tick;
|
|
405
441
|
}
|
|
406
442
|
if (tick > this.curTick) this.curTick = tick;
|
|
@@ -443,7 +479,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
443
479
|
}
|
|
444
480
|
/** `smoothingHalfLifeMs`, or 0 when the game turned the smoothing off. */
|
|
445
481
|
halfLifeMs() {
|
|
446
|
-
return Math.max(0, this.
|
|
482
|
+
return Math.max(0, this.tuning.smoothingHalfLifeMs ?? SMOOTHING_HALF_LIFE_MS);
|
|
447
483
|
}
|
|
448
484
|
/** Remember where every body is being drawn, before this frame re-simulates anything. */
|
|
449
485
|
snapshotDrawn() {
|
|
@@ -473,7 +509,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
473
509
|
absorbJump() {
|
|
474
510
|
const halfLife = this.halfLifeMs();
|
|
475
511
|
if (halfLife === 0) return;
|
|
476
|
-
const snap = Math.max(0, this.
|
|
512
|
+
const snap = Math.max(0, this.tuning.smoothingSnapUnits ?? SMOOTHING_SNAP_UNITS);
|
|
477
513
|
const eps = this.epsilon;
|
|
478
514
|
let worst = 0;
|
|
479
515
|
for (const entry of this.bodies.values()) {
|
|
@@ -557,7 +593,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
557
593
|
return this.scratch;
|
|
558
594
|
}
|
|
559
595
|
}
|
|
560
|
-
readPose(entry.body, this.scratch);
|
|
596
|
+
this.adapter.readPose(entry.body, this.scratch);
|
|
561
597
|
return this.scratch;
|
|
562
598
|
}
|
|
563
599
|
/** `rawPose` with the render-error offset applied — what the draw loop actually gets. */
|
|
@@ -615,14 +651,12 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
615
651
|
const physics = desc.physics;
|
|
616
652
|
if (!entry || !physics) return void 0;
|
|
617
653
|
const remembered = atTick !== void 0 ? this.history.get(key(desc.name, id))?.get(atTick) : void 0;
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
const v = entry.body.linvel();
|
|
621
|
-
const w = entry.body.angvel();
|
|
654
|
+
const pose = this.live;
|
|
655
|
+
this.adapter.readPose(entry.body, pose);
|
|
622
656
|
const out = {};
|
|
623
657
|
for (const [channel, field] of physics.channels) {
|
|
624
658
|
if (!fields.includes(field)) continue;
|
|
625
|
-
const value = remembered !== void 0 && typeof remembered[field] === "number" ? remembered[field] : channelValue(channel, t, r, v, w);
|
|
659
|
+
const value = remembered !== void 0 && typeof remembered[field] === "number" ? remembered[field] : channelValue(channel, pose.t, pose.r, pose.v, pose.w);
|
|
626
660
|
out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
|
|
627
661
|
}
|
|
628
662
|
return out;
|
|
@@ -631,65 +665,177 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
631
665
|
// Membership
|
|
632
666
|
// -------------------------------------------------------------------------
|
|
633
667
|
/**
|
|
634
|
-
* Mirrors the local world
|
|
635
|
-
* instance it owns
|
|
636
|
-
*
|
|
637
|
-
*
|
|
668
|
+
* Mirrors the local world onto the instances this client can see: a **simulated** body for every
|
|
669
|
+
* physics instance it owns plus non-owned instances of `predicted: true` collections up to
|
|
670
|
+
* `maxPredictedBodies`, and a **kinematic proxy** (D71) for everything else that has a body
|
|
671
|
+
* factory and has not opted out with `proxy: false`, up to `maxProxyBodies`.
|
|
672
|
+
*
|
|
673
|
+
* Slot order is collection order then insertion order — the same iteration guarantee the server
|
|
674
|
+
* states — so which instances fall over either cap is deterministic. There is no relevance
|
|
675
|
+
* policy and no distance ordering: the ninth crate spawned is still the ninth crate, it is just
|
|
676
|
+
* now solid rather than missing.
|
|
677
|
+
*
|
|
678
|
+
* Three transitions happen here, and each is a removal before a creation so the world never holds
|
|
679
|
+
* two colliders for one id:
|
|
638
680
|
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
681
|
+
* - **Promotion.** A proxied instance of a predicted collection finds a free slot (a predicted
|
|
682
|
+
* body was removed, or the cap was raised). `createBody` applies the authority record, velocity
|
|
683
|
+
* channels included, so the body carries on rather than starting from rest.
|
|
684
|
+
* - **Demotion.** A simulated instance loses its slot to an earlier-collection newcomer. That is
|
|
685
|
+
* what this loop already did before proxies — it stopped marking the body live and the sweep
|
|
686
|
+
* below removed it — and now it becomes a proxy at the drawn pose in the same frame. It costs
|
|
687
|
+
* one visible pose step: the body was a lead ahead of authority and the proxy is an
|
|
688
|
+
* interpolation delay behind it.
|
|
689
|
+
* - **Removal.** An instance the server removed, or one that left this client's area of interest
|
|
690
|
+
* (D23), loses whichever of the two it had, the same frame.
|
|
691
|
+
*
|
|
692
|
+
* What is left over — over the proxy cap, `proxy: false`, or no factory — has no collider in the
|
|
693
|
+
* local world at all, is counted in `stats.absent`, and is the only case a predicted body still
|
|
694
|
+
* falls through.
|
|
642
695
|
*/
|
|
643
696
|
reconcileBodies() {
|
|
644
|
-
|
|
645
|
-
const rapier = this.rapier;
|
|
646
|
-
if (!world || !rapier) return;
|
|
697
|
+
if (!this.worldReady) return;
|
|
647
698
|
const me = this.meOf();
|
|
648
699
|
const live = /* @__PURE__ */ new Set();
|
|
700
|
+
const liveProxies = /* @__PURE__ */ new Set();
|
|
649
701
|
let nonOwned = 0;
|
|
650
702
|
let overCap = 0;
|
|
703
|
+
let absent = 0;
|
|
651
704
|
const overCapBy = /* @__PURE__ */ new Map();
|
|
652
|
-
const
|
|
705
|
+
const cappedBy = /* @__PURE__ */ new Map();
|
|
706
|
+
const cap = this.tuning.maxPredictedBodies ?? MAX_PREDICTED_BODIES;
|
|
707
|
+
const proxyCap = this.tuning.maxProxyBodies ?? MAX_PROXY_BODIES;
|
|
653
708
|
for (const desc of this.collections) {
|
|
654
709
|
const coll = this.store.plainCollection(desc.name);
|
|
655
710
|
for (const id of coll.ids()) {
|
|
711
|
+
const k = key(desc.name, id);
|
|
656
712
|
const owned = coll.ownerOf(id) === me && me !== "";
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
if (nonOwned
|
|
713
|
+
let simulate = owned;
|
|
714
|
+
if (!owned && desc.predicted) {
|
|
715
|
+
if (nonOwned < cap) {
|
|
716
|
+
simulate = true;
|
|
717
|
+
nonOwned++;
|
|
718
|
+
} else {
|
|
660
719
|
overCap++;
|
|
661
720
|
overCapBy.set(desc.name, (overCapBy.get(desc.name) ?? 0) + 1);
|
|
662
|
-
continue;
|
|
663
721
|
}
|
|
664
|
-
nonOwned++;
|
|
665
722
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
723
|
+
if (simulate) {
|
|
724
|
+
live.add(k);
|
|
725
|
+
if (this.bodies.has(k)) continue;
|
|
726
|
+
this.removeProxy(k);
|
|
727
|
+
const record2 = coll.get(id);
|
|
728
|
+
if (record2) this.createBody(desc, id, record2, owned);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (!desc.proxy || !this.adapter.hasFactory(desc.name)) {
|
|
732
|
+
absent++;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (liveProxies.size >= proxyCap) {
|
|
736
|
+
absent++;
|
|
737
|
+
cappedBy.set(desc.name, (cappedBy.get(desc.name) ?? 0) + 1);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
liveProxies.add(k);
|
|
741
|
+
if (this.proxies.has(k)) continue;
|
|
742
|
+
this.removePredicted(k);
|
|
669
743
|
const record = coll.get(id);
|
|
670
|
-
if (record) this.
|
|
744
|
+
if (record) this.createProxy(desc, id, record);
|
|
671
745
|
}
|
|
672
746
|
}
|
|
673
747
|
this.stats.overCap = overCap;
|
|
674
|
-
this.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
this.
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
this.
|
|
681
|
-
|
|
748
|
+
this.stats.absent = absent;
|
|
749
|
+
this.warnCaps(cap, proxyCap, overCapBy, cappedBy);
|
|
750
|
+
for (const k of [...this.bodies.keys()]) {
|
|
751
|
+
if (!live.has(k)) this.removePredicted(k);
|
|
752
|
+
}
|
|
753
|
+
for (const k of [...this.proxies.keys()]) {
|
|
754
|
+
if (!liveProxies.has(k)) this.removeProxy(k);
|
|
755
|
+
}
|
|
756
|
+
this.stats.proxies = this.proxies.size;
|
|
757
|
+
this.refreshProxyTargets();
|
|
758
|
+
}
|
|
759
|
+
/** Drops one simulated body and everything the loop keeps per body. Safe on a missing key. */
|
|
760
|
+
removePredicted(k) {
|
|
761
|
+
const entry = this.bodies.get(k);
|
|
762
|
+
if (!entry) return;
|
|
763
|
+
this.bodies.delete(k);
|
|
764
|
+
this.authorityTicks.delete(k);
|
|
765
|
+
this.predictedTicks.delete(k);
|
|
766
|
+
this.history.delete(k);
|
|
767
|
+
this.adapter.removeBody(entry.body);
|
|
768
|
+
}
|
|
769
|
+
/** Drops one proxy. Safe on a missing key. */
|
|
770
|
+
removeProxy(k) {
|
|
771
|
+
const entry = this.proxies.get(k);
|
|
772
|
+
if (!entry) return;
|
|
773
|
+
this.proxies.delete(k);
|
|
774
|
+
this.adapter.removeBody(entry.body);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* D71: builds one kinematic proxy through the **same factory call** the simulated path uses, so
|
|
778
|
+
* the collider is the server's collider by construction rather than by a second description of
|
|
779
|
+
* it, then hands it to the adapter to be made kinematic.
|
|
780
|
+
*
|
|
781
|
+
* The authority record is applied first — a proxy appears where the server last said it was, not
|
|
782
|
+
* at the factory's origin — and the drawn pose takes over on the next `refreshProxyTargets`.
|
|
783
|
+
*/
|
|
784
|
+
createProxy(desc, id, record) {
|
|
785
|
+
if (!this.worldReady) return;
|
|
786
|
+
const body = this.adapter.createBody(
|
|
787
|
+
desc,
|
|
788
|
+
id,
|
|
789
|
+
record,
|
|
790
|
+
(k, message) => this.warnOnce(k, message)
|
|
791
|
+
);
|
|
792
|
+
if (!body) return;
|
|
793
|
+
const physics = desc.physics;
|
|
794
|
+
if (physics) this.adapter.applyRecord(body, physics.channels, record);
|
|
795
|
+
this.adapter.makeKinematic(body);
|
|
796
|
+
const entry = { desc, id, body, target: emptyPose(), targeted: false };
|
|
797
|
+
this.adapter.readPose(body, entry.target);
|
|
798
|
+
this.proxies.set(key(desc.name, id), entry);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Reads every proxy's drawn pose once per frame, and only once.
|
|
802
|
+
*
|
|
803
|
+
* Once, because a rebase re-steps the world up to `MAX_LEAD` times against a render clock that
|
|
804
|
+
* has not moved: asking again per step would return the same answer at a real cost. And once
|
|
805
|
+
* *because* of D71's rule — a proxy holds one pose for every re-step of a rebase. It cannot run
|
|
806
|
+
* ahead of the newest delta because the reader never extrapolates, and it cannot run ahead of the
|
|
807
|
+
* frame because it is only ever asked here.
|
|
808
|
+
*
|
|
809
|
+
* A proxy with nothing drawn (a delta gap, an entity not yet at the render clock) keeps the pose
|
|
810
|
+
* it had. That is the hold, and it is the difference between a crate that stays solid through a
|
|
811
|
+
* stall and a crate that slides off across the level.
|
|
812
|
+
*/
|
|
813
|
+
refreshProxyTargets() {
|
|
814
|
+
const reader = this.drawnReader;
|
|
815
|
+
if (!reader || this.proxies.size === 0) return;
|
|
816
|
+
const channels = this.drawnChannels;
|
|
817
|
+
for (const entry of this.proxies.values()) {
|
|
818
|
+
const physics = entry.desc.physics;
|
|
819
|
+
if (!physics) continue;
|
|
820
|
+
for (const [, field] of physics.channels) channels[field] = Number.NaN;
|
|
821
|
+
if (!reader(entry.desc, entry.id, channels)) continue;
|
|
822
|
+
poseFromRecord(physics.channels, channels, entry.target);
|
|
823
|
+
entry.targeted = true;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/** Writes every proxy to its target pose. Runs immediately before each `adapter.step()`. */
|
|
827
|
+
driveProxies() {
|
|
828
|
+
for (const entry of this.proxies.values()) {
|
|
829
|
+
if (entry.targeted) this.adapter.moveKinematic(entry.body, entry.target);
|
|
682
830
|
}
|
|
683
831
|
}
|
|
684
832
|
createBody(desc, id, record, owned) {
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
if (!
|
|
688
|
-
const factory = this.options.bodies?.[desc.name];
|
|
689
|
-
if (!factory) {
|
|
833
|
+
if (!this.worldReady) return;
|
|
834
|
+
const option = this.adapter.optionName;
|
|
835
|
+
if (!this.adapter.hasFactory(desc.name)) {
|
|
690
836
|
this.warnOnce(
|
|
691
837
|
`factory:${desc.name}`,
|
|
692
|
-
`irtio:
|
|
838
|
+
`irtio: ${option}.bodies.${desc.name} is missing from joinRoom({ ${option} }). ${desc.name} instances render by interpolation but have no body in the local world, so predicted bodies pass through them.`
|
|
693
839
|
);
|
|
694
840
|
return;
|
|
695
841
|
}
|
|
@@ -700,14 +846,13 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
700
846
|
`irtio: ${desc.name} maps no velocity channels (vx/vy/vz) \u2014 corrections can only rebase what the schema carries, and predicted bodies drift without them. Map the velocity channels on collections you predict.`
|
|
701
847
|
);
|
|
702
848
|
}
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
this.warnOnce(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
|
|
849
|
+
const body = this.adapter.createBody(
|
|
850
|
+
desc,
|
|
851
|
+
id,
|
|
852
|
+
record,
|
|
853
|
+
(k, message) => this.warnOnce(k, message)
|
|
854
|
+
);
|
|
855
|
+
if (!body) return;
|
|
711
856
|
const entry = {
|
|
712
857
|
desc,
|
|
713
858
|
id,
|
|
@@ -722,7 +867,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
722
867
|
};
|
|
723
868
|
this.applyRecord(entry, record);
|
|
724
869
|
for (let i = 0; i < POSE_RING; i++) {
|
|
725
|
-
readPose(body, entry.poses[i]);
|
|
870
|
+
this.adapter.readPose(body, entry.poses[i]);
|
|
726
871
|
}
|
|
727
872
|
for (let back = 0; back < POSE_RING; back++) {
|
|
728
873
|
const tick = this.curTick - back;
|
|
@@ -732,17 +877,29 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
732
877
|
this.bodies.set(key(desc.name, id), entry);
|
|
733
878
|
}
|
|
734
879
|
/**
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
880
|
+
* The two cap notices, both on a new high-water mark per collection: a game that grows past a cap
|
|
881
|
+
* mid-session hears about it, and a count that oscillates around one level does not turn the
|
|
882
|
+
* console into a log.
|
|
883
|
+
*
|
|
884
|
+
* They say different things now, and only the second is an emergency (D71). Over the *prediction*
|
|
885
|
+
* cap an instance is still solid — it has a proxy — it just is not simulated, so a shove does
|
|
886
|
+
* nothing locally until the server agrees. Over the *proxy* cap it is genuinely not there, which
|
|
887
|
+
* is the failure `bugs.md` #3 was about, and the message names both numbers so it is obvious
|
|
888
|
+
* which one to raise.
|
|
739
889
|
*/
|
|
740
|
-
|
|
890
|
+
warnCaps(cap, proxyCap, overCapBy, cappedBy) {
|
|
741
891
|
for (const [name, count] of overCapBy) {
|
|
742
892
|
if (count <= (this.overCapHigh.get(name) ?? 0)) continue;
|
|
743
893
|
this.overCapHigh.set(name, count);
|
|
744
894
|
this.log(
|
|
745
|
-
`irtio: ${count} ${name} instance(s) over the ${cap}-body prediction cap. They
|
|
895
|
+
`irtio: ${count} ${name} instance(s) over the ${cap}-body prediction cap. They keep a kinematic proxy in the local world, so predicted bodies still collide with them, but they are not simulated ahead: pushing one does nothing locally until the server says so. Raise maxPredictedBodies for anything a player shoves (the cost is a world step per lead tick, not a per-body-per-frame cost); leave it for anything a player only stands on or is blocked by.`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
for (const [name, count] of cappedBy) {
|
|
899
|
+
if (count <= (this.absentHigh.get(name) ?? 0)) continue;
|
|
900
|
+
this.absentHigh.set(name, count);
|
|
901
|
+
this.log(
|
|
902
|
+
`irtio: ${count} ${name} instance(s) over the ${proxyCap}-body proxy cap (maxProxyBodies ${proxyCap}, maxPredictedBodies ${cap}). They are ABSENT from the local world, not interpolated into it: predicted bodies pass through them until the next correction snaps them back. Raise maxProxyBodies, or set proxy: false on the collections nothing collides with so the budget goes to the ones that matter.`
|
|
746
903
|
);
|
|
747
904
|
}
|
|
748
905
|
}
|
|
@@ -755,49 +912,38 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
755
912
|
// Rebase and free-run
|
|
756
913
|
// -------------------------------------------------------------------------
|
|
757
914
|
/**
|
|
758
|
-
* The client's lead over authority, in ticks:
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
* Every tick of lead beyond that transit is a tick of motion the client draws on the
|
|
762
|
-
* assumption that the input it is holding will still be held when the server gets there. On a
|
|
763
|
-
* release that assumption is wrong by construction, and the invented travel is handed back as
|
|
764
|
-
* a backwards yank — bug 1's "keeps moving after key up and then snaps back". It was
|
|
765
|
-
* `ceil(owd) + 1`, which on a fast link is two whole ticks of margin over a transit of nearly
|
|
766
|
-
* zero. Measured on the arena fixture at no injected latency (release overshoot of a 6 u/s
|
|
767
|
-
* held-input character, `physics-predict.test.ts`):
|
|
768
|
-
*
|
|
769
|
-
* | lead | drawn overshoot past the server's stop | worst backwards step |
|
|
770
|
-
* | --- | --- | --- |
|
|
771
|
-
* | `ceil(owd) + 1` (was) | 0.585 u | -0.585 u |
|
|
772
|
-
* | `max(1, round(owd))` (is) | 0.293 u | -0.292 u |
|
|
773
|
-
* | `round(owd)`, floor 0 | 0.0008 u | -0.0008 u |
|
|
915
|
+
* The client's lead over authority, in ticks: a full round trip, rounded to the nearest tick
|
|
916
|
+
* (bugs.md #47, Candidate A).
|
|
774
917
|
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
918
|
+
* The authority in hand left the server one one-way transit ago, so the server is at
|
|
919
|
+
* `authority + owd` right now, and an input flushed now reaches it another one-way transit
|
|
920
|
+
* later: the first tick it can take effect at is `authority + rtt`. The head has to be there
|
|
921
|
+
* for two reasons. The stamp is `head + 1`, and a stamp that names the tick the server will
|
|
922
|
+
* actually apply the write at is what lets the replay put a release where the server put it;
|
|
923
|
+
* with the head at half a round trip (server-now) the stamp trailed the application by
|
|
924
|
+
* `owd - 1` ticks, `stampGap`'s floor at zero discarded the sign, and every release was
|
|
925
|
+
* replayed a one-way transit late: the local body stopped, then followed authority forward for
|
|
926
|
+
* the rest of the round trip. And the intent the local body is simulating under is the intent
|
|
927
|
+
* the server will be simulating under at the same tick, which is the whole point of predicting.
|
|
778
928
|
*
|
|
779
|
-
* The
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
* bot suites' correction-storm invariant fires immediately (21 corrections/s per bot against a
|
|
784
|
-
* threshold of 5 in `packages/cli/test/simulate-physics.test.ts`). One tick of speculation is
|
|
785
|
-
* the price of predicting at all; two was the bug.
|
|
929
|
+
* The price is the distance between the drawn body and the authority it is anchored to, which
|
|
930
|
+
* is the size of every misprediction the client has not been told about yet: a full round trip
|
|
931
|
+
* of motion instead of half. dive's lag budget states that distance against the round trip the
|
|
932
|
+
* test measures.
|
|
786
933
|
*
|
|
787
|
-
* Erring
|
|
788
|
-
*
|
|
789
|
-
* it is
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
* of the server's application the stamps still land, and it should sit at 0.
|
|
934
|
+
* Erring high is still the expensive direction, and the rounding is to nearest for that reason:
|
|
935
|
+
* a lead longer than the real round trip is a tick of motion the client draws on the assumption
|
|
936
|
+
* that the input it is holding will still be held when the server gets there, and on a release
|
|
937
|
+
* that assumption is wrong by construction (bug 1's "keeps moving after key up and then snaps
|
|
938
|
+
* back"). `stats.stampGap` is the running check: it reports how far ahead of the server's
|
|
939
|
+
* application the stamps land, and `stampGapRaw` the same before the clamp. Both should sit
|
|
940
|
+
* between 0 and 1.
|
|
795
941
|
*/
|
|
796
942
|
leadTicks() {
|
|
797
|
-
return Math.max(1, Math.round(this.rttOf() /
|
|
943
|
+
return Math.max(1, Math.round(this.rttOf() / this.timestepMs()));
|
|
798
944
|
}
|
|
799
945
|
timestepMs() {
|
|
800
|
-
return (this.
|
|
946
|
+
return (this.timestepSeconds ?? 1 / 30) * 1e3;
|
|
801
947
|
}
|
|
802
948
|
/**
|
|
803
949
|
* Rebase + re-step: snap every predicted body to the authoritative record (server values —
|
|
@@ -805,13 +951,11 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
805
951
|
* written, so plain state holds exactly what the server said), then re-step the world by the
|
|
806
952
|
* client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
|
|
807
953
|
* the newest buffered unjudged write stamped at or before it, or the baseline (the newest
|
|
808
|
-
* judged write) before the first of them. Bounded by
|
|
809
|
-
*
|
|
954
|
+
* judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
|
|
955
|
+
* authority and counts (`stats.snaps`).
|
|
810
956
|
*/
|
|
811
957
|
rebase() {
|
|
812
|
-
|
|
813
|
-
const rapier = this.rapier;
|
|
814
|
-
if (!world || !rapier) return;
|
|
958
|
+
if (!this.worldReady) return;
|
|
815
959
|
this.stats.rebases++;
|
|
816
960
|
for (const entry of this.bodies.values()) {
|
|
817
961
|
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
@@ -820,11 +964,18 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
820
964
|
for (const entry of this.bodies.values()) {
|
|
821
965
|
if (!entry.owned) continue;
|
|
822
966
|
const k = key(entry.desc.name, entry.id);
|
|
823
|
-
this.predictedTicks.set(k, this.
|
|
967
|
+
this.predictedTicks.set(k, Math.max(this.authorityTick, this.authorityTicks.get(k) ?? 0));
|
|
824
968
|
}
|
|
825
969
|
const lead = this.leadTicks();
|
|
970
|
+
if (this.lastLead !== void 0 && lead !== this.lastLead && this.gapMeasured) {
|
|
971
|
+
const delta = lead - this.lastLead;
|
|
972
|
+
this.stampGap = Math.min(RESIM_DEPTH, Math.max(0, this.stampGap + delta));
|
|
973
|
+
this.stats.stampGap = this.stampGap;
|
|
974
|
+
this.stats.stampGapRaw += delta;
|
|
975
|
+
}
|
|
976
|
+
this.lastLead = lead;
|
|
826
977
|
this.headTick = this.authorityTick;
|
|
827
|
-
if (lead >
|
|
978
|
+
if (lead > MAX_LEAD) {
|
|
828
979
|
this.stats.snaps++;
|
|
829
980
|
return;
|
|
830
981
|
}
|
|
@@ -840,7 +991,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
840
991
|
}
|
|
841
992
|
const started = performance.now();
|
|
842
993
|
for (let step = 0; step < lead; step++) {
|
|
843
|
-
this.applyIntents(
|
|
994
|
+
this.applyIntents((k) => {
|
|
844
995
|
const r = replays.get(k);
|
|
845
996
|
if (!r) return void 0;
|
|
846
997
|
const t = (this.predictedTicks.get(k) ?? 0) + 1;
|
|
@@ -852,7 +1003,8 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
852
1003
|
}
|
|
853
1004
|
return r.inForce;
|
|
854
1005
|
});
|
|
855
|
-
|
|
1006
|
+
this.driveProxies();
|
|
1007
|
+
this.adapter.step();
|
|
856
1008
|
this.recordStep();
|
|
857
1009
|
this.headTick++;
|
|
858
1010
|
if (lead - step <= POSE_RING) this.capturePoses();
|
|
@@ -868,7 +1020,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
868
1020
|
* until the engine runs or while nothing is owned; the session falls back to its counter.
|
|
869
1021
|
*/
|
|
870
1022
|
stampTick() {
|
|
871
|
-
if (!this.
|
|
1023
|
+
if (!this.worldReady) return void 0;
|
|
872
1024
|
let head;
|
|
873
1025
|
for (const entry of this.bodies.values()) {
|
|
874
1026
|
if (!entry.owned) continue;
|
|
@@ -886,13 +1038,11 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
886
1038
|
const k = key(entry.desc.name, entry.id);
|
|
887
1039
|
const tick = (this.predictedTicks.get(k) ?? 0) + 1;
|
|
888
1040
|
this.predictedTicks.set(k, tick);
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
const v = entry.body.linvel();
|
|
892
|
-
const w = entry.body.angvel();
|
|
1041
|
+
const pose = this.live;
|
|
1042
|
+
this.adapter.readPose(entry.body, pose);
|
|
893
1043
|
const record = {};
|
|
894
1044
|
for (const [channel, field] of physics.channels) {
|
|
895
|
-
record[field] = channelValue(channel, t, r, v, w);
|
|
1045
|
+
record[field] = channelValue(channel, pose.t, pose.r, pose.v, pose.w);
|
|
896
1046
|
}
|
|
897
1047
|
let byTick = this.history.get(k);
|
|
898
1048
|
if (!byTick) {
|
|
@@ -900,14 +1050,12 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
900
1050
|
this.history.set(k, byTick);
|
|
901
1051
|
}
|
|
902
1052
|
byTick.set(tick, record);
|
|
903
|
-
byTick.delete(tick -
|
|
1053
|
+
byTick.delete(tick - _Predictor.HISTORY_TICKS);
|
|
904
1054
|
}
|
|
905
1055
|
}
|
|
906
1056
|
/** Returns the number of steps taken, so the caller knows whether the head moved. */
|
|
907
1057
|
freeRun(now) {
|
|
908
|
-
|
|
909
|
-
const rapier = this.rapier;
|
|
910
|
-
if (!world || !rapier) return 0;
|
|
1058
|
+
if (!this.worldReady) return 0;
|
|
911
1059
|
if (this.lastNow === void 0) {
|
|
912
1060
|
this.lastNow = now;
|
|
913
1061
|
return 0;
|
|
@@ -921,8 +1069,9 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
921
1069
|
let steps = 0;
|
|
922
1070
|
while (this.accumulatorMs >= dtMs) {
|
|
923
1071
|
this.accumulatorMs -= dtMs;
|
|
924
|
-
this.applyIntents(
|
|
925
|
-
|
|
1072
|
+
this.applyIntents(() => void 0);
|
|
1073
|
+
this.driveProxies();
|
|
1074
|
+
this.adapter.step();
|
|
926
1075
|
this.recordStep();
|
|
927
1076
|
this.headTick++;
|
|
928
1077
|
this.capturePoses();
|
|
@@ -936,87 +1085,42 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
936
1085
|
* tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
|
|
937
1086
|
* current values (free-run: plain state carries the newest local intent writes, which is
|
|
938
1087
|
* correct there because free-run steps are the ticks *after* every buffered write).
|
|
1088
|
+
*
|
|
1089
|
+
* Then, on an engine that declares one, the `settle` pass: a per-step force over **every**
|
|
1090
|
+
* local body of a collection, owned or not, run after the whole intent pass rather than
|
|
1091
|
+
* interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
|
|
1092
|
+
* whose world has none of its own.
|
|
1093
|
+
*
|
|
1094
|
+
* Both passes walk `this.bodies`, which is why neither ever reaches a proxy (D71): a proxy is not
|
|
1095
|
+
* simulated, so an intent hook or a per-step gravity force on it would be a force on a body that
|
|
1096
|
+
* cannot move, applied to a pose that is going to be overwritten before the next step anyway. The
|
|
1097
|
+
* isolation is structural rather than a filter, which is the version that cannot rot.
|
|
939
1098
|
*/
|
|
940
|
-
applyIntents(
|
|
1099
|
+
applyIntents(frameFor) {
|
|
941
1100
|
for (const entry of this.bodies.values()) {
|
|
942
1101
|
if (!entry.owned) continue;
|
|
943
|
-
const hook = this.options.intents?.[entry.desc.name];
|
|
944
|
-
if (!hook) continue;
|
|
945
1102
|
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
946
1103
|
if (!record) continue;
|
|
947
1104
|
const frame = frameFor(key(entry.desc.name, entry.id));
|
|
948
|
-
|
|
1105
|
+
this.adapter.applyIntent(
|
|
1106
|
+
entry.desc.name,
|
|
1107
|
+
entry.body,
|
|
1108
|
+
frame ? { ...record, ...frame } : record
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
const settle = this.adapter.settle;
|
|
1112
|
+
if (!settle) return;
|
|
1113
|
+
for (const entry of this.bodies.values()) {
|
|
1114
|
+
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
1115
|
+
if (!record) continue;
|
|
1116
|
+
settle.call(this.adapter, entry.desc.name, entry.body, record);
|
|
949
1117
|
}
|
|
950
1118
|
}
|
|
951
1119
|
/** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
|
|
952
1120
|
applyRecord(entry, record) {
|
|
953
1121
|
const physics = entry.desc.physics;
|
|
954
1122
|
if (!physics) return;
|
|
955
|
-
|
|
956
|
-
const t = { ...body.translation() };
|
|
957
|
-
const r = { ...body.rotation() };
|
|
958
|
-
const v = { ...body.linvel() };
|
|
959
|
-
const w = { ...body.angvel() };
|
|
960
|
-
let setT = false;
|
|
961
|
-
let setR = false;
|
|
962
|
-
let setV = false;
|
|
963
|
-
let setW = false;
|
|
964
|
-
for (const [channel, field] of physics.channels) {
|
|
965
|
-
const raw = record[field];
|
|
966
|
-
if (typeof raw !== "number") continue;
|
|
967
|
-
switch (channel) {
|
|
968
|
-
case "x":
|
|
969
|
-
case "y":
|
|
970
|
-
case "z":
|
|
971
|
-
t[channel] = raw;
|
|
972
|
-
setT = true;
|
|
973
|
-
break;
|
|
974
|
-
case "qx":
|
|
975
|
-
r.x = raw;
|
|
976
|
-
setR = true;
|
|
977
|
-
break;
|
|
978
|
-
case "qy":
|
|
979
|
-
r.y = raw;
|
|
980
|
-
setR = true;
|
|
981
|
-
break;
|
|
982
|
-
case "qz":
|
|
983
|
-
r.z = raw;
|
|
984
|
-
setR = true;
|
|
985
|
-
break;
|
|
986
|
-
case "qw":
|
|
987
|
-
r.w = raw;
|
|
988
|
-
setR = true;
|
|
989
|
-
break;
|
|
990
|
-
case "vx":
|
|
991
|
-
v.x = raw;
|
|
992
|
-
setV = true;
|
|
993
|
-
break;
|
|
994
|
-
case "vy":
|
|
995
|
-
v.y = raw;
|
|
996
|
-
setV = true;
|
|
997
|
-
break;
|
|
998
|
-
case "vz":
|
|
999
|
-
v.z = raw;
|
|
1000
|
-
setV = true;
|
|
1001
|
-
break;
|
|
1002
|
-
case "wx":
|
|
1003
|
-
w.x = raw;
|
|
1004
|
-
setW = true;
|
|
1005
|
-
break;
|
|
1006
|
-
case "wy":
|
|
1007
|
-
w.y = raw;
|
|
1008
|
-
setW = true;
|
|
1009
|
-
break;
|
|
1010
|
-
case "wz":
|
|
1011
|
-
w.z = raw;
|
|
1012
|
-
setW = true;
|
|
1013
|
-
break;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
if (setT) body.setTranslation(t, true);
|
|
1017
|
-
if (setR) body.setRotation(r, true);
|
|
1018
|
-
if (setV) body.setLinvel(v, true);
|
|
1019
|
-
if (setW) body.setAngvel(w, true);
|
|
1123
|
+
this.adapter.applyRecord(entry.body, physics.channels, record);
|
|
1020
1124
|
}
|
|
1021
1125
|
isF32(desc, field) {
|
|
1022
1126
|
const idx = desc.fieldIndex.get(field);
|
|
@@ -1024,6 +1128,49 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
1024
1128
|
return desc.fields[idx]?.type.kind === "f32";
|
|
1025
1129
|
}
|
|
1026
1130
|
};
|
|
1131
|
+
function poseFromRecord(channels, record, into) {
|
|
1132
|
+
for (const [channel, field] of channels) {
|
|
1133
|
+
const raw = record[field];
|
|
1134
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
|
|
1135
|
+
switch (channel) {
|
|
1136
|
+
case "x":
|
|
1137
|
+
case "y":
|
|
1138
|
+
case "z":
|
|
1139
|
+
into.t[channel] = raw;
|
|
1140
|
+
break;
|
|
1141
|
+
case "qx":
|
|
1142
|
+
into.r.x = raw;
|
|
1143
|
+
break;
|
|
1144
|
+
case "qy":
|
|
1145
|
+
into.r.y = raw;
|
|
1146
|
+
break;
|
|
1147
|
+
case "qz":
|
|
1148
|
+
into.r.z = raw;
|
|
1149
|
+
break;
|
|
1150
|
+
case "qw":
|
|
1151
|
+
into.r.w = raw;
|
|
1152
|
+
break;
|
|
1153
|
+
case "vx":
|
|
1154
|
+
into.v.x = raw;
|
|
1155
|
+
break;
|
|
1156
|
+
case "vy":
|
|
1157
|
+
into.v.y = raw;
|
|
1158
|
+
break;
|
|
1159
|
+
case "vz":
|
|
1160
|
+
into.v.z = raw;
|
|
1161
|
+
break;
|
|
1162
|
+
case "wx":
|
|
1163
|
+
into.w.x = raw;
|
|
1164
|
+
break;
|
|
1165
|
+
case "wy":
|
|
1166
|
+
into.w.y = raw;
|
|
1167
|
+
break;
|
|
1168
|
+
case "wz":
|
|
1169
|
+
into.w.z = raw;
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1027
1174
|
function channelValue(channel, t, r, v, w) {
|
|
1028
1175
|
switch (channel) {
|
|
1029
1176
|
case "x":
|
|
@@ -1054,6 +1201,7 @@ function channelValue(channel, t, r, v, w) {
|
|
|
1054
1201
|
return w.z;
|
|
1055
1202
|
}
|
|
1056
1203
|
}
|
|
1204
|
+
|
|
1057
1205
|
export {
|
|
1058
|
-
|
|
1206
|
+
Predictor
|
|
1059
1207
|
};
|