astra
This commit is contained in:
parent
c074bc2645
commit
244620105a
1 changed files with 2080 additions and 518 deletions
|
|
@ -10,7 +10,6 @@ import {
|
|||
headRadius,
|
||||
feetRadius,
|
||||
} from '../shared/src/physics/character-movement';
|
||||
import { followVec2 } from '../shared/src/helper/ease';
|
||||
import { settings } from '../shared/src/settings';
|
||||
|
||||
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
|
|
@ -51,20 +50,23 @@ interface Conditions {
|
|||
dropRate: number;
|
||||
/** Server work that can delay a socket callback past the packet's arrival. */
|
||||
serverBusyMs: number;
|
||||
wireJitterMs?: number;
|
||||
emitJitterMs?: number;
|
||||
changeInput?: boolean;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
/** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */
|
||||
speedSpread: number;
|
||||
/** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */
|
||||
screenWobble: number;
|
||||
/** How far the rendered character trails the true server pose, in world units. */
|
||||
lagUnits: number;
|
||||
finalError: number;
|
||||
maximumSpeed: number;
|
||||
}
|
||||
|
||||
// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and
|
||||
// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the
|
||||
// camera follows the character, so what the player sees is the residual between the two.
|
||||
// Exercise shared movement with independent server, network, and render clocks.
|
||||
// Measure world travel: the camera tracks the local character exactly, so measuring
|
||||
// their relative position would always report zero even when the world visibly stutters.
|
||||
const measure = ({
|
||||
fps,
|
||||
wireMs,
|
||||
|
|
@ -72,6 +74,9 @@ const measure = ({
|
|||
frameJitter,
|
||||
dropRate,
|
||||
serverBusyMs,
|
||||
wireJitterMs = 0,
|
||||
emitJitterMs = 0,
|
||||
changeInput = false,
|
||||
}: Conditions): Result => {
|
||||
const world = new ClientCharacterWorld();
|
||||
world.sync([planetAt(0)]);
|
||||
|
|
@ -86,28 +91,30 @@ const measure = ({
|
|||
bodyVelocity: [0, 0],
|
||||
};
|
||||
|
||||
const input = [1, 0];
|
||||
let input = [1, 0];
|
||||
let serverInput = [1, 0];
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(settings.playerMaxStrength);
|
||||
|
||||
// Deterministic frame-time noise: a real display never delivers a perfectly even cadence.
|
||||
let seed = 12345;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
|
||||
const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
|
||||
|
||||
const nominalFrameMs = 1000 / fps;
|
||||
const toServer: Array<{ atMs: number; clientStampMs: number }> = [];
|
||||
const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = [];
|
||||
const toServer: Array<{ atMs: number; clientStampMs: number; direction?: number[] }> =
|
||||
[];
|
||||
const toClient: Array<{
|
||||
atMs: number;
|
||||
ackAgeMs: number;
|
||||
clientStampMs: number;
|
||||
pose: any;
|
||||
}> = [];
|
||||
const samples: Array<{
|
||||
screen: Array<number>;
|
||||
world: Array<number>;
|
||||
atMs: number;
|
||||
error: number;
|
||||
}> = [];
|
||||
|
||||
// The client's own view of the character, and the camera that chases it.
|
||||
const camera = [0, planetRadius + 90];
|
||||
const cameraPreviousTarget = [0, planetRadius + 90];
|
||||
|
||||
let simMs = 0;
|
||||
let nextSnapshotMs = 0;
|
||||
let nextFrameAtMs = 0;
|
||||
|
|
@ -115,27 +122,33 @@ const measure = ({
|
|||
let lastInputClientTimeMs = 0;
|
||||
let lastInputReceiptMs = 0;
|
||||
let hasPressed = false;
|
||||
|
||||
let lastServerArrivalMs = 0;
|
||||
let lastClientArrivalMs = 0;
|
||||
|
||||
while (simMs < 8000) {
|
||||
tickPlanetDetachment(server, stepSeconds);
|
||||
stepCharacterMovement(server, world, input.slice() as never, stepSeconds);
|
||||
stepCharacterMovement(server, world, serverInput.slice() as never, stepSeconds);
|
||||
simMs += stepSeconds * 1000;
|
||||
|
||||
while (toServer.length > 0 && toServer[0].atMs <= simMs) {
|
||||
const packet = toServer.shift()!;
|
||||
if (packet.direction) serverInput = packet.direction;
|
||||
if (packet.clientStampMs > lastInputClientTimeMs) {
|
||||
lastInputClientTimeMs = packet.clientStampMs;
|
||||
// Socket callbacks run between physics frames, so this is the true arrival instant,
|
||||
// not the frame boundary — plus however long the frame in progress still had to run.
|
||||
lastInputReceiptMs = packet.atMs + serverBusyMs;
|
||||
lastInputReceiptMs = packet.atMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (simMs >= nextSnapshotMs) {
|
||||
nextSnapshotMs += snapshotMs;
|
||||
lastClientArrivalMs = Math.max(
|
||||
lastClientArrivalMs,
|
||||
simMs + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs),
|
||||
);
|
||||
toClient.push({
|
||||
atMs: simMs + wireMs,
|
||||
atMs: lastClientArrivalMs,
|
||||
clientStampMs: lastInputClientTimeMs,
|
||||
ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0,
|
||||
pose: {
|
||||
|
|
@ -175,8 +188,16 @@ const measure = ({
|
|||
|
||||
// Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once
|
||||
// when the key goes down, then held by both sides.
|
||||
if (!hasPressed) {
|
||||
const desiredInput =
|
||||
changeInput && nextFrameAtMs >= 4500
|
||||
? nextFrameAtMs >= 6000
|
||||
? [0, 0]
|
||||
: [-1, 0]
|
||||
: [1, 0];
|
||||
const inputChanged = !hasPressed || desiredInput[0] !== input[0];
|
||||
if (inputChanged) {
|
||||
hasPressed = true;
|
||||
input = desiredInput;
|
||||
predictor.recordInput(input as never);
|
||||
}
|
||||
|
||||
|
|
@ -185,30 +206,35 @@ const measure = ({
|
|||
const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1);
|
||||
|
||||
if (!dropped) {
|
||||
predictor.update([planetAt(0)], frameMs / 1000);
|
||||
predictor.update([planetAt(0)]);
|
||||
const head = predictor.head.center;
|
||||
followVec2(
|
||||
camera as never,
|
||||
head,
|
||||
cameraPreviousTarget as never,
|
||||
frameMs / 1000,
|
||||
0.08,
|
||||
1500,
|
||||
);
|
||||
samples.push({
|
||||
screen: [head[0] - camera[0], head[1] - camera[1]],
|
||||
world: [head[0], head[1]],
|
||||
atMs: frameClientMs,
|
||||
error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]),
|
||||
error: Math.hypot(
|
||||
head[0] - server.head.center[0],
|
||||
head[1] - server.head.center[1],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Stamped at the top of the frame, emitted at the bottom.
|
||||
if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) {
|
||||
if (
|
||||
inputChanged ||
|
||||
(!dropped && frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1)
|
||||
) {
|
||||
lastHeartbeatClientMs = frameClientMs;
|
||||
lastServerArrivalMs = Math.max(
|
||||
lastServerArrivalMs,
|
||||
nextFrameAtMs +
|
||||
serverBusyMs +
|
||||
Math.max(0, emitLagMs + (random() * 2 - 1) * emitJitterMs) +
|
||||
Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs),
|
||||
);
|
||||
toServer.push({
|
||||
atMs: nextFrameAtMs + emitLagMs + wireMs,
|
||||
atMs: lastServerArrivalMs,
|
||||
clientStampMs: frameClientMs,
|
||||
direction: inputChanged ? input.slice() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -229,60 +255,115 @@ const measure = ({
|
|||
);
|
||||
const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
||||
|
||||
// Screen position: the camera settles to a constant lag, so anything left is visible wobble.
|
||||
const centre = [0, 1].map((axis) => {
|
||||
const values = tail.map((s) => s.screen[axis]);
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
});
|
||||
const wobble = Math.max(
|
||||
...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])),
|
||||
);
|
||||
|
||||
if ((globalThis as any).__dump) {
|
||||
console.log('--- apparent speed (u/ms) ---');
|
||||
speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`));
|
||||
}
|
||||
return {
|
||||
speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed,
|
||||
screenWobble: wobble,
|
||||
lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length,
|
||||
finalError: tail[tail.length - 1].error,
|
||||
maximumSpeed: Math.max(...speeds),
|
||||
};
|
||||
};
|
||||
|
||||
const CONDITIONS: Array<[string, Conditions]> = [
|
||||
['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
|
||||
['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
|
||||
['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }],
|
||||
['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }],
|
||||
['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }],
|
||||
['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
|
||||
['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
|
||||
[
|
||||
'localhost, clean frames',
|
||||
{ fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 },
|
||||
],
|
||||
[
|
||||
'localhost, 144 Hz',
|
||||
{ fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 },
|
||||
],
|
||||
[
|
||||
'localhost, frame jitter',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 0.2,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'localhost, 2% dropped',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 0.2,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.05,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'localhost, slower CPU',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 0.2,
|
||||
emitLagMs: 8,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'30 ms link',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 15,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.05,
|
||||
dropRate: 0,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'120 ms link',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 60,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.05,
|
||||
dropRate: 0,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe('local character render smoothness', () => {
|
||||
it('keeps on-screen travel even under realistic clocks and frame pacing', () => {
|
||||
;(globalThis as any).__dump = true
|
||||
measure(CONDITIONS[0][1])
|
||||
;(globalThis as any).__dump = false
|
||||
const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]);
|
||||
it.each(CONDITIONS)('keeps travel even with %s', (_label, conditions) => {
|
||||
const result = measure(conditions);
|
||||
expect(result.speedSpread).toBeLessThan(0.05);
|
||||
if (conditions.wireMs < 1) expect(result.lagUnits).toBeLessThan(5);
|
||||
});
|
||||
|
||||
for (const [label, r] of results) {
|
||||
console.log(
|
||||
`${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` +
|
||||
`wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` +
|
||||
`lag=${r.lagUnits.toFixed(1).padStart(5)}u `,
|
||||
);
|
||||
}
|
||||
it('absorbs changing network and client CPU delay without snapshot jumps', () => {
|
||||
const result = measure({
|
||||
fps: 144,
|
||||
wireMs: 30,
|
||||
emitLagMs: 5,
|
||||
wireJitterMs: 10,
|
||||
emitJitterMs: 5,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 2,
|
||||
});
|
||||
expect(result.speedSpread).toBeLessThan(0.4);
|
||||
expect(result.lagUnits).toBeLessThan(25);
|
||||
});
|
||||
|
||||
for (const [label, r] of results) {
|
||||
// Even travel: what the eye reads as smooth motion.
|
||||
expect(r.speedSpread, label).toBeLessThan(0.05);
|
||||
// No sub-pixel swimming against the camera, which follows the same character.
|
||||
expect(r.screenWobble, label).toBeLessThan(1);
|
||||
}
|
||||
|
||||
// Prediction exists to cancel latency; a follower that lags undoes that on every link.
|
||||
const localhost = results.find(([label]) => label === 'localhost, clean frames')![1];
|
||||
expect(localhost.lagUnits).toBeLessThan(5);
|
||||
it('reconciles direction changes and stopping across an irregular link', () => {
|
||||
const result = measure({
|
||||
fps: 144,
|
||||
wireMs: 30,
|
||||
emitLagMs: 5,
|
||||
wireJitterMs: 10,
|
||||
emitJitterMs: 5,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 2,
|
||||
changeInput: true,
|
||||
});
|
||||
expect(result.maximumSpeed).toBeLessThan(1);
|
||||
expect(result.finalError).toBeLessThan(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue