@irtio/client 0.5.1 → 0.6.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/{chunk-7UTJ7RSF.js → chunk-6J6PKFUS.js} +69 -8
- package/dist/{physics-H2VDQLAU.js → chunk-TE66EDBB.js} +174 -239
- package/dist/index.d.ts +1170 -158
- package/dist/index.js +1240 -42
- package/dist/physics-S5LHL3HK.js +196 -0
- package/dist/physics2d-ZV2IRRRZ.js +206 -0
- package/package.json +15 -6
|
@@ -4,9 +4,9 @@ import {
|
|
|
4
4
|
RESIM_DEPTH,
|
|
5
5
|
SMOOTHING_HALF_LIFE_MS,
|
|
6
6
|
SMOOTHING_SNAP_UNITS
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-6J6PKFUS.js";
|
|
8
8
|
|
|
9
|
-
// src/
|
|
9
|
+
// src/predictor.ts
|
|
10
10
|
var MAX_FREE_STEPS_PER_FRAME = 5;
|
|
11
11
|
var FREE_RUN_GRACE = 0.5;
|
|
12
12
|
var POSE_RING = 4;
|
|
@@ -14,6 +14,7 @@ var LAG_TARGET_MIN = 0.7;
|
|
|
14
14
|
var LAG_TARGET_MAX = 1.4;
|
|
15
15
|
var CLOCK_TRIM = 0.12;
|
|
16
16
|
var GAP_ALPHA = 0.3;
|
|
17
|
+
var MAX_LEAD = 40;
|
|
17
18
|
function emptyPose() {
|
|
18
19
|
return {
|
|
19
20
|
t: { x: 0, y: 0, z: 0 },
|
|
@@ -22,25 +23,6 @@ function emptyPose() {
|
|
|
22
23
|
w: { x: 0, y: 0, z: 0 }
|
|
23
24
|
};
|
|
24
25
|
}
|
|
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
26
|
function copyPose(from, into) {
|
|
45
27
|
into.t.x = from.t.x;
|
|
46
28
|
into.t.y = from.t.y;
|
|
@@ -125,42 +107,11 @@ function poseAt(entry, tick) {
|
|
|
125
107
|
function key(collection, id) {
|
|
126
108
|
return `${collection}\0${id}`;
|
|
127
109
|
}
|
|
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)) {
|
|
110
|
+
var Predictor = class _Predictor {
|
|
111
|
+
constructor(ext, store, adapter, tuning, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
|
|
162
112
|
this.store = store;
|
|
163
|
-
this.
|
|
113
|
+
this.adapter = adapter;
|
|
114
|
+
this.tuning = tuning;
|
|
164
115
|
this.meOf = meOf;
|
|
165
116
|
this.rttOf = rttOf;
|
|
166
117
|
this.tickIntervalOf = tickIntervalOf;
|
|
@@ -170,7 +121,8 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
170
121
|
);
|
|
171
122
|
}
|
|
172
123
|
store;
|
|
173
|
-
|
|
124
|
+
adapter;
|
|
125
|
+
tuning;
|
|
174
126
|
meOf;
|
|
175
127
|
rttOf;
|
|
176
128
|
tickIntervalOf;
|
|
@@ -184,12 +136,17 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
184
136
|
overCap: 0,
|
|
185
137
|
lastResimMicros: 0,
|
|
186
138
|
smoothing: 0,
|
|
187
|
-
stampGap: 0
|
|
139
|
+
stampGap: 0,
|
|
140
|
+
stampGapRaw: 0,
|
|
141
|
+
lastStampTick: 0,
|
|
142
|
+
lastAppliedTick: 0
|
|
188
143
|
};
|
|
189
|
-
|
|
190
|
-
|
|
144
|
+
/** True once the adapter's world exists. The `world !== undefined` of the one-engine version. */
|
|
145
|
+
worldReady = false;
|
|
146
|
+
/** Seconds per step, fixed for the life of the world. */
|
|
147
|
+
timestepSeconds;
|
|
191
148
|
bodies = /* @__PURE__ */ new Map();
|
|
192
|
-
/** Physics-backed entity collections, in schema order. */
|
|
149
|
+
/** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
|
|
193
150
|
collections;
|
|
194
151
|
warned = /* @__PURE__ */ new Set();
|
|
195
152
|
/** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
|
|
@@ -217,6 +174,8 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
217
174
|
renderTick;
|
|
218
175
|
/** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
|
|
219
176
|
scratch = emptyPose();
|
|
177
|
+
/** Scratch for a direct body reading, so it never races `scratch`'s interpolated one. */
|
|
178
|
+
live = emptyPose();
|
|
220
179
|
/** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
|
|
221
180
|
qa = { x: 0, y: 0, z: 0, w: 1 };
|
|
222
181
|
qb = { x: 0, y: 0, z: 0, w: 1 };
|
|
@@ -235,45 +194,74 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
235
194
|
gapMeasured = false;
|
|
236
195
|
/** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
|
|
237
196
|
gapSampledThrough = 0;
|
|
197
|
+
/** The lead the last rebase re-stepped, so a change in it can be folded into `stampGap`. */
|
|
198
|
+
lastLead;
|
|
238
199
|
predictedTicks = /* @__PURE__ */ new Map();
|
|
239
200
|
history = /* @__PURE__ */ new Map();
|
|
240
|
-
/**
|
|
241
|
-
|
|
201
|
+
/**
|
|
202
|
+
* History kept per body: a correction for tick T is judged against the prediction recorded for
|
|
203
|
+
* T, and T is `lead` ticks behind the head when it arrives, so this has to clear `MAX_LEAD`
|
|
204
|
+
* with room for delivery jitter.
|
|
205
|
+
*/
|
|
206
|
+
static HISTORY_TICKS = MAX_LEAD + 24;
|
|
207
|
+
/**
|
|
208
|
+
* D50: re-derive the predicted-collection list from a rebuilt schema.
|
|
209
|
+
*
|
|
210
|
+
* This is deliberately shallow, and it is safe to be shallow because of the scope rule the
|
|
211
|
+
* supervisor enforces: **any change touching a physics collection classifies breaking**
|
|
212
|
+
* (`packages/supervisor/src/schema-swap.ts`), so a swap that reaches this method is guaranteed
|
|
213
|
+
* to leave every physics collection structurally identical. What changes is descriptor
|
|
214
|
+
* *identity*, not content, and this brings the predictor's references back in line with the
|
|
215
|
+
* store's rather than leaving two equal-but-distinct descriptor trees in play.
|
|
216
|
+
*
|
|
217
|
+
* The live bodies are dropped instead of retargeted, for the same reason the render buffers
|
|
218
|
+
* are: `reset()` follows immediately, the resync WELCOME re-seeds authority, and a body carried
|
|
219
|
+
* across a schema boundary is exactly the kind of thing that would look fine and be wrong.
|
|
220
|
+
*
|
|
221
|
+
* When the physics scope rule is lifted, this is where the real work goes: rebuilding each
|
|
222
|
+
* `PredictedBody.desc` and re-deriving collider shapes from the new descriptors.
|
|
223
|
+
*/
|
|
224
|
+
swapSchema(newExt) {
|
|
225
|
+
this.collections = newExt.collections.filter(
|
|
226
|
+
(c) => c.kind === "entity" && c.physics !== void 0
|
|
227
|
+
);
|
|
228
|
+
for (const entry of this.bodies.values()) this.adapter.removeBody(entry.body);
|
|
229
|
+
this.bodies.clear();
|
|
230
|
+
this.reset();
|
|
231
|
+
}
|
|
242
232
|
get epsilon() {
|
|
243
|
-
return this.
|
|
233
|
+
return this.tuning.epsilon ?? PREDICTION_EPSILON;
|
|
244
234
|
}
|
|
245
235
|
/** `true` once the engine is loaded and the local world exists. */
|
|
246
236
|
get ready() {
|
|
247
|
-
return this.
|
|
237
|
+
return this.worldReady;
|
|
248
238
|
}
|
|
249
239
|
/**
|
|
250
240
|
* Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
|
|
251
241
|
* interpolation/authority — joining is never blocked on 2.9 MB of WASM.
|
|
252
242
|
*/
|
|
253
243
|
start() {
|
|
254
|
-
if (this.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (this.options.setup) this.options.setup(world, rapier);
|
|
262
|
-
this.world = world;
|
|
244
|
+
if (this.worldReady || this.failed) return Promise.resolve();
|
|
245
|
+
const tickMs = this.tickIntervalOf();
|
|
246
|
+
const dt = this.adapter.timestep ?? (tickMs > 0 ? tickMs / 1e3 : 1 / 30);
|
|
247
|
+
return this.adapter.start(dt).then(
|
|
248
|
+
() => {
|
|
249
|
+
this.timestepSeconds = dt;
|
|
250
|
+
this.worldReady = true;
|
|
263
251
|
this.authorityDirty = true;
|
|
264
252
|
},
|
|
265
253
|
(err) => {
|
|
266
254
|
this.failed = true;
|
|
267
255
|
this.log(
|
|
268
|
-
`irtio: physics prediction disabled \u2014
|
|
256
|
+
`irtio: physics prediction disabled \u2014 ${this.adapter.engineName} failed to load: ${err instanceof Error ? err.message : String(err)}`
|
|
269
257
|
);
|
|
270
258
|
}
|
|
271
259
|
);
|
|
272
260
|
}
|
|
273
261
|
free() {
|
|
274
262
|
this.bodies.clear();
|
|
275
|
-
this.
|
|
276
|
-
this.
|
|
263
|
+
this.adapter.free();
|
|
264
|
+
this.worldReady = false;
|
|
277
265
|
}
|
|
278
266
|
/** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
|
|
279
267
|
reset() {
|
|
@@ -290,7 +278,9 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
290
278
|
this.stampGap = 0;
|
|
291
279
|
this.gapMeasured = false;
|
|
292
280
|
this.gapSampledThrough = 0;
|
|
281
|
+
this.lastLead = void 0;
|
|
293
282
|
this.stats.stampGap = 0;
|
|
283
|
+
this.stats.stampGapRaw = 0;
|
|
294
284
|
}
|
|
295
285
|
/**
|
|
296
286
|
* Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one).
|
|
@@ -321,14 +311,24 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
321
311
|
* write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
|
|
322
312
|
* authority is on the session's bare write counter rather than the server's tick stream, and
|
|
323
313
|
* differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
|
|
314
|
+
*
|
|
315
|
+
* The floor is also load-bearing in a way that is not obvious, and `bugs.md` #45 is the
|
|
316
|
+
* measurement: a stamp can *trail* the server's application, when the lead over-estimates the
|
|
317
|
+
* transit, and correcting for that by letting the gap go negative delays every write's replay by
|
|
318
|
+
* the same amount. It buys an accurate stop and pays for it with a late start. The lever for
|
|
319
|
+
* that is the lead, not this.
|
|
324
320
|
*/
|
|
325
321
|
noteWriteApplied(stampTick, appliedTick) {
|
|
326
322
|
if (stampTick <= 0 || appliedTick <= 0 || stampTick <= this.gapSampledThrough) return;
|
|
327
323
|
this.gapSampledThrough = stampTick;
|
|
328
|
-
const
|
|
324
|
+
const raw = stampTick - appliedTick;
|
|
325
|
+
const sample = Math.min(RESIM_DEPTH, Math.max(0, raw));
|
|
329
326
|
this.stampGap = this.gapMeasured ? this.stampGap + (sample - this.stampGap) * GAP_ALPHA : sample;
|
|
327
|
+
this.stats.stampGapRaw = this.gapMeasured ? this.stats.stampGapRaw + (raw - this.stats.stampGapRaw) * GAP_ALPHA : raw;
|
|
330
328
|
this.gapMeasured = true;
|
|
331
329
|
this.stats.stampGap = this.stampGap;
|
|
330
|
+
this.stats.lastStampTick = stampTick;
|
|
331
|
+
this.stats.lastAppliedTick = appliedTick;
|
|
332
332
|
}
|
|
333
333
|
/** Does the local world currently simulate `collection[id]`? */
|
|
334
334
|
has(collection, id) {
|
|
@@ -337,14 +337,15 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
337
337
|
/**
|
|
338
338
|
* Is a correction's every value within the suppression tolerance of the prediction it judges?
|
|
339
339
|
* Position (and rotation) channels compare against `epsilon` directly; velocity and angular
|
|
340
|
-
* channels against
|
|
341
|
-
* moves in one tick.
|
|
340
|
+
* channels against the adapter's per-engine velocity tolerance, because a velocity
|
|
341
|
+
* disagreement matters by what it moves in one tick.
|
|
342
342
|
*/
|
|
343
343
|
withinEpsilon(desc, fields, patch, predicted) {
|
|
344
344
|
const physics = desc.physics;
|
|
345
345
|
if (!physics || fields.length === 0) return false;
|
|
346
346
|
const eps = this.epsilon;
|
|
347
|
-
const dt = this.
|
|
347
|
+
const dt = this.timestepSeconds ?? 1 / 30;
|
|
348
|
+
const velocityTolerance = this.adapter.velocityTolerance(eps, dt);
|
|
348
349
|
const channelOf = new Map(physics.channels.map(([c, f]) => [f, c]));
|
|
349
350
|
return fields.every((f) => {
|
|
350
351
|
const a = predicted[f];
|
|
@@ -352,7 +353,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
352
353
|
if (typeof a !== "number" || typeof b !== "number") return false;
|
|
353
354
|
const channel = channelOf.get(f);
|
|
354
355
|
const velocity = channel?.startsWith("v") === true || channel?.startsWith("w") === true;
|
|
355
|
-
const tolerance = velocity ?
|
|
356
|
+
const tolerance = velocity ? velocityTolerance : eps;
|
|
356
357
|
return Math.abs(a - b) <= tolerance;
|
|
357
358
|
});
|
|
358
359
|
}
|
|
@@ -365,7 +366,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
365
366
|
* draw loop — or a bot's render sampling — is the clock; there is no timer.
|
|
366
367
|
*/
|
|
367
368
|
frame(now) {
|
|
368
|
-
if (!this.
|
|
369
|
+
if (!this.worldReady || now === this.lastFrameNow && !this.authorityDirty) return;
|
|
369
370
|
const elapsedMs = this.lastFrameNow === void 0 ? 0 : Math.max(0, now - this.lastFrameNow);
|
|
370
371
|
this.advanceRenderClock(now);
|
|
371
372
|
this.lastFrameNow = now;
|
|
@@ -400,7 +401,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
400
401
|
const tick = this.headTick;
|
|
401
402
|
const slot = slotOf(tick);
|
|
402
403
|
for (const entry of this.bodies.values()) {
|
|
403
|
-
readPose(entry.body, entry.poses[slot]);
|
|
404
|
+
this.adapter.readPose(entry.body, entry.poses[slot]);
|
|
404
405
|
entry.poseTicks[slot] = tick;
|
|
405
406
|
}
|
|
406
407
|
if (tick > this.curTick) this.curTick = tick;
|
|
@@ -443,7 +444,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
443
444
|
}
|
|
444
445
|
/** `smoothingHalfLifeMs`, or 0 when the game turned the smoothing off. */
|
|
445
446
|
halfLifeMs() {
|
|
446
|
-
return Math.max(0, this.
|
|
447
|
+
return Math.max(0, this.tuning.smoothingHalfLifeMs ?? SMOOTHING_HALF_LIFE_MS);
|
|
447
448
|
}
|
|
448
449
|
/** Remember where every body is being drawn, before this frame re-simulates anything. */
|
|
449
450
|
snapshotDrawn() {
|
|
@@ -473,7 +474,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
473
474
|
absorbJump() {
|
|
474
475
|
const halfLife = this.halfLifeMs();
|
|
475
476
|
if (halfLife === 0) return;
|
|
476
|
-
const snap = Math.max(0, this.
|
|
477
|
+
const snap = Math.max(0, this.tuning.smoothingSnapUnits ?? SMOOTHING_SNAP_UNITS);
|
|
477
478
|
const eps = this.epsilon;
|
|
478
479
|
let worst = 0;
|
|
479
480
|
for (const entry of this.bodies.values()) {
|
|
@@ -557,7 +558,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
557
558
|
return this.scratch;
|
|
558
559
|
}
|
|
559
560
|
}
|
|
560
|
-
readPose(entry.body, this.scratch);
|
|
561
|
+
this.adapter.readPose(entry.body, this.scratch);
|
|
561
562
|
return this.scratch;
|
|
562
563
|
}
|
|
563
564
|
/** `rawPose` with the render-error offset applied — what the draw loop actually gets. */
|
|
@@ -615,14 +616,12 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
615
616
|
const physics = desc.physics;
|
|
616
617
|
if (!entry || !physics) return void 0;
|
|
617
618
|
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();
|
|
619
|
+
const pose = this.live;
|
|
620
|
+
this.adapter.readPose(entry.body, pose);
|
|
622
621
|
const out = {};
|
|
623
622
|
for (const [channel, field] of physics.channels) {
|
|
624
623
|
if (!fields.includes(field)) continue;
|
|
625
|
-
const value = remembered !== void 0 && typeof remembered[field] === "number" ? remembered[field] : channelValue(channel, t, r, v, w);
|
|
624
|
+
const value = remembered !== void 0 && typeof remembered[field] === "number" ? remembered[field] : channelValue(channel, pose.t, pose.r, pose.v, pose.w);
|
|
626
625
|
out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
|
|
627
626
|
}
|
|
628
627
|
return out;
|
|
@@ -641,15 +640,13 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
641
640
|
* authoritative state directly), but nothing in the local world can touch them.
|
|
642
641
|
*/
|
|
643
642
|
reconcileBodies() {
|
|
644
|
-
|
|
645
|
-
const rapier = this.rapier;
|
|
646
|
-
if (!world || !rapier) return;
|
|
643
|
+
if (!this.worldReady) return;
|
|
647
644
|
const me = this.meOf();
|
|
648
645
|
const live = /* @__PURE__ */ new Set();
|
|
649
646
|
let nonOwned = 0;
|
|
650
647
|
let overCap = 0;
|
|
651
648
|
const overCapBy = /* @__PURE__ */ new Map();
|
|
652
|
-
const cap = this.
|
|
649
|
+
const cap = this.tuning.maxPredictedBodies ?? MAX_PREDICTED_BODIES;
|
|
653
650
|
for (const desc of this.collections) {
|
|
654
651
|
const coll = this.store.plainCollection(desc.name);
|
|
655
652
|
for (const id of coll.ids()) {
|
|
@@ -678,18 +675,16 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
678
675
|
this.authorityTicks.delete(k);
|
|
679
676
|
this.predictedTicks.delete(k);
|
|
680
677
|
this.history.delete(k);
|
|
681
|
-
this.
|
|
678
|
+
this.adapter.removeBody(entry.body);
|
|
682
679
|
}
|
|
683
680
|
}
|
|
684
681
|
createBody(desc, id, record, owned) {
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
if (!
|
|
688
|
-
const factory = this.options.bodies?.[desc.name];
|
|
689
|
-
if (!factory) {
|
|
682
|
+
if (!this.worldReady) return;
|
|
683
|
+
const option = this.adapter.optionName;
|
|
684
|
+
if (!this.adapter.hasFactory(desc.name)) {
|
|
690
685
|
this.warnOnce(
|
|
691
686
|
`factory:${desc.name}`,
|
|
692
|
-
`irtio:
|
|
687
|
+
`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
688
|
);
|
|
694
689
|
return;
|
|
695
690
|
}
|
|
@@ -700,14 +695,13 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
700
695
|
`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
696
|
);
|
|
702
697
|
}
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
this.warnOnce(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
|
|
698
|
+
const body = this.adapter.createBody(
|
|
699
|
+
desc,
|
|
700
|
+
id,
|
|
701
|
+
record,
|
|
702
|
+
(k, message) => this.warnOnce(k, message)
|
|
703
|
+
);
|
|
704
|
+
if (!body) return;
|
|
711
705
|
const entry = {
|
|
712
706
|
desc,
|
|
713
707
|
id,
|
|
@@ -722,7 +716,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
722
716
|
};
|
|
723
717
|
this.applyRecord(entry, record);
|
|
724
718
|
for (let i = 0; i < POSE_RING; i++) {
|
|
725
|
-
readPose(body, entry.poses[i]);
|
|
719
|
+
this.adapter.readPose(body, entry.poses[i]);
|
|
726
720
|
}
|
|
727
721
|
for (let back = 0; back < POSE_RING; back++) {
|
|
728
722
|
const tick = this.curTick - back;
|
|
@@ -755,49 +749,38 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
755
749
|
// Rebase and free-run
|
|
756
750
|
// -------------------------------------------------------------------------
|
|
757
751
|
/**
|
|
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 |
|
|
752
|
+
* The client's lead over authority, in ticks: a full round trip, rounded to the nearest tick
|
|
753
|
+
* (bugs.md #47, Candidate A).
|
|
774
754
|
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
755
|
+
* The authority in hand left the server one one-way transit ago, so the server is at
|
|
756
|
+
* `authority + owd` right now, and an input flushed now reaches it another one-way transit
|
|
757
|
+
* later: the first tick it can take effect at is `authority + rtt`. The head has to be there
|
|
758
|
+
* for two reasons. The stamp is `head + 1`, and a stamp that names the tick the server will
|
|
759
|
+
* actually apply the write at is what lets the replay put a release where the server put it;
|
|
760
|
+
* with the head at half a round trip (server-now) the stamp trailed the application by
|
|
761
|
+
* `owd - 1` ticks, `stampGap`'s floor at zero discarded the sign, and every release was
|
|
762
|
+
* replayed a one-way transit late: the local body stopped, then followed authority forward for
|
|
763
|
+
* the rest of the round trip. And the intent the local body is simulating under is the intent
|
|
764
|
+
* the server will be simulating under at the same tick, which is the whole point of predicting.
|
|
778
765
|
*
|
|
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.
|
|
766
|
+
* The price is the distance between the drawn body and the authority it is anchored to, which
|
|
767
|
+
* is the size of every misprediction the client has not been told about yet: a full round trip
|
|
768
|
+
* of motion instead of half. dive's lag budget states that distance against the round trip the
|
|
769
|
+
* test measures.
|
|
786
770
|
*
|
|
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.
|
|
771
|
+
* Erring high is still the expensive direction, and the rounding is to nearest for that reason:
|
|
772
|
+
* a lead longer than the real round trip is a tick of motion the client draws on the assumption
|
|
773
|
+
* that the input it is holding will still be held when the server gets there, and on a release
|
|
774
|
+
* that assumption is wrong by construction (bug 1's "keeps moving after key up and then snaps
|
|
775
|
+
* back"). `stats.stampGap` is the running check: it reports how far ahead of the server's
|
|
776
|
+
* application the stamps land, and `stampGapRaw` the same before the clamp. Both should sit
|
|
777
|
+
* between 0 and 1.
|
|
795
778
|
*/
|
|
796
779
|
leadTicks() {
|
|
797
|
-
return Math.max(1, Math.round(this.rttOf() /
|
|
780
|
+
return Math.max(1, Math.round(this.rttOf() / this.timestepMs()));
|
|
798
781
|
}
|
|
799
782
|
timestepMs() {
|
|
800
|
-
return (this.
|
|
783
|
+
return (this.timestepSeconds ?? 1 / 30) * 1e3;
|
|
801
784
|
}
|
|
802
785
|
/**
|
|
803
786
|
* Rebase + re-step: snap every predicted body to the authoritative record (server values —
|
|
@@ -805,13 +788,11 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
805
788
|
* written, so plain state holds exactly what the server said), then re-step the world by the
|
|
806
789
|
* client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
|
|
807
790
|
* 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
|
-
*
|
|
791
|
+
* judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
|
|
792
|
+
* authority and counts (`stats.snaps`).
|
|
810
793
|
*/
|
|
811
794
|
rebase() {
|
|
812
|
-
|
|
813
|
-
const rapier = this.rapier;
|
|
814
|
-
if (!world || !rapier) return;
|
|
795
|
+
if (!this.worldReady) return;
|
|
815
796
|
this.stats.rebases++;
|
|
816
797
|
for (const entry of this.bodies.values()) {
|
|
817
798
|
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
@@ -820,11 +801,18 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
820
801
|
for (const entry of this.bodies.values()) {
|
|
821
802
|
if (!entry.owned) continue;
|
|
822
803
|
const k = key(entry.desc.name, entry.id);
|
|
823
|
-
this.predictedTicks.set(k, this.
|
|
804
|
+
this.predictedTicks.set(k, Math.max(this.authorityTick, this.authorityTicks.get(k) ?? 0));
|
|
824
805
|
}
|
|
825
806
|
const lead = this.leadTicks();
|
|
807
|
+
if (this.lastLead !== void 0 && lead !== this.lastLead && this.gapMeasured) {
|
|
808
|
+
const delta = lead - this.lastLead;
|
|
809
|
+
this.stampGap = Math.min(RESIM_DEPTH, Math.max(0, this.stampGap + delta));
|
|
810
|
+
this.stats.stampGap = this.stampGap;
|
|
811
|
+
this.stats.stampGapRaw += delta;
|
|
812
|
+
}
|
|
813
|
+
this.lastLead = lead;
|
|
826
814
|
this.headTick = this.authorityTick;
|
|
827
|
-
if (lead >
|
|
815
|
+
if (lead > MAX_LEAD) {
|
|
828
816
|
this.stats.snaps++;
|
|
829
817
|
return;
|
|
830
818
|
}
|
|
@@ -840,7 +828,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
840
828
|
}
|
|
841
829
|
const started = performance.now();
|
|
842
830
|
for (let step = 0; step < lead; step++) {
|
|
843
|
-
this.applyIntents(
|
|
831
|
+
this.applyIntents((k) => {
|
|
844
832
|
const r = replays.get(k);
|
|
845
833
|
if (!r) return void 0;
|
|
846
834
|
const t = (this.predictedTicks.get(k) ?? 0) + 1;
|
|
@@ -852,7 +840,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
852
840
|
}
|
|
853
841
|
return r.inForce;
|
|
854
842
|
});
|
|
855
|
-
|
|
843
|
+
this.adapter.step();
|
|
856
844
|
this.recordStep();
|
|
857
845
|
this.headTick++;
|
|
858
846
|
if (lead - step <= POSE_RING) this.capturePoses();
|
|
@@ -868,7 +856,7 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
868
856
|
* until the engine runs or while nothing is owned; the session falls back to its counter.
|
|
869
857
|
*/
|
|
870
858
|
stampTick() {
|
|
871
|
-
if (!this.
|
|
859
|
+
if (!this.worldReady) return void 0;
|
|
872
860
|
let head;
|
|
873
861
|
for (const entry of this.bodies.values()) {
|
|
874
862
|
if (!entry.owned) continue;
|
|
@@ -886,13 +874,11 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
886
874
|
const k = key(entry.desc.name, entry.id);
|
|
887
875
|
const tick = (this.predictedTicks.get(k) ?? 0) + 1;
|
|
888
876
|
this.predictedTicks.set(k, tick);
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
const v = entry.body.linvel();
|
|
892
|
-
const w = entry.body.angvel();
|
|
877
|
+
const pose = this.live;
|
|
878
|
+
this.adapter.readPose(entry.body, pose);
|
|
893
879
|
const record = {};
|
|
894
880
|
for (const [channel, field] of physics.channels) {
|
|
895
|
-
record[field] = channelValue(channel, t, r, v, w);
|
|
881
|
+
record[field] = channelValue(channel, pose.t, pose.r, pose.v, pose.w);
|
|
896
882
|
}
|
|
897
883
|
let byTick = this.history.get(k);
|
|
898
884
|
if (!byTick) {
|
|
@@ -900,14 +886,12 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
900
886
|
this.history.set(k, byTick);
|
|
901
887
|
}
|
|
902
888
|
byTick.set(tick, record);
|
|
903
|
-
byTick.delete(tick -
|
|
889
|
+
byTick.delete(tick - _Predictor.HISTORY_TICKS);
|
|
904
890
|
}
|
|
905
891
|
}
|
|
906
892
|
/** Returns the number of steps taken, so the caller knows whether the head moved. */
|
|
907
893
|
freeRun(now) {
|
|
908
|
-
|
|
909
|
-
const rapier = this.rapier;
|
|
910
|
-
if (!world || !rapier) return 0;
|
|
894
|
+
if (!this.worldReady) return 0;
|
|
911
895
|
if (this.lastNow === void 0) {
|
|
912
896
|
this.lastNow = now;
|
|
913
897
|
return 0;
|
|
@@ -921,8 +905,8 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
921
905
|
let steps = 0;
|
|
922
906
|
while (this.accumulatorMs >= dtMs) {
|
|
923
907
|
this.accumulatorMs -= dtMs;
|
|
924
|
-
this.applyIntents(
|
|
925
|
-
|
|
908
|
+
this.applyIntents(() => void 0);
|
|
909
|
+
this.adapter.step();
|
|
926
910
|
this.recordStep();
|
|
927
911
|
this.headTick++;
|
|
928
912
|
this.capturePoses();
|
|
@@ -936,87 +920,37 @@ var PhysicsPredictor = class _PhysicsPredictor {
|
|
|
936
920
|
* tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
|
|
937
921
|
* current values (free-run: plain state carries the newest local intent writes, which is
|
|
938
922
|
* correct there because free-run steps are the ticks *after* every buffered write).
|
|
923
|
+
*
|
|
924
|
+
* Then, on an engine that declares one, the `settle` pass: a per-step force over **every**
|
|
925
|
+
* local body of a collection, owned or not, run after the whole intent pass rather than
|
|
926
|
+
* interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
|
|
927
|
+
* whose world has none of its own.
|
|
939
928
|
*/
|
|
940
|
-
applyIntents(
|
|
929
|
+
applyIntents(frameFor) {
|
|
941
930
|
for (const entry of this.bodies.values()) {
|
|
942
931
|
if (!entry.owned) continue;
|
|
943
|
-
const hook = this.options.intents?.[entry.desc.name];
|
|
944
|
-
if (!hook) continue;
|
|
945
932
|
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
946
933
|
if (!record) continue;
|
|
947
934
|
const frame = frameFor(key(entry.desc.name, entry.id));
|
|
948
|
-
|
|
935
|
+
this.adapter.applyIntent(
|
|
936
|
+
entry.desc.name,
|
|
937
|
+
entry.body,
|
|
938
|
+
frame ? { ...record, ...frame } : record
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
const settle = this.adapter.settle;
|
|
942
|
+
if (!settle) return;
|
|
943
|
+
for (const entry of this.bodies.values()) {
|
|
944
|
+
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
945
|
+
if (!record) continue;
|
|
946
|
+
settle.call(this.adapter, entry.desc.name, entry.body, record);
|
|
949
947
|
}
|
|
950
948
|
}
|
|
951
949
|
/** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
|
|
952
950
|
applyRecord(entry, record) {
|
|
953
951
|
const physics = entry.desc.physics;
|
|
954
952
|
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);
|
|
953
|
+
this.adapter.applyRecord(entry.body, physics.channels, record);
|
|
1020
954
|
}
|
|
1021
955
|
isF32(desc, field) {
|
|
1022
956
|
const idx = desc.fieldIndex.get(field);
|
|
@@ -1054,6 +988,7 @@ function channelValue(channel, t, r, v, w) {
|
|
|
1054
988
|
return w.z;
|
|
1055
989
|
}
|
|
1056
990
|
}
|
|
991
|
+
|
|
1057
992
|
export {
|
|
1058
|
-
|
|
993
|
+
Predictor
|
|
1059
994
|
};
|