@rapierphysicsplugin/client 1.0.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/__tests__/clock-sync.test.d.ts +2 -0
- package/dist/__tests__/clock-sync.test.d.ts.map +1 -0
- package/dist/__tests__/clock-sync.test.js +63 -0
- package/dist/__tests__/clock-sync.test.js.map +1 -0
- package/dist/__tests__/interpolator.test.d.ts +2 -0
- package/dist/__tests__/interpolator.test.d.ts.map +1 -0
- package/dist/__tests__/interpolator.test.js +82 -0
- package/dist/__tests__/interpolator.test.js.map +1 -0
- package/dist/__tests__/state-reconciler.test.d.ts +2 -0
- package/dist/__tests__/state-reconciler.test.d.ts.map +1 -0
- package/dist/__tests__/state-reconciler.test.js +86 -0
- package/dist/__tests__/state-reconciler.test.js.map +1 -0
- package/dist/clock-sync.d.ts +17 -0
- package/dist/clock-sync.d.ts.map +1 -0
- package/dist/clock-sync.js +63 -0
- package/dist/clock-sync.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/input-manager.d.ts +18 -0
- package/dist/input-manager.d.ts.map +1 -0
- package/dist/input-manager.js +62 -0
- package/dist/input-manager.js.map +1 -0
- package/dist/interpolator.d.ts +35 -0
- package/dist/interpolator.d.ts.map +1 -0
- package/dist/interpolator.js +198 -0
- package/dist/interpolator.js.map +1 -0
- package/dist/networked-rapier-plugin.d.ts +82 -0
- package/dist/networked-rapier-plugin.d.ts.map +1 -0
- package/dist/networked-rapier-plugin.js +698 -0
- package/dist/networked-rapier-plugin.js.map +1 -0
- package/dist/rapier-body-ops.d.ts +27 -0
- package/dist/rapier-body-ops.d.ts.map +1 -0
- package/dist/rapier-body-ops.js +208 -0
- package/dist/rapier-body-ops.js.map +1 -0
- package/dist/rapier-collision-ops.d.ts +6 -0
- package/dist/rapier-collision-ops.d.ts.map +1 -0
- package/dist/rapier-collision-ops.js +200 -0
- package/dist/rapier-collision-ops.js.map +1 -0
- package/dist/rapier-constraint-ops.d.ts +29 -0
- package/dist/rapier-constraint-ops.d.ts.map +1 -0
- package/dist/rapier-constraint-ops.js +286 -0
- package/dist/rapier-constraint-ops.js.map +1 -0
- package/dist/rapier-plugin.d.ts +145 -0
- package/dist/rapier-plugin.d.ts.map +1 -0
- package/dist/rapier-plugin.js +263 -0
- package/dist/rapier-plugin.js.map +1 -0
- package/dist/rapier-shape-ops.d.ts +21 -0
- package/dist/rapier-shape-ops.d.ts.map +1 -0
- package/dist/rapier-shape-ops.js +314 -0
- package/dist/rapier-shape-ops.js.map +1 -0
- package/dist/rapier-types.d.ts +58 -0
- package/dist/rapier-types.d.ts.map +1 -0
- package/dist/rapier-types.js +4 -0
- package/dist/rapier-types.js.map +1 -0
- package/dist/state-reconciler.d.ts +28 -0
- package/dist/state-reconciler.d.ts.map +1 -0
- package/dist/state-reconciler.js +119 -0
- package/dist/state-reconciler.js.map +1 -0
- package/dist/sync-client.d.ts +110 -0
- package/dist/sync-client.d.ts.map +1 -0
- package/dist/sync-client.js +514 -0
- package/dist/sync-client.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/clock-sync.test.ts +72 -0
- package/src/__tests__/interpolator.test.ts +98 -0
- package/src/__tests__/state-reconciler.test.ts +102 -0
- package/src/clock-sync.ts +77 -0
- package/src/index.ts +9 -0
- package/src/input-manager.ts +72 -0
- package/src/interpolator.ts +256 -0
- package/src/networked-rapier-plugin.ts +909 -0
- package/src/rapier-body-ops.ts +251 -0
- package/src/rapier-collision-ops.ts +229 -0
- package/src/rapier-constraint-ops.ts +327 -0
- package/src/rapier-plugin.ts +364 -0
- package/src/rapier-shape-ops.ts +369 -0
- package/src/rapier-types.ts +60 -0
- package/src/state-reconciler.ts +151 -0
- package/src/sync-client.ts +640 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { Interpolator } from '../interpolator.js';
|
|
3
|
+
import type { BodyState } from '@rapierphysicsplugin/shared';
|
|
4
|
+
|
|
5
|
+
function makeState(id: string, x: number, y: number, z: number, vx = 0, vy = 0, vz = 0): BodyState {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
position: { x, y, z },
|
|
9
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
10
|
+
linVel: { x: vx, y: vy, z: vz },
|
|
11
|
+
angVel: { x: 0, y: 0, z: 0 },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Interpolator', () => {
|
|
16
|
+
let interpolator: Interpolator;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Use 0ms render delay for deterministic testing
|
|
20
|
+
interpolator = new Interpolator(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return null for unknown bodies', () => {
|
|
24
|
+
expect(interpolator.getInterpolatedState('unknown', Date.now())).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return earliest state when render time is before all snapshots', () => {
|
|
28
|
+
interpolator.addSnapshot('body1', makeState('body1', 0, 0, 0), 1000);
|
|
29
|
+
interpolator.addSnapshot('body1', makeState('body1', 10, 0, 0), 2000);
|
|
30
|
+
|
|
31
|
+
const state = interpolator.getInterpolatedState('body1', 500);
|
|
32
|
+
expect(state).not.toBeNull();
|
|
33
|
+
expect(state!.position.x).toBeCloseTo(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should interpolate between two snapshots', () => {
|
|
37
|
+
interpolator.addSnapshot('body1', makeState('body1', 0, 0, 0), 1000);
|
|
38
|
+
interpolator.addSnapshot('body1', makeState('body1', 10, 0, 0), 2000);
|
|
39
|
+
|
|
40
|
+
// Midpoint
|
|
41
|
+
const state = interpolator.getInterpolatedState('body1', 1500);
|
|
42
|
+
expect(state).not.toBeNull();
|
|
43
|
+
// Hermite interpolation at t=0.5 with zero velocities should give midpoint
|
|
44
|
+
expect(state!.position.x).toBeCloseTo(5, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should extrapolate after last snapshot', () => {
|
|
48
|
+
interpolator.addSnapshot('body1', makeState('body1', 0, 0, 0, 10, 0, 0), 1000);
|
|
49
|
+
|
|
50
|
+
// 0.1 seconds after the last snapshot
|
|
51
|
+
const state = interpolator.getInterpolatedState('body1', 1100);
|
|
52
|
+
expect(state).not.toBeNull();
|
|
53
|
+
// Should move in the x direction due to velocity
|
|
54
|
+
expect(state!.position.x).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should interpolate rotation via slerp', () => {
|
|
58
|
+
const state1: BodyState = {
|
|
59
|
+
id: 'body1',
|
|
60
|
+
position: { x: 0, y: 0, z: 0 },
|
|
61
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 }, // identity
|
|
62
|
+
linVel: { x: 0, y: 0, z: 0 },
|
|
63
|
+
angVel: { x: 0, y: 0, z: 0 },
|
|
64
|
+
};
|
|
65
|
+
const state2: BodyState = {
|
|
66
|
+
id: 'body1',
|
|
67
|
+
position: { x: 0, y: 0, z: 0 },
|
|
68
|
+
rotation: { x: 0, y: 0.7071, z: 0, w: 0.7071 }, // 90deg around Y
|
|
69
|
+
linVel: { x: 0, y: 0, z: 0 },
|
|
70
|
+
angVel: { x: 0, y: 0, z: 0 },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
interpolator.addSnapshot('body1', state1, 1000);
|
|
74
|
+
interpolator.addSnapshot('body1', state2, 2000);
|
|
75
|
+
|
|
76
|
+
const result = interpolator.getInterpolatedState('body1', 1500);
|
|
77
|
+
expect(result).not.toBeNull();
|
|
78
|
+
|
|
79
|
+
// Quaternion magnitude should be ~1
|
|
80
|
+
const q = result!.rotation;
|
|
81
|
+
const mag = Math.sqrt(q.x ** 2 + q.y ** 2 + q.z ** 2 + q.w ** 2);
|
|
82
|
+
expect(mag).toBeCloseTo(1, 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should remove a body', () => {
|
|
86
|
+
interpolator.addSnapshot('body1', makeState('body1', 0, 0, 0), 1000);
|
|
87
|
+
interpolator.removeBody('body1');
|
|
88
|
+
expect(interpolator.getInterpolatedState('body1', 1000)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should clear all data', () => {
|
|
92
|
+
interpolator.addSnapshot('body1', makeState('body1', 0, 0, 0), 1000);
|
|
93
|
+
interpolator.addSnapshot('body2', makeState('body2', 5, 5, 5), 1000);
|
|
94
|
+
interpolator.clear();
|
|
95
|
+
expect(interpolator.getInterpolatedState('body1', 1000)).toBeNull();
|
|
96
|
+
expect(interpolator.getInterpolatedState('body2', 1000)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { StateReconciler, needsCorrection, blendBodyState } from '../state-reconciler.js';
|
|
3
|
+
import type { BodyState, RoomSnapshot } from '@rapierphysicsplugin/shared';
|
|
4
|
+
|
|
5
|
+
function makeState(id: string, x: number, y: number, z: number): BodyState {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
position: { x, y, z },
|
|
9
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
10
|
+
linVel: { x: 0, y: 0, z: 0 },
|
|
11
|
+
angVel: { x: 0, y: 0, z: 0 },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('StateReconciler', () => {
|
|
16
|
+
let reconciler: StateReconciler;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
reconciler = new StateReconciler();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should separate local and remote bodies', () => {
|
|
23
|
+
reconciler.addLocalBody('player1');
|
|
24
|
+
|
|
25
|
+
const timestamp = Date.now();
|
|
26
|
+
const snapshot: RoomSnapshot = {
|
|
27
|
+
tick: 10,
|
|
28
|
+
timestamp,
|
|
29
|
+
bodies: [
|
|
30
|
+
makeState('player1', 1, 2, 3),
|
|
31
|
+
makeState('enemy1', 4, 5, 6),
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = reconciler.processServerState(snapshot);
|
|
36
|
+
// Local body goes to corrections
|
|
37
|
+
expect(result.localCorrections.has('player1')).toBe(true);
|
|
38
|
+
// Remote body is fed to interpolator — query it at render time
|
|
39
|
+
const interpolated = reconciler.getInterpolatedRemoteState('enemy1', timestamp);
|
|
40
|
+
expect(interpolated).not.toBeNull();
|
|
41
|
+
expect(interpolated!.position.x).toBeCloseTo(4, 1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should discard old pending inputs', () => {
|
|
45
|
+
reconciler.addPendingInput({ tick: 5, sequenceNum: 0, actions: [] });
|
|
46
|
+
reconciler.addPendingInput({ tick: 10, sequenceNum: 1, actions: [] });
|
|
47
|
+
reconciler.addPendingInput({ tick: 15, sequenceNum: 2, actions: [] });
|
|
48
|
+
|
|
49
|
+
reconciler.processServerState({
|
|
50
|
+
tick: 10,
|
|
51
|
+
timestamp: Date.now(),
|
|
52
|
+
bodies: [],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const pending = reconciler.getPendingInputs();
|
|
56
|
+
expect(pending).toHaveLength(1);
|
|
57
|
+
expect(pending[0].tick).toBe(15);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should track last server tick', () => {
|
|
61
|
+
reconciler.processServerState({
|
|
62
|
+
tick: 42,
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
bodies: [],
|
|
65
|
+
});
|
|
66
|
+
expect(reconciler.lastProcessedServerTick).toBe(42);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should clear all state', () => {
|
|
70
|
+
reconciler.addLocalBody('player1');
|
|
71
|
+
reconciler.addPendingInput({ tick: 5, sequenceNum: 0, actions: [] });
|
|
72
|
+
reconciler.clear();
|
|
73
|
+
|
|
74
|
+
expect(reconciler.getPendingInputs()).toHaveLength(0);
|
|
75
|
+
expect(reconciler.lastProcessedServerTick).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('needsCorrection', () => {
|
|
80
|
+
it('should return false for small differences', () => {
|
|
81
|
+
const a = makeState('body1', 1, 2, 3);
|
|
82
|
+
const b = makeState('body1', 1.01, 2.01, 3.01);
|
|
83
|
+
expect(needsCorrection(a, b)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return true for large differences', () => {
|
|
87
|
+
const a = makeState('body1', 1, 2, 3);
|
|
88
|
+
const b = makeState('body1', 2, 2, 3);
|
|
89
|
+
expect(needsCorrection(a, b)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('blendBodyState', () => {
|
|
94
|
+
it('should blend positions toward target', () => {
|
|
95
|
+
const current = makeState('body1', 0, 0, 0);
|
|
96
|
+
const target = makeState('body1', 10, 0, 0);
|
|
97
|
+
|
|
98
|
+
const blended = blendBodyState(current, target);
|
|
99
|
+
expect(blended.position.x).toBeGreaterThan(0);
|
|
100
|
+
expect(blended.position.x).toBeLessThan(10);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MessageType,
|
|
3
|
+
CLOCK_SYNC_INTERVAL_MS,
|
|
4
|
+
CLOCK_SYNC_SAMPLES,
|
|
5
|
+
SERVER_TICK_RATE,
|
|
6
|
+
FIXED_TIMESTEP,
|
|
7
|
+
encodeMessage,
|
|
8
|
+
} from '@rapierphysicsplugin/shared';
|
|
9
|
+
import type { ClockSyncResponseMessage } from '@rapierphysicsplugin/shared';
|
|
10
|
+
|
|
11
|
+
export class ClockSyncClient {
|
|
12
|
+
private rttSamples: number[] = [];
|
|
13
|
+
private offsetSamples: number[] = [];
|
|
14
|
+
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
15
|
+
private sendFn: ((data: Uint8Array) => void) | null = null;
|
|
16
|
+
|
|
17
|
+
start(sendFn: (data: Uint8Array) => void): void {
|
|
18
|
+
this.sendFn = sendFn;
|
|
19
|
+
this.sendSyncRequest();
|
|
20
|
+
this.intervalId = setInterval(() => this.sendSyncRequest(), CLOCK_SYNC_INTERVAL_MS);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
stop(): void {
|
|
24
|
+
if (this.intervalId) {
|
|
25
|
+
clearInterval(this.intervalId);
|
|
26
|
+
this.intervalId = null;
|
|
27
|
+
}
|
|
28
|
+
this.sendFn = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private sendSyncRequest(): void {
|
|
32
|
+
if (!this.sendFn) return;
|
|
33
|
+
this.sendFn(encodeMessage({
|
|
34
|
+
type: MessageType.CLOCK_SYNC_REQUEST,
|
|
35
|
+
clientTimestamp: Date.now(),
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
handleResponse(message: ClockSyncResponseMessage): void {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const rtt = now - message.clientTimestamp;
|
|
42
|
+
const offset = message.serverTimestamp - message.clientTimestamp - rtt / 2;
|
|
43
|
+
|
|
44
|
+
this.rttSamples.push(rtt);
|
|
45
|
+
if (this.rttSamples.length > CLOCK_SYNC_SAMPLES) {
|
|
46
|
+
this.rttSamples.shift();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.offsetSamples.push(offset);
|
|
50
|
+
if (this.offsetSamples.length > CLOCK_SYNC_SAMPLES) {
|
|
51
|
+
this.offsetSamples.shift();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getRTT(): number {
|
|
56
|
+
if (this.rttSamples.length === 0) return 0;
|
|
57
|
+
return this.rttSamples.reduce((a, b) => a + b, 0) / this.rttSamples.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getClockOffset(): number {
|
|
61
|
+
if (this.offsetSamples.length === 0) return 0;
|
|
62
|
+
return this.offsetSamples.reduce((a, b) => a + b, 0) / this.offsetSamples.length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getServerTime(): number {
|
|
66
|
+
return Date.now() + this.getClockOffset();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getServerTick(): number {
|
|
70
|
+
const serverTimeMs = this.getServerTime();
|
|
71
|
+
return Math.floor(serverTimeMs / (FIXED_TIMESTEP * 1000));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get isCalibrated(): boolean {
|
|
75
|
+
return this.rttSamples.length >= 3;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { PhysicsSyncClient } from './sync-client.js';
|
|
2
|
+
export { RapierPlugin } from './rapier-plugin.js';
|
|
3
|
+
export { NetworkedRapierPlugin } from './networked-rapier-plugin.js';
|
|
4
|
+
export type { NetworkedRapierPluginConfig } from './networked-rapier-plugin.js';
|
|
5
|
+
export { ClockSyncClient } from './clock-sync.js';
|
|
6
|
+
export { StateReconciler, needsCorrection, blendBodyState } from './state-reconciler.js';
|
|
7
|
+
export { Interpolator } from './interpolator.js';
|
|
8
|
+
export type { InterpolatorStats } from './interpolator.js';
|
|
9
|
+
export { InputManager } from './input-manager.js';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ClientInput, InputAction } from '@rapierphysicsplugin/shared';
|
|
2
|
+
import { MAX_INPUT_BUFFER, CLIENT_INPUT_RATE } from '@rapierphysicsplugin/shared';
|
|
3
|
+
|
|
4
|
+
export class InputManager {
|
|
5
|
+
private pendingActions: InputAction[] = [];
|
|
6
|
+
private inputHistory: ClientInput[] = [];
|
|
7
|
+
private sequenceNum = 0;
|
|
8
|
+
private currentTick = 0;
|
|
9
|
+
private sendFn: ((input: ClientInput) => void) | null = null;
|
|
10
|
+
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
11
|
+
|
|
12
|
+
start(sendFn: (input: ClientInput) => void, getTickFn: () => number): void {
|
|
13
|
+
this.sendFn = sendFn;
|
|
14
|
+
|
|
15
|
+
const intervalMs = 1000 / CLIENT_INPUT_RATE;
|
|
16
|
+
this.intervalId = setInterval(() => {
|
|
17
|
+
this.currentTick = getTickFn();
|
|
18
|
+
this.flush();
|
|
19
|
+
}, intervalMs);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
stop(): void {
|
|
23
|
+
if (this.intervalId) {
|
|
24
|
+
clearInterval(this.intervalId);
|
|
25
|
+
this.intervalId = null;
|
|
26
|
+
}
|
|
27
|
+
this.sendFn = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
queueAction(action: InputAction): void {
|
|
31
|
+
this.pendingActions.push(action);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
flush(): void {
|
|
35
|
+
if (this.pendingActions.length === 0) return;
|
|
36
|
+
if (!this.sendFn) return;
|
|
37
|
+
|
|
38
|
+
const input: ClientInput = {
|
|
39
|
+
tick: this.currentTick,
|
|
40
|
+
sequenceNum: this.sequenceNum++,
|
|
41
|
+
actions: [...this.pendingActions],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
this.pendingActions = [];
|
|
45
|
+
|
|
46
|
+
// Store in history for reconciliation
|
|
47
|
+
this.inputHistory.push(input);
|
|
48
|
+
if (this.inputHistory.length > MAX_INPUT_BUFFER) {
|
|
49
|
+
this.inputHistory.shift();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.sendFn(input);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getInputHistory(): ClientInput[] {
|
|
56
|
+
return this.inputHistory;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getInputsSince(tick: number): ClientInput[] {
|
|
60
|
+
return this.inputHistory.filter(input => input.tick > tick);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clearHistory(): void {
|
|
64
|
+
this.inputHistory = [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
clear(): void {
|
|
68
|
+
this.pendingActions = [];
|
|
69
|
+
this.inputHistory = [];
|
|
70
|
+
this.sequenceNum = 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { BodyState, Vec3, Quat } from '@rapierphysicsplugin/shared';
|
|
2
|
+
import { INTERPOLATION_BUFFER_SIZE, BROADCAST_RATE } from '@rapierphysicsplugin/shared';
|
|
3
|
+
|
|
4
|
+
interface Snapshot {
|
|
5
|
+
timestamp: number;
|
|
6
|
+
state: BodyState;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface InterpolatorStats {
|
|
10
|
+
/** Bodies that had two bracketing snapshots — true interpolation */
|
|
11
|
+
interpolatedCount: number;
|
|
12
|
+
/** Bodies where renderTime was past all snapshots — velocity extrapolation */
|
|
13
|
+
extrapolatedCount: number;
|
|
14
|
+
/** Bodies where renderTime was before all snapshots — returned earliest */
|
|
15
|
+
staleCount: number;
|
|
16
|
+
/** Bodies with empty buffer — returned null */
|
|
17
|
+
emptyCount: number;
|
|
18
|
+
/** Render delay being used (ms) */
|
|
19
|
+
renderDelay: number;
|
|
20
|
+
/** Sample body diagnostics (first dynamic body seen) */
|
|
21
|
+
sampleBodyId: string | null;
|
|
22
|
+
sampleBufferLen: number;
|
|
23
|
+
sampleRenderTime: number;
|
|
24
|
+
sampleBufferOldest: number;
|
|
25
|
+
sampleBufferNewest: number;
|
|
26
|
+
sampleT: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Interpolator {
|
|
30
|
+
private buffers: Map<string, Snapshot[]> = new Map();
|
|
31
|
+
private renderDelay: number;
|
|
32
|
+
private _stats: InterpolatorStats = this._emptyStats();
|
|
33
|
+
|
|
34
|
+
constructor(renderDelayMs?: number) {
|
|
35
|
+
// Default render delay: ~3x broadcast interval to absorb jitter
|
|
36
|
+
this.renderDelay = renderDelayMs ?? (3 * (1000 / BROADCAST_RATE));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private _emptyStats(): InterpolatorStats {
|
|
40
|
+
return {
|
|
41
|
+
interpolatedCount: 0,
|
|
42
|
+
extrapolatedCount: 0,
|
|
43
|
+
staleCount: 0,
|
|
44
|
+
emptyCount: 0,
|
|
45
|
+
renderDelay: this?.renderDelay ?? 0,
|
|
46
|
+
sampleBodyId: null,
|
|
47
|
+
sampleBufferLen: 0,
|
|
48
|
+
sampleRenderTime: 0,
|
|
49
|
+
sampleBufferOldest: 0,
|
|
50
|
+
sampleBufferNewest: 0,
|
|
51
|
+
sampleT: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Reset stats at the start of each render frame, then call getInterpolatedState per body */
|
|
56
|
+
resetStats(): void {
|
|
57
|
+
this._stats = this._emptyStats();
|
|
58
|
+
this._stats.renderDelay = this.renderDelay;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getStats(): InterpolatorStats {
|
|
62
|
+
return this._stats;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
addSnapshot(bodyId: string, state: BodyState, timestamp: number): void {
|
|
66
|
+
if (!this.buffers.has(bodyId)) {
|
|
67
|
+
this.buffers.set(bodyId, []);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const buffer = this.buffers.get(bodyId)!;
|
|
71
|
+
|
|
72
|
+
// Guard against duplicate or out-of-order timestamps (TCP can burst after stalls)
|
|
73
|
+
if (buffer.length > 0 && timestamp <= buffer[buffer.length - 1].timestamp) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
buffer.push({ timestamp, state });
|
|
78
|
+
|
|
79
|
+
// Keep buffer limited
|
|
80
|
+
while (buffer.length > INTERPOLATION_BUFFER_SIZE + 1) {
|
|
81
|
+
buffer.shift();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getInterpolatedState(bodyId: string, currentTime: number): BodyState | null {
|
|
86
|
+
const buffer = this.buffers.get(bodyId);
|
|
87
|
+
if (!buffer || buffer.length === 0) {
|
|
88
|
+
this._stats.emptyCount++;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Render time is behind real time by renderDelay
|
|
93
|
+
const renderTime = currentTime - this.renderDelay;
|
|
94
|
+
|
|
95
|
+
// Capture sample diagnostics for the first body we see with data
|
|
96
|
+
if (!this._stats.sampleBodyId) {
|
|
97
|
+
this._stats.sampleBodyId = bodyId;
|
|
98
|
+
this._stats.sampleBufferLen = buffer.length;
|
|
99
|
+
this._stats.sampleRenderTime = renderTime;
|
|
100
|
+
this._stats.sampleBufferOldest = buffer[0].timestamp;
|
|
101
|
+
this._stats.sampleBufferNewest = buffer[buffer.length - 1].timestamp;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Find the two snapshots to interpolate between
|
|
105
|
+
let older: Snapshot | null = null;
|
|
106
|
+
let newer: Snapshot | null = null;
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < buffer.length - 1; i++) {
|
|
109
|
+
if (buffer[i].timestamp <= renderTime && buffer[i + 1].timestamp >= renderTime) {
|
|
110
|
+
older = buffer[i];
|
|
111
|
+
newer = buffer[i + 1];
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If we have two bracketing snapshots, interpolate
|
|
117
|
+
if (older && newer) {
|
|
118
|
+
const dtMs = newer.timestamp - older.timestamp;
|
|
119
|
+
const t = (renderTime - older.timestamp) / dtMs;
|
|
120
|
+
const dtSec = dtMs / 1000;
|
|
121
|
+
this._stats.interpolatedCount++;
|
|
122
|
+
if (this._stats.sampleBodyId === bodyId) {
|
|
123
|
+
this._stats.sampleT = t;
|
|
124
|
+
}
|
|
125
|
+
return interpolateBodyState(older.state, newer.state, t, dtSec);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// If render time is past all snapshots, extrapolate from last
|
|
129
|
+
const last = buffer[buffer.length - 1];
|
|
130
|
+
if (renderTime > last.timestamp) {
|
|
131
|
+
const dt = (renderTime - last.timestamp) / 1000;
|
|
132
|
+
this._stats.extrapolatedCount++;
|
|
133
|
+
return extrapolateBodyState(last.state, dt);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// If render time is before all snapshots, return earliest
|
|
137
|
+
this._stats.staleCount++;
|
|
138
|
+
return buffer[0].state;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
removeBody(bodyId: string): void {
|
|
142
|
+
this.buffers.delete(bodyId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
clear(): void {
|
|
146
|
+
this.buffers.clear();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function interpolateBodyState(a: BodyState, b: BodyState, t: number, dtSec: number): BodyState {
|
|
151
|
+
// Scale velocity tangents by dt so they match the Hermite parameter space (t: 0→1)
|
|
152
|
+
// Raw velocities are in units/second; tangents need to be in units/interval
|
|
153
|
+
const scaledVelA: Vec3 = { x: a.linVel.x * dtSec, y: a.linVel.y * dtSec, z: a.linVel.z * dtSec };
|
|
154
|
+
const scaledVelB: Vec3 = { x: b.linVel.x * dtSec, y: b.linVel.y * dtSec, z: b.linVel.z * dtSec };
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id: a.id,
|
|
158
|
+
position: hermiteInterpolateVec3(a.position, scaledVelA, b.position, scaledVelB, t),
|
|
159
|
+
rotation: slerpQuat(a.rotation, b.rotation, t),
|
|
160
|
+
linVel: lerpVec3(a.linVel, b.linVel, t),
|
|
161
|
+
angVel: lerpVec3(a.angVel, b.angVel, t),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function extrapolateBodyState(state: BodyState, dt: number): BodyState {
|
|
166
|
+
// Simple linear extrapolation with velocity decay
|
|
167
|
+
const decay = Math.max(0, 1 - dt * 2); // Decay over 0.5 seconds
|
|
168
|
+
return {
|
|
169
|
+
id: state.id,
|
|
170
|
+
position: {
|
|
171
|
+
x: state.position.x + state.linVel.x * dt * decay,
|
|
172
|
+
y: state.position.y + state.linVel.y * dt * decay,
|
|
173
|
+
z: state.position.z + state.linVel.z * dt * decay,
|
|
174
|
+
},
|
|
175
|
+
rotation: state.rotation, // Don't extrapolate rotation
|
|
176
|
+
linVel: {
|
|
177
|
+
x: state.linVel.x * decay,
|
|
178
|
+
y: state.linVel.y * decay,
|
|
179
|
+
z: state.linVel.z * decay,
|
|
180
|
+
},
|
|
181
|
+
angVel: {
|
|
182
|
+
x: state.angVel.x * decay,
|
|
183
|
+
y: state.angVel.y * decay,
|
|
184
|
+
z: state.angVel.z * decay,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hermiteInterpolateVec3(
|
|
190
|
+
p0: Vec3, v0: Vec3,
|
|
191
|
+
p1: Vec3, v1: Vec3,
|
|
192
|
+
t: number
|
|
193
|
+
): Vec3 {
|
|
194
|
+
// Hermite basis functions
|
|
195
|
+
const t2 = t * t;
|
|
196
|
+
const t3 = t2 * t;
|
|
197
|
+
const h00 = 2 * t3 - 3 * t2 + 1;
|
|
198
|
+
const h10 = t3 - 2 * t2 + t;
|
|
199
|
+
const h01 = -2 * t3 + 3 * t2;
|
|
200
|
+
const h11 = t3 - t2;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
x: h00 * p0.x + h10 * v0.x + h01 * p1.x + h11 * v1.x,
|
|
204
|
+
y: h00 * p0.y + h10 * v0.y + h01 * p1.y + h11 * v1.y,
|
|
205
|
+
z: h00 * p0.z + h10 * v0.z + h01 * p1.z + h11 * v1.z,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function lerpVec3(a: Vec3, b: Vec3, t: number): Vec3 {
|
|
210
|
+
return {
|
|
211
|
+
x: a.x + (b.x - a.x) * t,
|
|
212
|
+
y: a.y + (b.y - a.y) * t,
|
|
213
|
+
z: a.z + (b.z - a.z) * t,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function slerpQuat(a: Quat, b: Quat, t: number): Quat {
|
|
218
|
+
let dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
|
|
219
|
+
|
|
220
|
+
// If dot is negative, negate one quaternion to take the shorter path
|
|
221
|
+
let bx = b.x, by = b.y, bz = b.z, bw = b.w;
|
|
222
|
+
if (dot < 0) {
|
|
223
|
+
dot = -dot;
|
|
224
|
+
bx = -bx; by = -by; bz = -bz; bw = -bw;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// If quaternions are very close, use linear interpolation
|
|
228
|
+
if (dot > 0.9995) {
|
|
229
|
+
return normalizeQuat({
|
|
230
|
+
x: a.x + (bx - a.x) * t,
|
|
231
|
+
y: a.y + (by - a.y) * t,
|
|
232
|
+
z: a.z + (bz - a.z) * t,
|
|
233
|
+
w: a.w + (bw - a.w) * t,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const theta0 = Math.acos(dot);
|
|
238
|
+
const theta = theta0 * t;
|
|
239
|
+
const sinTheta = Math.sin(theta);
|
|
240
|
+
const sinTheta0 = Math.sin(theta0);
|
|
241
|
+
const s0 = Math.cos(theta) - dot * sinTheta / sinTheta0;
|
|
242
|
+
const s1 = sinTheta / sinTheta0;
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
x: s0 * a.x + s1 * bx,
|
|
246
|
+
y: s0 * a.y + s1 * by,
|
|
247
|
+
z: s0 * a.z + s1 * bz,
|
|
248
|
+
w: s0 * a.w + s1 * bw,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeQuat(q: Quat): Quat {
|
|
253
|
+
const len = Math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
|
|
254
|
+
if (len === 0) return { x: 0, y: 0, z: 0, w: 1 };
|
|
255
|
+
return { x: q.x / len, y: q.y / len, z: q.z / len, w: q.w / len };
|
|
256
|
+
}
|