@irtio/client 0.2.0 → 0.4.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-AJHN3YF6.js → chunk-7UTJ7RSF.js} +7 -1
- package/dist/index.d.ts +250 -14
- package/dist/index.js +22 -7
- package/dist/physics-H2VDQLAU.js +1059 -0
- package/package.json +3 -3
- package/dist/physics-BBFSQEYL.js +0 -583
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_PREDICTED_BODIES,
|
|
3
|
+
PREDICTION_EPSILON,
|
|
4
|
+
RESIM_DEPTH,
|
|
5
|
+
SMOOTHING_HALF_LIFE_MS,
|
|
6
|
+
SMOOTHING_SNAP_UNITS
|
|
7
|
+
} from "./chunk-7UTJ7RSF.js";
|
|
8
|
+
|
|
9
|
+
// src/physics.ts
|
|
10
|
+
var MAX_FREE_STEPS_PER_FRAME = 5;
|
|
11
|
+
var FREE_RUN_GRACE = 0.5;
|
|
12
|
+
var POSE_RING = 4;
|
|
13
|
+
var LAG_TARGET_MIN = 0.7;
|
|
14
|
+
var LAG_TARGET_MAX = 1.4;
|
|
15
|
+
var CLOCK_TRIM = 0.12;
|
|
16
|
+
var GAP_ALPHA = 0.3;
|
|
17
|
+
function emptyPose() {
|
|
18
|
+
return {
|
|
19
|
+
t: { x: 0, y: 0, z: 0 },
|
|
20
|
+
r: { x: 0, y: 0, z: 0, w: 1 },
|
|
21
|
+
v: { x: 0, y: 0, z: 0 },
|
|
22
|
+
w: { x: 0, y: 0, z: 0 }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
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
|
+
function copyPose(from, into) {
|
|
45
|
+
into.t.x = from.t.x;
|
|
46
|
+
into.t.y = from.t.y;
|
|
47
|
+
into.t.z = from.t.z;
|
|
48
|
+
into.r.x = from.r.x;
|
|
49
|
+
into.r.y = from.r.y;
|
|
50
|
+
into.r.z = from.r.z;
|
|
51
|
+
into.r.w = from.r.w;
|
|
52
|
+
into.v.x = from.v.x;
|
|
53
|
+
into.v.y = from.v.y;
|
|
54
|
+
into.v.z = from.v.z;
|
|
55
|
+
into.w.x = from.w.x;
|
|
56
|
+
into.w.y = from.w.y;
|
|
57
|
+
into.w.z = from.w.z;
|
|
58
|
+
}
|
|
59
|
+
function quatMul(a, b, out) {
|
|
60
|
+
out.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y;
|
|
61
|
+
out.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x;
|
|
62
|
+
out.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w;
|
|
63
|
+
out.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z;
|
|
64
|
+
}
|
|
65
|
+
function decayQuat(q, k) {
|
|
66
|
+
const s = 1 - k;
|
|
67
|
+
const sign = q.w < 0 ? -1 : 1;
|
|
68
|
+
let x = q.x * s * sign;
|
|
69
|
+
let y = q.y * s * sign;
|
|
70
|
+
let z = q.z * s * sign;
|
|
71
|
+
let w = q.w * s * sign + k;
|
|
72
|
+
const len = Math.hypot(x, y, z, w);
|
|
73
|
+
if (len > 1e-9) {
|
|
74
|
+
x /= len;
|
|
75
|
+
y /= len;
|
|
76
|
+
z /= len;
|
|
77
|
+
w /= len;
|
|
78
|
+
} else {
|
|
79
|
+
x = 0;
|
|
80
|
+
y = 0;
|
|
81
|
+
z = 0;
|
|
82
|
+
w = 1;
|
|
83
|
+
}
|
|
84
|
+
q.x = x;
|
|
85
|
+
q.y = y;
|
|
86
|
+
q.z = z;
|
|
87
|
+
q.w = w;
|
|
88
|
+
}
|
|
89
|
+
function lerpPose(a, b, alpha, out) {
|
|
90
|
+
const k = 1 - alpha;
|
|
91
|
+
out.t.x = a.t.x * k + b.t.x * alpha;
|
|
92
|
+
out.t.y = a.t.y * k + b.t.y * alpha;
|
|
93
|
+
out.t.z = a.t.z * k + b.t.z * alpha;
|
|
94
|
+
out.v.x = a.v.x * k + b.v.x * alpha;
|
|
95
|
+
out.v.y = a.v.y * k + b.v.y * alpha;
|
|
96
|
+
out.v.z = a.v.z * k + b.v.z * alpha;
|
|
97
|
+
out.w.x = a.w.x * k + b.w.x * alpha;
|
|
98
|
+
out.w.y = a.w.y * k + b.w.y * alpha;
|
|
99
|
+
out.w.z = a.w.z * k + b.w.z * alpha;
|
|
100
|
+
const dot = a.r.x * b.r.x + a.r.y * b.r.y + a.r.z * b.r.z + a.r.w * b.r.w;
|
|
101
|
+
const s = dot < 0 ? -alpha : alpha;
|
|
102
|
+
let x = a.r.x * k + b.r.x * s;
|
|
103
|
+
let y = a.r.y * k + b.r.y * s;
|
|
104
|
+
let z = a.r.z * k + b.r.z * s;
|
|
105
|
+
let w = a.r.w * k + b.r.w * s;
|
|
106
|
+
const len = Math.hypot(x, y, z, w);
|
|
107
|
+
if (len > 1e-9) {
|
|
108
|
+
x /= len;
|
|
109
|
+
y /= len;
|
|
110
|
+
z /= len;
|
|
111
|
+
w /= len;
|
|
112
|
+
}
|
|
113
|
+
out.r.x = x;
|
|
114
|
+
out.r.y = y;
|
|
115
|
+
out.r.z = z;
|
|
116
|
+
out.r.w = w;
|
|
117
|
+
}
|
|
118
|
+
function slotOf(tick) {
|
|
119
|
+
return (tick % POSE_RING + POSE_RING) % POSE_RING;
|
|
120
|
+
}
|
|
121
|
+
function poseAt(entry, tick) {
|
|
122
|
+
const slot = slotOf(tick);
|
|
123
|
+
return entry.poseTicks[slot] === tick ? entry.poses[slot] : void 0;
|
|
124
|
+
}
|
|
125
|
+
function key(collection, id) {
|
|
126
|
+
return `${collection}\0${id}`;
|
|
127
|
+
}
|
|
128
|
+
var engine;
|
|
129
|
+
var loading;
|
|
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)) {
|
|
162
|
+
this.store = store;
|
|
163
|
+
this.options = options;
|
|
164
|
+
this.meOf = meOf;
|
|
165
|
+
this.rttOf = rttOf;
|
|
166
|
+
this.tickIntervalOf = tickIntervalOf;
|
|
167
|
+
this.log = log;
|
|
168
|
+
this.collections = ext.collections.filter(
|
|
169
|
+
(c) => c.kind === "entity" && c.physics !== void 0
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
store;
|
|
173
|
+
options;
|
|
174
|
+
meOf;
|
|
175
|
+
rttOf;
|
|
176
|
+
tickIntervalOf;
|
|
177
|
+
log;
|
|
178
|
+
stats = {
|
|
179
|
+
freeSteps: 0,
|
|
180
|
+
resimSteps: 0,
|
|
181
|
+
rebases: 0,
|
|
182
|
+
snaps: 0,
|
|
183
|
+
suppressed: 0,
|
|
184
|
+
overCap: 0,
|
|
185
|
+
lastResimMicros: 0,
|
|
186
|
+
smoothing: 0,
|
|
187
|
+
stampGap: 0
|
|
188
|
+
};
|
|
189
|
+
rapier;
|
|
190
|
+
world;
|
|
191
|
+
bodies = /* @__PURE__ */ new Map();
|
|
192
|
+
/** Physics-backed entity collections, in schema order. */
|
|
193
|
+
collections;
|
|
194
|
+
warned = /* @__PURE__ */ new Set();
|
|
195
|
+
/** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
|
|
196
|
+
overCapHigh = /* @__PURE__ */ new Map();
|
|
197
|
+
/** Authority arrived since the last frame: rebase before free-running. */
|
|
198
|
+
authorityDirty = false;
|
|
199
|
+
accumulatorMs = 0;
|
|
200
|
+
lastNow;
|
|
201
|
+
lastFrameNow;
|
|
202
|
+
failed = false;
|
|
203
|
+
/**
|
|
204
|
+
* The local world's head, on the server's tick timeline: the authoritative tick the last
|
|
205
|
+
* rebase started from plus the lead it re-stepped, then one per free-run step.
|
|
206
|
+
*/
|
|
207
|
+
headTick = 0;
|
|
208
|
+
/** The newest server tick any authority has been seen for. `headTick`'s base. */
|
|
209
|
+
authorityTick = 0;
|
|
210
|
+
/** The newest tick a pose has been stored for. `-1` until the first capture. */
|
|
211
|
+
curTick = -1;
|
|
212
|
+
/**
|
|
213
|
+
* Where the renderer is, as a fractional tick. Advanced by wall time every frame and steered
|
|
214
|
+
* to sit about a tick behind `curTick`; `read()` draws between the stored poses either side
|
|
215
|
+
* of it.
|
|
216
|
+
*/
|
|
217
|
+
renderTick;
|
|
218
|
+
/** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
|
|
219
|
+
scratch = emptyPose();
|
|
220
|
+
/** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
|
|
221
|
+
qa = { x: 0, y: 0, z: 0, w: 1 };
|
|
222
|
+
qb = { x: 0, y: 0, z: 0, w: 1 };
|
|
223
|
+
qc = { x: 0, y: 0, z: 0, w: 1 };
|
|
224
|
+
/**
|
|
225
|
+
* Per owned body: the server tick its authoritative record is *from* (the tick of the last
|
|
226
|
+
* `CORRECT` that touched it), and the per-tick prediction history — after every local step,
|
|
227
|
+
* the body's channels are recorded under the tick that step predicted. A correction for tick T
|
|
228
|
+
* is then judged against the client's prediction **for tick T**, not against the current head
|
|
229
|
+
* of the simulation (which legitimately leads authority by the whole latency — comparing
|
|
230
|
+
* against it would report the lead as misprediction).
|
|
231
|
+
*/
|
|
232
|
+
authorityTicks = /* @__PURE__ */ new Map();
|
|
233
|
+
/** Smoothed stamp-minus-applied gap; see `noteWriteApplied`. */
|
|
234
|
+
stampGap = 0;
|
|
235
|
+
gapMeasured = false;
|
|
236
|
+
/** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
|
|
237
|
+
gapSampledThrough = 0;
|
|
238
|
+
predictedTicks = /* @__PURE__ */ new Map();
|
|
239
|
+
history = /* @__PURE__ */ new Map();
|
|
240
|
+
/** History kept per body — comfortably past the resim depth. */
|
|
241
|
+
static HISTORY_TICKS = 32;
|
|
242
|
+
get epsilon() {
|
|
243
|
+
return this.options.epsilon ?? PREDICTION_EPSILON;
|
|
244
|
+
}
|
|
245
|
+
/** `true` once the engine is loaded and the local world exists. */
|
|
246
|
+
get ready() {
|
|
247
|
+
return this.world !== void 0;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
|
|
251
|
+
* interpolation/authority — joining is never blocked on 2.9 MB of WASM.
|
|
252
|
+
*/
|
|
253
|
+
start() {
|
|
254
|
+
if (this.world || this.failed) return Promise.resolve();
|
|
255
|
+
return loadEngine().then(
|
|
256
|
+
(rapier) => {
|
|
257
|
+
this.rapier = rapier;
|
|
258
|
+
const world = new rapier.World({ ...this.options.gravity });
|
|
259
|
+
const tickMs = this.tickIntervalOf();
|
|
260
|
+
world.timestep = this.options.timestep ?? (tickMs > 0 ? tickMs / 1e3 : 1 / 30);
|
|
261
|
+
if (this.options.setup) this.options.setup(world, rapier);
|
|
262
|
+
this.world = world;
|
|
263
|
+
this.authorityDirty = true;
|
|
264
|
+
},
|
|
265
|
+
(err) => {
|
|
266
|
+
this.failed = true;
|
|
267
|
+
this.log(
|
|
268
|
+
`irtio: physics prediction disabled \u2014 @dimforge/rapier3d-compat failed to load: ${err instanceof Error ? err.message : String(err)}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
free() {
|
|
274
|
+
this.bodies.clear();
|
|
275
|
+
this.world?.free();
|
|
276
|
+
this.world = void 0;
|
|
277
|
+
}
|
|
278
|
+
/** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
|
|
279
|
+
reset() {
|
|
280
|
+
this.authorityDirty = true;
|
|
281
|
+
this.accumulatorMs = 0;
|
|
282
|
+
this.lastNow = void 0;
|
|
283
|
+
this.renderTick = void 0;
|
|
284
|
+
this.curTick = -1;
|
|
285
|
+
for (const entry of this.bodies.values()) {
|
|
286
|
+
entry.poseTicks.fill(-1);
|
|
287
|
+
entry.preValid = false;
|
|
288
|
+
this.clearOffset(entry);
|
|
289
|
+
}
|
|
290
|
+
this.stampGap = 0;
|
|
291
|
+
this.gapMeasured = false;
|
|
292
|
+
this.gapSampledThrough = 0;
|
|
293
|
+
this.stats.stampGap = 0;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one).
|
|
297
|
+
*
|
|
298
|
+
* `tick` is the server tick it carried, and it is the base of the predictor's own head clock —
|
|
299
|
+
* which the renderer's interpolation is paced against. It has to be the *world's* clock rather
|
|
300
|
+
* than any one body's: a client with nothing of its own to own still draws a world full of
|
|
301
|
+
* predicted bodies.
|
|
302
|
+
*/
|
|
303
|
+
noteAuthority(tick) {
|
|
304
|
+
this.authorityDirty = true;
|
|
305
|
+
if (tick !== void 0 && tick > this.authorityTick) this.authorityTick = tick;
|
|
306
|
+
}
|
|
307
|
+
/** The session tells us which server tick one body's authoritative record is from. */
|
|
308
|
+
noteAuthorityTick(collection, id, tick) {
|
|
309
|
+
this.authorityTicks.set(key(collection, id), tick);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* A `CORRECT` reported both halves of a judged write: the stamp the client put on it and the
|
|
313
|
+
* server tick it was actually applied at (bug 1). The gap between them is the systematic error
|
|
314
|
+
* in the rebase's replay, and it is not zero even on a loopback: the stamp is the predictor's
|
|
315
|
+
* *head* (authority + lead), while the server applies on arrival, at a tick barely past the
|
|
316
|
+
* authority the client is replaying from. Replaying at the stamp therefore held the previous
|
|
317
|
+
* intent for `gap` ticks the server had already simulated under the new one — the overshoot on
|
|
318
|
+
* every key release, sized `gap x speed`.
|
|
319
|
+
*
|
|
320
|
+
* Smoothed, because it rides on delivery jitter and the measurement is one sample per judged
|
|
321
|
+
* write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
|
|
322
|
+
* authority is on the session's bare write counter rather than the server's tick stream, and
|
|
323
|
+
* differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
|
|
324
|
+
*/
|
|
325
|
+
noteWriteApplied(stampTick, appliedTick) {
|
|
326
|
+
if (stampTick <= 0 || appliedTick <= 0 || stampTick <= this.gapSampledThrough) return;
|
|
327
|
+
this.gapSampledThrough = stampTick;
|
|
328
|
+
const sample = Math.min(RESIM_DEPTH, Math.max(0, stampTick - appliedTick));
|
|
329
|
+
this.stampGap = this.gapMeasured ? this.stampGap + (sample - this.stampGap) * GAP_ALPHA : sample;
|
|
330
|
+
this.gapMeasured = true;
|
|
331
|
+
this.stats.stampGap = this.stampGap;
|
|
332
|
+
}
|
|
333
|
+
/** Does the local world currently simulate `collection[id]`? */
|
|
334
|
+
has(collection, id) {
|
|
335
|
+
return this.bodies.has(key(collection, id));
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Is a correction's every value within the suppression tolerance of the prediction it judges?
|
|
339
|
+
* Position (and rotation) channels compare against `epsilon` directly; velocity and angular
|
|
340
|
+
* channels against `epsilon / timestep`, because a velocity disagreement matters by what it
|
|
341
|
+
* moves in one tick.
|
|
342
|
+
*/
|
|
343
|
+
withinEpsilon(desc, fields, patch, predicted) {
|
|
344
|
+
const physics = desc.physics;
|
|
345
|
+
if (!physics || fields.length === 0) return false;
|
|
346
|
+
const eps = this.epsilon;
|
|
347
|
+
const dt = this.world?.timestep ?? 1 / 30;
|
|
348
|
+
const channelOf = new Map(physics.channels.map(([c, f]) => [f, c]));
|
|
349
|
+
return fields.every((f) => {
|
|
350
|
+
const a = predicted[f];
|
|
351
|
+
const b = patch[f];
|
|
352
|
+
if (typeof a !== "number" || typeof b !== "number") return false;
|
|
353
|
+
const channel = channelOf.get(f);
|
|
354
|
+
const velocity = channel?.startsWith("v") === true || channel?.startsWith("w") === true;
|
|
355
|
+
const tolerance = velocity ? eps / dt : eps;
|
|
356
|
+
return Math.abs(a - b) <= tolerance;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
|
|
360
|
+
predictsCollection(name) {
|
|
361
|
+
return this.collections.some((c) => c.name === name);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Advances the local world to `now`. Driven by render reads (one pass per timestamp), so a
|
|
365
|
+
* draw loop — or a bot's render sampling — is the clock; there is no timer.
|
|
366
|
+
*/
|
|
367
|
+
frame(now) {
|
|
368
|
+
if (!this.world || now === this.lastFrameNow && !this.authorityDirty) return;
|
|
369
|
+
const elapsedMs = this.lastFrameNow === void 0 ? 0 : Math.max(0, now - this.lastFrameNow);
|
|
370
|
+
this.advanceRenderClock(now);
|
|
371
|
+
this.lastFrameNow = now;
|
|
372
|
+
this.reconcileBodies();
|
|
373
|
+
this.decayOffsets(elapsedMs);
|
|
374
|
+
this.snapshotDrawn();
|
|
375
|
+
if (this.authorityDirty) {
|
|
376
|
+
this.authorityDirty = false;
|
|
377
|
+
this.rebase();
|
|
378
|
+
this.accumulatorMs = -FREE_RUN_GRACE * this.timestepMs();
|
|
379
|
+
this.lastNow = now;
|
|
380
|
+
if (this.curTick < this.headTick) this.capturePoses();
|
|
381
|
+
} else {
|
|
382
|
+
this.freeRun(now);
|
|
383
|
+
}
|
|
384
|
+
this.holdRenderTick();
|
|
385
|
+
this.absorbJump();
|
|
386
|
+
}
|
|
387
|
+
// -------------------------------------------------------------------------
|
|
388
|
+
// The render clock
|
|
389
|
+
// -------------------------------------------------------------------------
|
|
390
|
+
/**
|
|
391
|
+
* The head moved: store every predicted body's pose under the tick it now holds.
|
|
392
|
+
*
|
|
393
|
+
* A tick can be simulated more than once — a `DELTA` and a `CORRECT` both arrive for the same
|
|
394
|
+
* tick and each rebases — so the slot is simply rewritten with the better answer. It can also
|
|
395
|
+
* move backwards, when a free-run step is discarded by the rebase behind it; `curTick` does
|
|
396
|
+
* not follow it down, because moving the newest label backwards would drag the segment the
|
|
397
|
+
* renderer is crossing backwards with it.
|
|
398
|
+
*/
|
|
399
|
+
capturePoses() {
|
|
400
|
+
const tick = this.headTick;
|
|
401
|
+
const slot = slotOf(tick);
|
|
402
|
+
for (const entry of this.bodies.values()) {
|
|
403
|
+
readPose(entry.body, entry.poses[slot]);
|
|
404
|
+
entry.poseTicks[slot] = tick;
|
|
405
|
+
}
|
|
406
|
+
if (tick > this.curTick) this.curTick = tick;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Advance the render clock by one frame of wall time, steering it towards a target distance
|
|
410
|
+
* behind the head rather than clamping it there.
|
|
411
|
+
*
|
|
412
|
+
* `renderTick` and `curTick` agree on rate — one tick per tick period — but not on phase, and
|
|
413
|
+
* the phase error is delivery jitter: authority arrives when the network hands it over, not
|
|
414
|
+
* every 33 ms however steady the server is. Left alone the two would wander apart, so the
|
|
415
|
+
* clock is steered, by rate rather than by position. What the network did to a packet is not
|
|
416
|
+
* something the player's character should be seen doing.
|
|
417
|
+
*/
|
|
418
|
+
advanceRenderClock(now) {
|
|
419
|
+
if (this.renderTick === void 0 || this.lastFrameNow === void 0) return;
|
|
420
|
+
const period = this.timestepMs();
|
|
421
|
+
if (period <= 0) return;
|
|
422
|
+
const lag = this.curTick - this.renderTick;
|
|
423
|
+
const rate = lag > LAG_TARGET_MAX ? 1 + CLOCK_TRIM : lag < LAG_TARGET_MIN ? 1 - CLOCK_TRIM : 1;
|
|
424
|
+
this.renderTick += Math.max(0, now - this.lastFrameNow) / period * rate;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Keep the render clock inside the span there are poses for.
|
|
428
|
+
*
|
|
429
|
+
* Both ends are real. At `curTick` there is nothing further to draw towards, so a late arrival
|
|
430
|
+
* holds the pose rather than extrapolating motion the next tick would have to take back — the
|
|
431
|
+
* same trade that sized the prediction lead (bug 1). At the far end the ring has recycled the
|
|
432
|
+
* slot and the pose is genuinely gone. Between them the clock floats, and the rate trim above
|
|
433
|
+
* is what keeps it from spending its time against either stop.
|
|
434
|
+
*/
|
|
435
|
+
holdRenderTick() {
|
|
436
|
+
if (this.renderTick === void 0) {
|
|
437
|
+
this.renderTick = this.curTick;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const oldest = this.curTick - (POSE_RING - 1);
|
|
441
|
+
if (this.renderTick > this.curTick) this.renderTick = this.curTick;
|
|
442
|
+
else if (this.renderTick < oldest) this.renderTick = oldest;
|
|
443
|
+
}
|
|
444
|
+
/** `smoothingHalfLifeMs`, or 0 when the game turned the smoothing off. */
|
|
445
|
+
halfLifeMs() {
|
|
446
|
+
return Math.max(0, this.options.smoothingHalfLifeMs ?? SMOOTHING_HALF_LIFE_MS);
|
|
447
|
+
}
|
|
448
|
+
/** Remember where every body is being drawn, before this frame re-simulates anything. */
|
|
449
|
+
snapshotDrawn() {
|
|
450
|
+
if (this.halfLifeMs() === 0) return;
|
|
451
|
+
const from = this.renderTick === void 0 ? void 0 : Math.floor(this.renderTick);
|
|
452
|
+
for (const entry of this.bodies.values()) {
|
|
453
|
+
entry.preValid = from !== void 0 && poseAt(entry, from) !== void 0;
|
|
454
|
+
if (entry.preValid) copyPose(this.rawPose(entry), entry.pre);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Take whatever the re-simulation moved the drawn pose by and put it in the offset instead.
|
|
459
|
+
*
|
|
460
|
+
* This is the whole of correction smoothing, and it is smoothing the *error*: the difference
|
|
461
|
+
* measured here is between two answers to the same question — where is this body at the render
|
|
462
|
+
* clock — asked either side of a rebase. Motion the simulation produced is not in it, because
|
|
463
|
+
* the render clock advanced before the snapshot. So real movement is never slowed, which is
|
|
464
|
+
* the thing a rate limiter on the drawn position cannot promise: it sees a position that moved
|
|
465
|
+
* and has no way to know whether the body ran or was corrected.
|
|
466
|
+
*
|
|
467
|
+
* It absorbs more than server disagreement. A newly flushed intent changes what the last few
|
|
468
|
+
* ticks should have been, and the replay hands that over as a jump the same way; so does the
|
|
469
|
+
* render clock being pulled back inside the poses it has. All of it is the same defect from
|
|
470
|
+
* the player's side — the character was drawn somewhere it now turns out it was not — and all
|
|
471
|
+
* of it eases away here.
|
|
472
|
+
*/
|
|
473
|
+
absorbJump() {
|
|
474
|
+
const halfLife = this.halfLifeMs();
|
|
475
|
+
if (halfLife === 0) return;
|
|
476
|
+
const snap = Math.max(0, this.options.smoothingSnapUnits ?? SMOOTHING_SNAP_UNITS);
|
|
477
|
+
const eps = this.epsilon;
|
|
478
|
+
let worst = 0;
|
|
479
|
+
for (const entry of this.bodies.values()) {
|
|
480
|
+
if (!entry.preValid) continue;
|
|
481
|
+
entry.preValid = false;
|
|
482
|
+
const after = this.rawPose(entry);
|
|
483
|
+
const dx = entry.pre.t.x - after.t.x;
|
|
484
|
+
const dy = entry.pre.t.y - after.t.y;
|
|
485
|
+
const dz = entry.pre.t.z - after.t.z;
|
|
486
|
+
if (Math.abs(dx) + Math.abs(dy) + Math.abs(dz) >= eps) {
|
|
487
|
+
entry.offset.x += dx;
|
|
488
|
+
entry.offset.y += dy;
|
|
489
|
+
entry.offset.z += dz;
|
|
490
|
+
}
|
|
491
|
+
if (entry.pre.r.x !== after.r.x || entry.pre.r.y !== after.r.y || entry.pre.r.z !== after.r.z || entry.pre.r.w !== after.r.w) {
|
|
492
|
+
this.qa.x = -after.r.x;
|
|
493
|
+
this.qa.y = -after.r.y;
|
|
494
|
+
this.qa.z = -after.r.z;
|
|
495
|
+
this.qa.w = after.r.w;
|
|
496
|
+
quatMul(entry.pre.r, this.qa, this.qb);
|
|
497
|
+
quatMul(entry.rotOffset, this.qb, this.qc);
|
|
498
|
+
entry.rotOffset.x = this.qc.x;
|
|
499
|
+
entry.rotOffset.y = this.qc.y;
|
|
500
|
+
entry.rotOffset.z = this.qc.z;
|
|
501
|
+
entry.rotOffset.w = this.qc.w;
|
|
502
|
+
}
|
|
503
|
+
const size = Math.hypot(entry.offset.x, entry.offset.y, entry.offset.z);
|
|
504
|
+
if (size > snap) this.clearOffset(entry);
|
|
505
|
+
else if (size > worst) worst = size;
|
|
506
|
+
}
|
|
507
|
+
this.stats.smoothing = worst;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Ease every offset towards zero. Exponential, so the rate is proportional to the error and
|
|
511
|
+
* there is no world-scale speed to pick: a correction worth a tenth of a unit is worked off
|
|
512
|
+
* gently and one worth a whole unit is not left hanging around for a second.
|
|
513
|
+
*/
|
|
514
|
+
decayOffsets(elapsedMs) {
|
|
515
|
+
const halfLife = this.halfLifeMs();
|
|
516
|
+
if (halfLife === 0 || elapsedMs <= 0) return;
|
|
517
|
+
const k = 1 - 2 ** (-elapsedMs / halfLife);
|
|
518
|
+
for (const entry of this.bodies.values()) {
|
|
519
|
+
const o = entry.offset;
|
|
520
|
+
if (o.x !== 0 || o.y !== 0 || o.z !== 0) {
|
|
521
|
+
o.x -= o.x * k;
|
|
522
|
+
o.y -= o.y * k;
|
|
523
|
+
o.z -= o.z * k;
|
|
524
|
+
if (Math.abs(o.x) < 1e-6) o.x = 0;
|
|
525
|
+
if (Math.abs(o.y) < 1e-6) o.y = 0;
|
|
526
|
+
if (Math.abs(o.z) < 1e-6) o.z = 0;
|
|
527
|
+
}
|
|
528
|
+
if (entry.rotOffset.w < 1 - 1e-9) decayQuat(entry.rotOffset, k);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
clearOffset(entry) {
|
|
532
|
+
entry.offset.x = 0;
|
|
533
|
+
entry.offset.y = 0;
|
|
534
|
+
entry.offset.z = 0;
|
|
535
|
+
entry.rotOffset.x = 0;
|
|
536
|
+
entry.rotOffset.y = 0;
|
|
537
|
+
entry.rotOffset.z = 0;
|
|
538
|
+
entry.rotOffset.w = 1;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The pose to draw one body at: the stored poses either side of the render clock, blended.
|
|
542
|
+
* Falls back to the body's live transform when the ring cannot bracket it — before the first
|
|
543
|
+
* capture, and for a body that appeared this frame.
|
|
544
|
+
*/
|
|
545
|
+
rawPose(entry) {
|
|
546
|
+
const rt = this.renderTick;
|
|
547
|
+
if (rt !== void 0) {
|
|
548
|
+
const from = Math.floor(rt);
|
|
549
|
+
const a = poseAt(entry, from);
|
|
550
|
+
const b = poseAt(entry, from + 1);
|
|
551
|
+
if (a && b) {
|
|
552
|
+
lerpPose(a, b, rt - from, this.scratch);
|
|
553
|
+
return this.scratch;
|
|
554
|
+
}
|
|
555
|
+
if (a) {
|
|
556
|
+
copyPose(a, this.scratch);
|
|
557
|
+
return this.scratch;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
readPose(entry.body, this.scratch);
|
|
561
|
+
return this.scratch;
|
|
562
|
+
}
|
|
563
|
+
/** `rawPose` with the render-error offset applied — what the draw loop actually gets. */
|
|
564
|
+
renderPose(entry) {
|
|
565
|
+
const pose = this.rawPose(entry);
|
|
566
|
+
const o = entry.offset;
|
|
567
|
+
if (o.x !== 0 || o.y !== 0 || o.z !== 0) {
|
|
568
|
+
pose.t.x += o.x;
|
|
569
|
+
pose.t.y += o.y;
|
|
570
|
+
pose.t.z += o.z;
|
|
571
|
+
}
|
|
572
|
+
if (entry.rotOffset.w < 1 - 1e-9) {
|
|
573
|
+
quatMul(entry.rotOffset, pose.r, this.qa);
|
|
574
|
+
pose.r.x = this.qa.x;
|
|
575
|
+
pose.r.y = this.qa.y;
|
|
576
|
+
pose.r.z = this.qa.z;
|
|
577
|
+
pose.r.w = this.qa.w;
|
|
578
|
+
}
|
|
579
|
+
return pose;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* The predicted record for `collection[id]`: the authoritative record (which already carries
|
|
583
|
+
* local intent writes — `track()` writes through to plain state) with the body-mapped channels
|
|
584
|
+
* replaced by the local world's values, `Math.fround`ed for f32 fields so what the draw loop
|
|
585
|
+
* reads is what the wire would carry.
|
|
586
|
+
*
|
|
587
|
+
* Drawn *between* two simulated ticks, not at the head. The local world steps at the room's
|
|
588
|
+
* tick rate, and only when authority arrives to rebase it; a display runs at two to four times
|
|
589
|
+
* that and asks on every frame. Handing back the head means the answer changes on some frames
|
|
590
|
+
* and not others, so a character running at a steady nine units a second is drawn standing
|
|
591
|
+
* still for half of them and moving at twenty for the rest. It is the player's own character
|
|
592
|
+
* answering their own key, and it reads as the game hitching.
|
|
593
|
+
*
|
|
594
|
+
* This is a *render* read. `room.state` and every correctness path — `predictedValues`, the
|
|
595
|
+
* per-tick history a correction is judged against — still see the head exactly as before.
|
|
596
|
+
*/
|
|
597
|
+
read(desc, id, base) {
|
|
598
|
+
const entry = this.bodies.get(key(desc.name, id));
|
|
599
|
+
const physics = desc.physics;
|
|
600
|
+
if (!entry || !physics) return base;
|
|
601
|
+
const out = { ...base };
|
|
602
|
+
const pose = this.renderPose(entry);
|
|
603
|
+
for (const [channel, field] of physics.channels) {
|
|
604
|
+
const value = channelValue(channel, pose.t, pose.r, pose.v, pose.w);
|
|
605
|
+
out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The local world's current values for `fields` of one body — what a correction is judged
|
|
611
|
+
* against (`previous` on the `correct` event, and the epsilon-suppression comparison).
|
|
612
|
+
*/
|
|
613
|
+
predictedValues(desc, id, fields, atTick) {
|
|
614
|
+
const entry = this.bodies.get(key(desc.name, id));
|
|
615
|
+
const physics = desc.physics;
|
|
616
|
+
if (!entry || !physics) return void 0;
|
|
617
|
+
const remembered = atTick !== void 0 ? this.history.get(key(desc.name, id))?.get(atTick) : void 0;
|
|
618
|
+
const t = entry.body.translation();
|
|
619
|
+
const r = entry.body.rotation();
|
|
620
|
+
const v = entry.body.linvel();
|
|
621
|
+
const w = entry.body.angvel();
|
|
622
|
+
const out = {};
|
|
623
|
+
for (const [channel, field] of physics.channels) {
|
|
624
|
+
if (!fields.includes(field)) continue;
|
|
625
|
+
const value = remembered !== void 0 && typeof remembered[field] === "number" ? remembered[field] : channelValue(channel, t, r, v, w);
|
|
626
|
+
out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
|
|
627
|
+
}
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
// -------------------------------------------------------------------------
|
|
631
|
+
// Membership
|
|
632
|
+
// -------------------------------------------------------------------------
|
|
633
|
+
/**
|
|
634
|
+
* Mirrors the local world's bodies onto the instances this client predicts: every physics
|
|
635
|
+
* instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
|
|
636
|
+
* (collection order, then insertion order — the same stated iteration guarantee the server
|
|
637
|
+
* follows, so which bodies fall over the cap is deterministic).
|
|
638
|
+
*
|
|
639
|
+
* Everything else — non-`predicted` collections, and `predicted` instances over the cap — gets
|
|
640
|
+
* no body and no collider here. Those instances still render (the interpolation path reads
|
|
641
|
+
* authoritative state directly), but nothing in the local world can touch them.
|
|
642
|
+
*/
|
|
643
|
+
reconcileBodies() {
|
|
644
|
+
const world = this.world;
|
|
645
|
+
const rapier = this.rapier;
|
|
646
|
+
if (!world || !rapier) return;
|
|
647
|
+
const me = this.meOf();
|
|
648
|
+
const live = /* @__PURE__ */ new Set();
|
|
649
|
+
let nonOwned = 0;
|
|
650
|
+
let overCap = 0;
|
|
651
|
+
const overCapBy = /* @__PURE__ */ new Map();
|
|
652
|
+
const cap = this.options.maxPredictedBodies ?? MAX_PREDICTED_BODIES;
|
|
653
|
+
for (const desc of this.collections) {
|
|
654
|
+
const coll = this.store.plainCollection(desc.name);
|
|
655
|
+
for (const id of coll.ids()) {
|
|
656
|
+
const owned = coll.ownerOf(id) === me && me !== "";
|
|
657
|
+
if (!owned) {
|
|
658
|
+
if (!desc.predicted) continue;
|
|
659
|
+
if (nonOwned >= cap) {
|
|
660
|
+
overCap++;
|
|
661
|
+
overCapBy.set(desc.name, (overCapBy.get(desc.name) ?? 0) + 1);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
nonOwned++;
|
|
665
|
+
}
|
|
666
|
+
const k = key(desc.name, id);
|
|
667
|
+
live.add(k);
|
|
668
|
+
if (this.bodies.has(k)) continue;
|
|
669
|
+
const record = coll.get(id);
|
|
670
|
+
if (record) this.createBody(desc, id, record, owned);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
this.stats.overCap = overCap;
|
|
674
|
+
this.warnOverCap(cap, overCapBy);
|
|
675
|
+
for (const [k, entry] of [...this.bodies]) {
|
|
676
|
+
if (live.has(k)) continue;
|
|
677
|
+
this.bodies.delete(k);
|
|
678
|
+
this.authorityTicks.delete(k);
|
|
679
|
+
this.predictedTicks.delete(k);
|
|
680
|
+
this.history.delete(k);
|
|
681
|
+
this.world?.removeRigidBody(entry.body);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
createBody(desc, id, record, owned) {
|
|
685
|
+
const world = this.world;
|
|
686
|
+
const rapier = this.rapier;
|
|
687
|
+
if (!world || !rapier) return;
|
|
688
|
+
const factory = this.options.bodies?.[desc.name];
|
|
689
|
+
if (!factory) {
|
|
690
|
+
this.warnOnce(
|
|
691
|
+
`factory:${desc.name}`,
|
|
692
|
+
`irtio: physics.bodies.${desc.name} is missing from joinRoom({ physics }). ${desc.name} instances render by interpolation but have no body in the local world, so predicted bodies pass through them.`
|
|
693
|
+
);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const physics = desc.physics;
|
|
697
|
+
if (physics && !physics.channels.some(([c]) => c.startsWith("v"))) {
|
|
698
|
+
this.warnOnce(
|
|
699
|
+
`velocity:${desc.name}`,
|
|
700
|
+
`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
|
+
);
|
|
702
|
+
}
|
|
703
|
+
const spec = factory(rapier, record, id);
|
|
704
|
+
if (!spec || !spec.body) return;
|
|
705
|
+
const planar = planarLockWarning(spec);
|
|
706
|
+
if (planar !== void 0) {
|
|
707
|
+
this.warnOnce(`planar:${desc.name}`, `irtio: physics.bodies.${desc.name} ${planar}`);
|
|
708
|
+
}
|
|
709
|
+
const body = world.createRigidBody(spec.body);
|
|
710
|
+
for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
|
|
711
|
+
const entry = {
|
|
712
|
+
desc,
|
|
713
|
+
id,
|
|
714
|
+
body,
|
|
715
|
+
owned,
|
|
716
|
+
poses: Array.from({ length: POSE_RING }, emptyPose),
|
|
717
|
+
poseTicks: new Array(POSE_RING).fill(-1),
|
|
718
|
+
offset: { x: 0, y: 0, z: 0 },
|
|
719
|
+
rotOffset: { x: 0, y: 0, z: 0, w: 1 },
|
|
720
|
+
pre: emptyPose(),
|
|
721
|
+
preValid: false
|
|
722
|
+
};
|
|
723
|
+
this.applyRecord(entry, record);
|
|
724
|
+
for (let i = 0; i < POSE_RING; i++) {
|
|
725
|
+
readPose(body, entry.poses[i]);
|
|
726
|
+
}
|
|
727
|
+
for (let back = 0; back < POSE_RING; back++) {
|
|
728
|
+
const tick = this.curTick - back;
|
|
729
|
+
if (tick < 0) break;
|
|
730
|
+
entry.poseTicks[slotOf(tick)] = tick;
|
|
731
|
+
}
|
|
732
|
+
this.bodies.set(key(desc.name, id), entry);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Over-cap is not a one-time tuning notice: it means those instances are missing from the local
|
|
736
|
+
* world right now, so a predicted body walks through them. Warn on every new high-water mark
|
|
737
|
+
* per collection — a game that grows past the cap mid-session hears about it, and a count that
|
|
738
|
+
* oscillates around one level does not turn the console into a log.
|
|
739
|
+
*/
|
|
740
|
+
warnOverCap(cap, overCapBy) {
|
|
741
|
+
for (const [name, count] of overCapBy) {
|
|
742
|
+
if (count <= (this.overCapHigh.get(name) ?? 0)) continue;
|
|
743
|
+
this.overCapHigh.set(name, count);
|
|
744
|
+
this.log(
|
|
745
|
+
`irtio: ${count} ${name} instance(s) over the ${cap}-body prediction 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 maxPredictedBodies (the cost is a world step per lead tick, not a per-body-per-frame cost), or stop predicting what nothing collides with.`
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
warnOnce(k, message) {
|
|
750
|
+
if (this.warned.has(k)) return;
|
|
751
|
+
this.warned.add(k);
|
|
752
|
+
this.log(message);
|
|
753
|
+
}
|
|
754
|
+
// -------------------------------------------------------------------------
|
|
755
|
+
// Rebase and free-run
|
|
756
|
+
// -------------------------------------------------------------------------
|
|
757
|
+
/**
|
|
758
|
+
* The client's lead over authority, in ticks: the one-way transit the authority in hand has
|
|
759
|
+
* already spent in flight, rounded to the nearest tick. Nothing more.
|
|
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 |
|
|
774
|
+
*
|
|
775
|
+
* One tick of running is 0.3 u there: the overshoot is the lead, in ticks, and nothing else.
|
|
776
|
+
* On a link with no transit to cover, every tick of lead is a tick of guessing that the key is
|
|
777
|
+
* still held.
|
|
778
|
+
*
|
|
779
|
+
* The floor of 1 is not margin, it is the smallest lead that is still prediction. At 0 the
|
|
780
|
+
* rebase takes no steps: the local world is pinned to the authority it just received, which is
|
|
781
|
+
* already a tick old, so the client responds to its own input only when the next `CORRECT`
|
|
782
|
+
* arrives and every moving body reads as mispredicted by one tick of motion. Measured: the
|
|
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.
|
|
786
|
+
*
|
|
787
|
+
* Erring low is also the cheap direction — a lead shorter than the transit means authority
|
|
788
|
+
* arrives slightly *ahead* of the prediction, and a correction that pulls the character the way
|
|
789
|
+
* it is already going is the one nobody can see.
|
|
790
|
+
*
|
|
791
|
+
* Measured earlier the same day (dive e2e): a *full*-round-trip lead was worse again
|
|
792
|
+
* (-1.4 to -1.8 u worst backwards step vs -0.6 at half), which is the same finding from the
|
|
793
|
+
* other end. `stats.stampGap` is the running check on all of this — it reports how far ahead
|
|
794
|
+
* of the server's application the stamps still land, and it should sit at 0.
|
|
795
|
+
*/
|
|
796
|
+
leadTicks() {
|
|
797
|
+
return Math.max(1, Math.round(this.rttOf() / 2 / this.timestepMs()));
|
|
798
|
+
}
|
|
799
|
+
timestepMs() {
|
|
800
|
+
return (this.world?.timestep ?? 1 / 30) * 1e3;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Rebase + re-step: snap every predicted body to the authoritative record (server values —
|
|
804
|
+
* `DELTA` for non-owned bodies, `CORRECT` for owned ones; body fields are never client-
|
|
805
|
+
* written, so plain state holds exactly what the server said), then re-step the world by the
|
|
806
|
+
* client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
|
|
807
|
+
* 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 the shared resim depth: an outrun lead
|
|
809
|
+
* snaps to authority and counts (`stats.snaps`).
|
|
810
|
+
*/
|
|
811
|
+
rebase() {
|
|
812
|
+
const world = this.world;
|
|
813
|
+
const rapier = this.rapier;
|
|
814
|
+
if (!world || !rapier) return;
|
|
815
|
+
this.stats.rebases++;
|
|
816
|
+
for (const entry of this.bodies.values()) {
|
|
817
|
+
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
818
|
+
if (record) this.applyRecord(entry, record);
|
|
819
|
+
}
|
|
820
|
+
for (const entry of this.bodies.values()) {
|
|
821
|
+
if (!entry.owned) continue;
|
|
822
|
+
const k = key(entry.desc.name, entry.id);
|
|
823
|
+
this.predictedTicks.set(k, this.authorityTicks.get(k) ?? this.predictedTicks.get(k) ?? 0);
|
|
824
|
+
}
|
|
825
|
+
const lead = this.leadTicks();
|
|
826
|
+
this.headTick = this.authorityTick;
|
|
827
|
+
if (lead > RESIM_DEPTH) {
|
|
828
|
+
this.stats.snaps++;
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const replays = /* @__PURE__ */ new Map();
|
|
832
|
+
for (const entry of this.bodies.values()) {
|
|
833
|
+
if (!entry.owned) continue;
|
|
834
|
+
const k = key(entry.desc.name, entry.id);
|
|
835
|
+
replays.set(k, {
|
|
836
|
+
frames: this.store.pendingWritePatches(entry.desc.name, entry.id),
|
|
837
|
+
next: 0,
|
|
838
|
+
inForce: this.store.baselineIntent(entry.desc.name, entry.id)
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const started = performance.now();
|
|
842
|
+
for (let step = 0; step < lead; step++) {
|
|
843
|
+
this.applyIntents(rapier, world, (k) => {
|
|
844
|
+
const r = replays.get(k);
|
|
845
|
+
if (!r) return void 0;
|
|
846
|
+
const t = (this.predictedTicks.get(k) ?? 0) + 1;
|
|
847
|
+
const gap = Math.round(this.stampGap);
|
|
848
|
+
while (r.next < r.frames.length && r.frames[r.next].tick - gap <= t) {
|
|
849
|
+
const f = r.frames[r.next];
|
|
850
|
+
r.inForce = r.inForce ? { ...r.inForce, ...f.patch } : { ...f.patch };
|
|
851
|
+
r.next++;
|
|
852
|
+
}
|
|
853
|
+
return r.inForce;
|
|
854
|
+
});
|
|
855
|
+
world.step();
|
|
856
|
+
this.recordStep();
|
|
857
|
+
this.headTick++;
|
|
858
|
+
if (lead - step <= POSE_RING) this.capturePoses();
|
|
859
|
+
this.stats.resimSteps++;
|
|
860
|
+
}
|
|
861
|
+
this.stats.lastResimMicros = Math.round((performance.now() - started) * 1e3);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* The tick a `WRITE` flushed right now should be stamped with: one past the newest predicted
|
|
865
|
+
* head across owned bodies — the first tick the new intent can affect. That puts the stamp on
|
|
866
|
+
* the same clock as `authorityTicks` and the resim window (the server's tick stream), which is
|
|
867
|
+
* what lets the rebase walk pending writes by tick instead of by array index. `undefined`
|
|
868
|
+
* until the engine runs or while nothing is owned; the session falls back to its counter.
|
|
869
|
+
*/
|
|
870
|
+
stampTick() {
|
|
871
|
+
if (!this.world) return void 0;
|
|
872
|
+
let head;
|
|
873
|
+
for (const entry of this.bodies.values()) {
|
|
874
|
+
if (!entry.owned) continue;
|
|
875
|
+
const t = this.predictedTicks.get(key(entry.desc.name, entry.id));
|
|
876
|
+
if (t !== void 0 && (head === void 0 || t > head)) head = t;
|
|
877
|
+
}
|
|
878
|
+
return head === void 0 ? void 0 : head + 1;
|
|
879
|
+
}
|
|
880
|
+
/** After every step: advance each owned body's prediction clock and remember its channels. */
|
|
881
|
+
recordStep() {
|
|
882
|
+
for (const entry of this.bodies.values()) {
|
|
883
|
+
if (!entry.owned) continue;
|
|
884
|
+
const physics = entry.desc.physics;
|
|
885
|
+
if (!physics) continue;
|
|
886
|
+
const k = key(entry.desc.name, entry.id);
|
|
887
|
+
const tick = (this.predictedTicks.get(k) ?? 0) + 1;
|
|
888
|
+
this.predictedTicks.set(k, tick);
|
|
889
|
+
const t = entry.body.translation();
|
|
890
|
+
const r = entry.body.rotation();
|
|
891
|
+
const v = entry.body.linvel();
|
|
892
|
+
const w = entry.body.angvel();
|
|
893
|
+
const record = {};
|
|
894
|
+
for (const [channel, field] of physics.channels) {
|
|
895
|
+
record[field] = channelValue(channel, t, r, v, w);
|
|
896
|
+
}
|
|
897
|
+
let byTick = this.history.get(k);
|
|
898
|
+
if (!byTick) {
|
|
899
|
+
byTick = /* @__PURE__ */ new Map();
|
|
900
|
+
this.history.set(k, byTick);
|
|
901
|
+
}
|
|
902
|
+
byTick.set(tick, record);
|
|
903
|
+
byTick.delete(tick - _PhysicsPredictor.HISTORY_TICKS);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/** Returns the number of steps taken, so the caller knows whether the head moved. */
|
|
907
|
+
freeRun(now) {
|
|
908
|
+
const world = this.world;
|
|
909
|
+
const rapier = this.rapier;
|
|
910
|
+
if (!world || !rapier) return 0;
|
|
911
|
+
if (this.lastNow === void 0) {
|
|
912
|
+
this.lastNow = now;
|
|
913
|
+
return 0;
|
|
914
|
+
}
|
|
915
|
+
this.accumulatorMs += Math.max(0, now - this.lastNow);
|
|
916
|
+
this.lastNow = now;
|
|
917
|
+
const dtMs = this.timestepMs();
|
|
918
|
+
if (this.accumulatorMs > dtMs * MAX_FREE_STEPS_PER_FRAME) {
|
|
919
|
+
this.accumulatorMs = dtMs * MAX_FREE_STEPS_PER_FRAME;
|
|
920
|
+
}
|
|
921
|
+
let steps = 0;
|
|
922
|
+
while (this.accumulatorMs >= dtMs) {
|
|
923
|
+
this.accumulatorMs -= dtMs;
|
|
924
|
+
this.applyIntents(rapier, world, () => void 0);
|
|
925
|
+
world.step();
|
|
926
|
+
this.recordStep();
|
|
927
|
+
this.headTick++;
|
|
928
|
+
this.capturePoses();
|
|
929
|
+
this.stats.freeSteps++;
|
|
930
|
+
steps++;
|
|
931
|
+
}
|
|
932
|
+
return steps;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Applies intents for every owned predicted body before one step: the intent in force at the
|
|
936
|
+
* tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
|
|
937
|
+
* current values (free-run: plain state carries the newest local intent writes, which is
|
|
938
|
+
* correct there because free-run steps are the ticks *after* every buffered write).
|
|
939
|
+
*/
|
|
940
|
+
applyIntents(rapier, world, frameFor) {
|
|
941
|
+
for (const entry of this.bodies.values()) {
|
|
942
|
+
if (!entry.owned) continue;
|
|
943
|
+
const hook = this.options.intents?.[entry.desc.name];
|
|
944
|
+
if (!hook) continue;
|
|
945
|
+
const record = this.store.plainCollection(entry.desc.name).get(entry.id);
|
|
946
|
+
if (!record) continue;
|
|
947
|
+
const frame = frameFor(key(entry.desc.name, entry.id));
|
|
948
|
+
hook(entry.body, frame ? { ...record, ...frame } : record, rapier, world);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
/** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
|
|
952
|
+
applyRecord(entry, record) {
|
|
953
|
+
const physics = entry.desc.physics;
|
|
954
|
+
if (!physics) return;
|
|
955
|
+
const body = entry.body;
|
|
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);
|
|
1020
|
+
}
|
|
1021
|
+
isF32(desc, field) {
|
|
1022
|
+
const idx = desc.fieldIndex.get(field);
|
|
1023
|
+
if (idx === void 0) return false;
|
|
1024
|
+
return desc.fields[idx]?.type.kind === "f32";
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
function channelValue(channel, t, r, v, w) {
|
|
1028
|
+
switch (channel) {
|
|
1029
|
+
case "x":
|
|
1030
|
+
return t.x;
|
|
1031
|
+
case "y":
|
|
1032
|
+
return t.y;
|
|
1033
|
+
case "z":
|
|
1034
|
+
return t.z;
|
|
1035
|
+
case "qx":
|
|
1036
|
+
return r.x;
|
|
1037
|
+
case "qy":
|
|
1038
|
+
return r.y;
|
|
1039
|
+
case "qz":
|
|
1040
|
+
return r.z;
|
|
1041
|
+
case "qw":
|
|
1042
|
+
return r.w;
|
|
1043
|
+
case "vx":
|
|
1044
|
+
return v.x;
|
|
1045
|
+
case "vy":
|
|
1046
|
+
return v.y;
|
|
1047
|
+
case "vz":
|
|
1048
|
+
return v.z;
|
|
1049
|
+
case "wx":
|
|
1050
|
+
return w.x;
|
|
1051
|
+
case "wy":
|
|
1052
|
+
return w.y;
|
|
1053
|
+
default:
|
|
1054
|
+
return w.z;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
export {
|
|
1058
|
+
PhysicsPredictor
|
|
1059
|
+
};
|