mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/main' into claude/hal-627-starfield-cb6fa2
This commit is contained in:
commit
a1a9a36f54
48 files changed
+2377
-286
No files matched your search
@@ -0,0 +1,100 @@
|
||||
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
|
||||
export const ARRIVAL_OBSERVATION_MS = 500;
|
||||
|
||||
/**
|
||||
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||
* The deadline starts when moveEnd is forced, before its rotation baseline, so
|
||||
* waiting for either frame cannot let the one-second idle refresh mask a bug.
|
||||
* `read` snapshots visibility and rotation error against the current position.
|
||||
*/
|
||||
export function observeArrivalRotation({
|
||||
scene,
|
||||
startMoveEnd,
|
||||
startReveal,
|
||||
read,
|
||||
now = () => performance.now(),
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = now();
|
||||
let moveEnd = null;
|
||||
let moveEndMs = null;
|
||||
let revealFrames = 0;
|
||||
let done = false;
|
||||
let remove;
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
done = true;
|
||||
remove?.();
|
||||
clearTimer(timer);
|
||||
};
|
||||
const finish = (result) => {
|
||||
if (done) return;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
const expired = () => ({
|
||||
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||
elapsedMs: now() - startedAt,
|
||||
moveEndMs,
|
||||
flipFrame: -1,
|
||||
});
|
||||
remove = scene.postRender.addEventListener(() => {
|
||||
if (done) return;
|
||||
try {
|
||||
const elapsedMs = now() - startedAt;
|
||||
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
|
||||
finish(expired());
|
||||
return;
|
||||
}
|
||||
const sample = read();
|
||||
if (moveEnd === null) {
|
||||
moveEnd = sample;
|
||||
moveEndMs = elapsedMs;
|
||||
startReveal();
|
||||
} else {
|
||||
revealFrames++;
|
||||
if (sample.shown) {
|
||||
// Keep the FIRST visible result, including a wrong angle. Never
|
||||
// wait for a subsequent tick to repair a failed reveal.
|
||||
finish({
|
||||
dMoveEnd: moveEnd.rotationError,
|
||||
dReveal: sample.rotationError,
|
||||
shown: sample.shown,
|
||||
flipFrame: revealFrames,
|
||||
moveEndMs,
|
||||
revealMs: elapsedMs - moveEndMs,
|
||||
elapsedMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
scene.requestRender();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||
try {
|
||||
startMoveEnd();
|
||||
scene.requestRender();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Test the captured frames without retrying a bad first observation. */
|
||||
export function arrivalRotationPassed(observation) {
|
||||
return (
|
||||
!!observation &&
|
||||
!observation.error &&
|
||||
observation.shown === true &&
|
||||
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
|
||||
(observation.dMoveEnd * 180) / Math.PI < 10 &&
|
||||
(observation.dReveal * 180) / Math.PI < 10
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ARRIVAL_OBSERVATION_MS,
|
||||
arrivalRotationPassed,
|
||||
observeArrivalRotation,
|
||||
} from './trackArrivalObservation.mjs';
|
||||
|
||||
function fixture() {
|
||||
let time = 0;
|
||||
let state = { shown: true, rotationError: 0 };
|
||||
const listeners = new Set();
|
||||
const timers = new Map();
|
||||
let renders = 0;
|
||||
let moveEnds = 0;
|
||||
const observation = observeArrivalRotation({
|
||||
scene: {
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
requestRender() {
|
||||
renders++;
|
||||
},
|
||||
},
|
||||
startMoveEnd() {
|
||||
moveEnds++;
|
||||
state.rotationError = 1.5;
|
||||
},
|
||||
startReveal() {
|
||||
state = { shown: false, rotationError: 1.5 };
|
||||
},
|
||||
read: () => ({ ...state }),
|
||||
now: () => time,
|
||||
setTimer(callback, delay) {
|
||||
const handle = {};
|
||||
timers.set(handle, { callback, at: time + delay });
|
||||
return handle;
|
||||
},
|
||||
clearTimer(handle) {
|
||||
timers.delete(handle);
|
||||
},
|
||||
});
|
||||
return {
|
||||
observation,
|
||||
frame(at, nextState) {
|
||||
time = at;
|
||||
state = { ...state, ...nextState };
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
expire(at) {
|
||||
time = at;
|
||||
for (const timer of [...timers.values()])
|
||||
if (timer.at <= at) timer.callback();
|
||||
},
|
||||
get resources() {
|
||||
return {
|
||||
listeners: listeners.size,
|
||||
timers: timers.size,
|
||||
renders,
|
||||
moveEnds,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
// The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine
|
||||
// more frames before the next 80 ms fleet interval becomes eligible.
|
||||
for (let at = 16; at < 88; at += 8) f.frame(at);
|
||||
f.frame(88, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.flipFrame, 10);
|
||||
assert.equal(result.moveEndMs, 8);
|
||||
assert.equal(result.revealMs, 80);
|
||||
assert.equal(result.elapsedMs, 88);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
});
|
||||
|
||||
test('a wrong first visible angle fails even if the next frame corrects it', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(88, { shown: true, rotationError: 1.5 });
|
||||
f.frame(96, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.dReveal, 1.5);
|
||||
assert.equal(result.flipFrame, 1);
|
||||
assert.equal(result.elapsedMs, 88);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 1.5 });
|
||||
f.frame(88, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.dMoveEnd, 1.5);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('no reveal fails at a deadline before the one-second idle catch-up', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(480);
|
||||
f.expire(500);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
f.frame(1000, { shown: true, rotationError: 0 });
|
||||
assert.equal(result.flipFrame, -1);
|
||||
});
|
||||
|
||||
test('a late visible frame fails even if the timeout callback is delayed', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(501, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.elapsedMs, 501);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
|
||||
const f = fixture();
|
||||
f.frame(400, { shown: true, rotationError: 0 });
|
||||
f.frame(600, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.moveEndMs, 400);
|
||||
assert.equal(result.elapsedMs, 600);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('missing moveEnd frames fail without waiting for a render callback', async () => {
|
||||
const f = fixture();
|
||||
f.expire(500);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
Reference in new issue
Block a user