@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.
Files changed (83) hide show
  1. package/dist/__tests__/clock-sync.test.d.ts +2 -0
  2. package/dist/__tests__/clock-sync.test.d.ts.map +1 -0
  3. package/dist/__tests__/clock-sync.test.js +63 -0
  4. package/dist/__tests__/clock-sync.test.js.map +1 -0
  5. package/dist/__tests__/interpolator.test.d.ts +2 -0
  6. package/dist/__tests__/interpolator.test.d.ts.map +1 -0
  7. package/dist/__tests__/interpolator.test.js +82 -0
  8. package/dist/__tests__/interpolator.test.js.map +1 -0
  9. package/dist/__tests__/state-reconciler.test.d.ts +2 -0
  10. package/dist/__tests__/state-reconciler.test.d.ts.map +1 -0
  11. package/dist/__tests__/state-reconciler.test.js +86 -0
  12. package/dist/__tests__/state-reconciler.test.js.map +1 -0
  13. package/dist/clock-sync.d.ts +17 -0
  14. package/dist/clock-sync.d.ts.map +1 -0
  15. package/dist/clock-sync.js +63 -0
  16. package/dist/clock-sync.js.map +1 -0
  17. package/dist/index.d.ts +10 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/input-manager.d.ts +18 -0
  22. package/dist/input-manager.d.ts.map +1 -0
  23. package/dist/input-manager.js +62 -0
  24. package/dist/input-manager.js.map +1 -0
  25. package/dist/interpolator.d.ts +35 -0
  26. package/dist/interpolator.d.ts.map +1 -0
  27. package/dist/interpolator.js +198 -0
  28. package/dist/interpolator.js.map +1 -0
  29. package/dist/networked-rapier-plugin.d.ts +82 -0
  30. package/dist/networked-rapier-plugin.d.ts.map +1 -0
  31. package/dist/networked-rapier-plugin.js +698 -0
  32. package/dist/networked-rapier-plugin.js.map +1 -0
  33. package/dist/rapier-body-ops.d.ts +27 -0
  34. package/dist/rapier-body-ops.d.ts.map +1 -0
  35. package/dist/rapier-body-ops.js +208 -0
  36. package/dist/rapier-body-ops.js.map +1 -0
  37. package/dist/rapier-collision-ops.d.ts +6 -0
  38. package/dist/rapier-collision-ops.d.ts.map +1 -0
  39. package/dist/rapier-collision-ops.js +200 -0
  40. package/dist/rapier-collision-ops.js.map +1 -0
  41. package/dist/rapier-constraint-ops.d.ts +29 -0
  42. package/dist/rapier-constraint-ops.d.ts.map +1 -0
  43. package/dist/rapier-constraint-ops.js +286 -0
  44. package/dist/rapier-constraint-ops.js.map +1 -0
  45. package/dist/rapier-plugin.d.ts +145 -0
  46. package/dist/rapier-plugin.d.ts.map +1 -0
  47. package/dist/rapier-plugin.js +263 -0
  48. package/dist/rapier-plugin.js.map +1 -0
  49. package/dist/rapier-shape-ops.d.ts +21 -0
  50. package/dist/rapier-shape-ops.d.ts.map +1 -0
  51. package/dist/rapier-shape-ops.js +314 -0
  52. package/dist/rapier-shape-ops.js.map +1 -0
  53. package/dist/rapier-types.d.ts +58 -0
  54. package/dist/rapier-types.d.ts.map +1 -0
  55. package/dist/rapier-types.js +4 -0
  56. package/dist/rapier-types.js.map +1 -0
  57. package/dist/state-reconciler.d.ts +28 -0
  58. package/dist/state-reconciler.d.ts.map +1 -0
  59. package/dist/state-reconciler.js +119 -0
  60. package/dist/state-reconciler.js.map +1 -0
  61. package/dist/sync-client.d.ts +110 -0
  62. package/dist/sync-client.d.ts.map +1 -0
  63. package/dist/sync-client.js +514 -0
  64. package/dist/sync-client.js.map +1 -0
  65. package/package.json +21 -0
  66. package/src/__tests__/clock-sync.test.ts +72 -0
  67. package/src/__tests__/interpolator.test.ts +98 -0
  68. package/src/__tests__/state-reconciler.test.ts +102 -0
  69. package/src/clock-sync.ts +77 -0
  70. package/src/index.ts +9 -0
  71. package/src/input-manager.ts +72 -0
  72. package/src/interpolator.ts +256 -0
  73. package/src/networked-rapier-plugin.ts +909 -0
  74. package/src/rapier-body-ops.ts +251 -0
  75. package/src/rapier-collision-ops.ts +229 -0
  76. package/src/rapier-constraint-ops.ts +327 -0
  77. package/src/rapier-plugin.ts +364 -0
  78. package/src/rapier-shape-ops.ts +369 -0
  79. package/src/rapier-types.ts +60 -0
  80. package/src/state-reconciler.ts +151 -0
  81. package/src/sync-client.ts +640 -0
  82. package/tsconfig.json +12 -0
  83. 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
+ }