mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Move satellites every frame between SGP4 samples
Satellite points were propagated once a second and held still in between, so while the world clock plays faster than real time they jumped: at 1 min/s, about 45 px once a second at globe scale. Each SGP4 call now keeps a sample of the satellite's inertial position, velocity, gravity and its rate of change (src/layers/satellites/motion.js). Every frame each shown point is reckoned from its sample to the world time and turned into the Earth-fixed frame by the sidereal angle, so points glide at any play speed. Core satellites still take a fresh sample every second, and at once when the world time is 30 s from it (fast play, a seek), which keeps every point within about 10 m of SGP4. Starlink extras take their first sample as they load and are resampled round-robin, more per frame the faster time plays (up to 2,000 a frame); a point more than two minutes from its sample waits for the next one. Hidden points are moved when shown again, and the tracked satellite keeps its per-frame path. Measured at globe scale: at 1 min/s points move on every frame, 0.4 px a frame and at most 1.4 px (before: once a second, 45 px). With 11,880 satellites (Starlink on) the scene holds 60 fps, frames 16.7 ms median and 19.7 ms p95; reckoning costs 0.02 ms a frame for 1,342 points.
This commit is contained in:
1 parent
e4708a815d
commit
e31de29ccc
11 files changed
+466
-60
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { gstime, twoline2satrec } from 'satellite.js';
|
||||
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
|
||||
import { reckonEarthFixed, sampleOrbit } from './motion.js';
|
||||
import {
|
||||
DENSE_REFRESH_FRAMES,
|
||||
DENSE_GROUP_PATH,
|
||||
DENSE_SAMPLES_PER_FRAME_MAX,
|
||||
POINT_STYLES,
|
||||
DENSE_CREATE_CHUNK,
|
||||
ORBIT_SAMPLE_HORIZON_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
@@ -18,37 +21,42 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-propagate a small per-frame slice of the dense extras (round-robin).
|
||||
* Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES
|
||||
* frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays
|
||||
* ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the
|
||||
* work per frame avoids the once-per-second spike a tick-sized chunk
|
||||
* (~2K props ≈ 4ms) would cause.
|
||||
* Sample a small per-frame slice of the dense extras (round-robin); every
|
||||
* point is then reckoned from its sample each frame (rendering.js
|
||||
* _reckonPoints). At normal speed the full dense set is sampled once every
|
||||
* ~DENSE_REFRESH_FRAMES frames (~5s at 60fps), so per-frame cost stays ~35
|
||||
* propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the work
|
||||
* per frame avoids the once-per-second spike a tick-sized chunk (~2K props
|
||||
* ≈ 4ms) would cause. While time plays fast, or after a seek, the slice
|
||||
* grows so each sample stays within ORBIT_SAMPLE_HORIZON_MS of the world
|
||||
* time, up to DENSE_SAMPLES_PER_FRAME_MAX a frame.
|
||||
* @param {number} worldStepMs How far the world time moved since the last
|
||||
* frame (NaN on the first).
|
||||
*/
|
||||
|
||||
function _propagateDenseChunk() {
|
||||
if (layerState._denseIds.length === 0) return;
|
||||
const perFrame = Math.max(
|
||||
1,
|
||||
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
|
||||
function _propagateDenseChunk(worldStepMs) {
|
||||
const count = layerState._denseIds.length;
|
||||
if (count === 0) return;
|
||||
const share = Math.max(
|
||||
1 / DENSE_REFRESH_FRAMES,
|
||||
Number.isFinite(worldStepMs)
|
||||
? Math.abs(worldStepMs) / ORBIT_SAMPLE_HORIZON_MS
|
||||
: 1,
|
||||
);
|
||||
const now = new Date(layerState.now());
|
||||
const perFrame = Math.min(
|
||||
count,
|
||||
DENSE_SAMPLES_PER_FRAME_MAX,
|
||||
Math.max(1, Math.ceil(count * share)),
|
||||
);
|
||||
const nowMs = layerState.now();
|
||||
for (let i = 0; i < perFrame; i++) {
|
||||
if (layerState._denseCursor >= layerState._denseIds.length)
|
||||
layerState._denseCursor = 0;
|
||||
if (layerState._denseCursor >= count) layerState._denseCursor = 0;
|
||||
const noradId = layerState._denseIds[layerState._denseCursor++];
|
||||
if (noradId === layerState._trackedNorad) continue; // per-frame tracked path owns it
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
const point = layerState._points.get(noradId);
|
||||
if (!sat || !point) continue;
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, now);
|
||||
if (pos) {
|
||||
point.position = Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
}
|
||||
if (!sat) continue;
|
||||
const sample = sampleOrbit(sat.satrec, nowMs, sat.orbitSample);
|
||||
if (sample) sat.orbitSample = sample;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +105,8 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
|
||||
const entries = parts.orbits.parseTLE(text);
|
||||
const style = POINT_STYLES.dense;
|
||||
const now = new Date(layerState.now());
|
||||
const nowMs = layerState.now();
|
||||
const gmst = gstime(new Date(nowMs));
|
||||
let added = 0;
|
||||
|
||||
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
|
||||
@@ -116,8 +125,9 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
if (!satrec || satrec.error !== 0) continue;
|
||||
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
|
||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||
if (!pos) continue;
|
||||
// Its first sample places it now and moves it from then on.
|
||||
const orbitSample = sampleOrbit(satrec, nowMs);
|
||||
if (!orbitSample) continue;
|
||||
|
||||
layerState._catalog.set(noradId, {
|
||||
name: entry.name,
|
||||
@@ -131,12 +141,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
ucs: null,
|
||||
satcat: null,
|
||||
sensor: null,
|
||||
orbitSample,
|
||||
});
|
||||
const point = layerState._pointCollection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
position: reckonEarthFixed(
|
||||
orbitSample,
|
||||
nowMs,
|
||||
gmst,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
pixelSize: style.pixelSize,
|
||||
color: style.color,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { propagate } from 'satellite.js';
|
||||
|
||||
/** The Earth's gravitational parameter, km³/s², as SGP4's WGS72 constants. */
|
||||
const MU_KM3_S2 = 398600.8;
|
||||
|
||||
/** Floats in one orbit sample: its time, then position, velocity,
|
||||
* acceleration and jerk. */
|
||||
export const ORBIT_SAMPLE_LENGTH = 13;
|
||||
|
||||
/**
|
||||
* One SGP4 state of a satellite for dead reckoning: its inertial (TEME)
|
||||
* position and velocity at `atMs`, with the two-body acceleration and its
|
||||
* rate of change (jerk) there.
|
||||
* Layout: [atMs, rx, ry, rz (km), vx, vy, vz (km/s), ax, ay, az (km/s²),
|
||||
* jx, jy, jz (km/s³)].
|
||||
* @param {object} satrec
|
||||
* @param {number} atMs Epoch milliseconds.
|
||||
* @param {Float64Array} [out]
|
||||
* @returns {Float64Array|null} Null when SGP4 has no state for that time.
|
||||
*/
|
||||
export function sampleOrbit(
|
||||
satrec,
|
||||
atMs,
|
||||
out = new Float64Array(ORBIT_SAMPLE_LENGTH),
|
||||
) {
|
||||
let state;
|
||||
try {
|
||||
state = propagate(satrec, new Date(atMs));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const p = state?.position;
|
||||
const v = state?.velocity;
|
||||
if (!p || typeof p === 'boolean' || !v || typeof v === 'boolean') return null;
|
||||
const r = Math.hypot(p.x, p.y, p.z);
|
||||
if (!(r > 0) || !Number.isFinite(v.x + v.y + v.z)) return null;
|
||||
const k = -MU_KM3_S2 / (r * r * r);
|
||||
// d/dt of -μ r/|r|³: -μ (v/|r|³ - 3 (r·v) r/|r|⁵).
|
||||
const radial = (3 * (p.x * v.x + p.y * v.y + p.z * v.z)) / (r * r);
|
||||
out[0] = atMs;
|
||||
out[1] = p.x;
|
||||
out[2] = p.y;
|
||||
out[3] = p.z;
|
||||
out[4] = v.x;
|
||||
out[5] = v.y;
|
||||
out[6] = v.z;
|
||||
out[7] = k * p.x;
|
||||
out[8] = k * p.y;
|
||||
out[9] = k * p.z;
|
||||
out[10] = k * (v.x - radial * p.x);
|
||||
out[11] = k * (v.y - radial * p.y);
|
||||
out[12] = k * (v.z - radial * p.z);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a satellite is at `atMs`, reckoned from its sample along the inertial
|
||||
* path (position, velocity, gravity and its change) and turned into the
|
||||
* Earth-fixed frame by the Greenwich sidereal angle at that time. Over half a
|
||||
* minute of world time the reckoned point stays within metres of SGP4.
|
||||
* @param {Float64Array} sample From sampleOrbit.
|
||||
* @param {number} atMs Epoch milliseconds.
|
||||
* @param {number} gmst Greenwich mean sidereal angle at `atMs`, radians.
|
||||
* @param {{x: number, y: number, z: number}} out Receives Earth-fixed metres.
|
||||
* @returns {{x: number, y: number, z: number}} `out`.
|
||||
*/
|
||||
export function reckonEarthFixed(sample, atMs, gmst, out) {
|
||||
const dt = (atMs - sample[0]) / 1000;
|
||||
const half = 0.5 * dt * dt;
|
||||
const sixth = (half * dt) / 3;
|
||||
const x = sample[1] + sample[4] * dt + sample[7] * half + sample[10] * sixth;
|
||||
const y = sample[2] + sample[5] * dt + sample[8] * half + sample[11] * sixth;
|
||||
const z = sample[3] + sample[6] * dt + sample[9] * half + sample[12] * sixth;
|
||||
const cos = Math.cos(gmst);
|
||||
const sin = Math.sin(gmst);
|
||||
out.x = (x * cos + y * sin) * 1000;
|
||||
out.y = (-x * sin + y * cos) * 1000;
|
||||
out.z = z * 1000;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// src/layers/satellites/motion.test.mjs — a satellite's point reckoned from
|
||||
// one SGP4 sample stays on SGP4's track between samples.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime, twoline2satrec } from 'satellite.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { reckonEarthFixed, sampleOrbit } from './motion.js';
|
||||
|
||||
const ISS = twoline2satrec(
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
||||
);
|
||||
const AT_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
|
||||
const orbits = createOrbits({ state: {}, services: {}, parts: {} });
|
||||
|
||||
/** Where the layer has always drawn a satellite: SGP4, through geodetic. */
|
||||
function drawnBySgp4(atMs) {
|
||||
const pos = orbits.propagatePosition(ISS, new Date(atMs));
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
}
|
||||
|
||||
function reckoned(sample, atMs) {
|
||||
return reckonEarthFixed(
|
||||
sample,
|
||||
atMs,
|
||||
gstime(new Date(atMs)),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
}
|
||||
|
||||
test('at its own time the reckoned point is where SGP4 draws it', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
const gap = Cesium.Cartesian3.distance(
|
||||
reckoned(sample, AT_MS),
|
||||
drawnBySgp4(AT_MS),
|
||||
);
|
||||
assert.ok(gap < 1, `${gap} m`);
|
||||
});
|
||||
|
||||
test('between samples the point keeps to the SGP4 track', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
for (const [seconds, withinM] of [
|
||||
[0.016, 1],
|
||||
[1, 2],
|
||||
[10, 2],
|
||||
[30, 10],
|
||||
[60, 50],
|
||||
]) {
|
||||
const atMs = AT_MS + seconds * 1000;
|
||||
const gap = Cesium.Cartesian3.distance(
|
||||
reckoned(sample, atMs),
|
||||
drawnBySgp4(atMs),
|
||||
);
|
||||
assert.ok(gap < withinM, `${seconds} s: ${gap} m`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the point moves every frame, at orbital speed', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
const a = reckoned(sample, AT_MS + 500);
|
||||
const b = reckoned(sample, AT_MS + 516);
|
||||
const speed = Cesium.Cartesian3.distance(a, b) / 0.016;
|
||||
// ISS ground-relative speed: about 7.2–7.7 km/s.
|
||||
assert.ok(speed > 7000 && speed < 8000, `${speed} m/s`);
|
||||
});
|
||||
|
||||
test('reckoning backwards in time works as forwards', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
const atMs = AT_MS - 30_000;
|
||||
const gap = Cesium.Cartesian3.distance(
|
||||
reckoned(sample, atMs),
|
||||
drawnBySgp4(atMs),
|
||||
);
|
||||
assert.ok(gap < 10, `${gap} m`);
|
||||
});
|
||||
|
||||
test('an element set SGP4 cannot propagate has no sample', () => {
|
||||
const decayed = { ...ISS, error: 0 };
|
||||
decayed.ecco = 2; // not an orbit
|
||||
assert.equal(sampleOrbit(decayed, AT_MS), null);
|
||||
});
|
||||
@@ -31,7 +31,16 @@ export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
export const ORBIT_PATH_STEPS = 180;
|
||||
// points per orbital path
|
||||
|
||||
/** How often every core satellite takes a fresh SGP4 sample (wall ms). */
|
||||
export const POSITION_UPDATE_MS = 1000;
|
||||
/**
|
||||
* Between SGP4 samples every point is reckoned each frame from its last one
|
||||
* (motion.js). A sample this far from the world time is taken again, so a
|
||||
* point is never more than about 10 m off SGP4 however fast time plays.
|
||||
*/
|
||||
export const ORBIT_SAMPLE_HORIZON_MS = 30_000;
|
||||
/** Beyond this from its sample a point is left where it is until sampled again. */
|
||||
export const ORBIT_RECKON_LIMIT_MS = 120_000;
|
||||
/** How far either side of now SGP4 on current element sets stays meaningful. */
|
||||
export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
|
||||
|
||||
@@ -41,7 +50,6 @@ export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
|
||||
* drifts a few degrees a day.
|
||||
*/
|
||||
export const ORBIT_REBAKE_MS = 30 * 60_000;
|
||||
// re-propagate every 1s (SGP4 is smooth at this rate)
|
||||
|
||||
export const RING_ROTATION_MS = 1000;
|
||||
// re-align baked orbit rings to current GMST every 1s
|
||||
@@ -80,6 +88,14 @@ export const DENSE_GROUP_PATH = 'starlink';
|
||||
export const DENSE_REFRESH_FRAMES = 300;
|
||||
// full dense pass spread over ~300 frames (~5s @ 60fps)
|
||||
|
||||
/**
|
||||
* The most dense extras sampled in one frame while time plays fast (~4 ms).
|
||||
* Past about 10 minutes of world time a second the full dense set no longer
|
||||
* fits within ORBIT_RECKON_LIMIT_MS, and its points move only as they are
|
||||
* sampled.
|
||||
*/
|
||||
export const DENSE_SAMPLES_PER_FRAME_MAX = 2000;
|
||||
|
||||
export const DENSE_CREATE_CHUNK = 1500;
|
||||
// satrec builds per macro-task while loading
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// src/layers/satellites/reckon.test.mjs — every frame each shown point moves
|
||||
// to where its satellite is at the world time; samples are taken again when
|
||||
// time has moved far from them, and dense extras are sampled faster the
|
||||
// faster time plays.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { createCatalog } from './catalog.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { sampleOrbit } from './motion.js';
|
||||
import {
|
||||
DENSE_REFRESH_FRAMES,
|
||||
DENSE_SAMPLES_PER_FRAME_MAX,
|
||||
ORBIT_RECKON_LIMIT_MS,
|
||||
ORBIT_SAMPLE_HORIZON_MS,
|
||||
} from './policy.js';
|
||||
|
||||
const ISS = twoline2satrec(
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
||||
);
|
||||
const AT_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
|
||||
const orbits = createOrbits({ state: {}, services: {}, parts: {} });
|
||||
|
||||
/** A point primitive that counts position writes. */
|
||||
function point(show = true) {
|
||||
let position = new Cesium.Cartesian3();
|
||||
return {
|
||||
show,
|
||||
writes: 0,
|
||||
get position() {
|
||||
return position;
|
||||
},
|
||||
set position(value) {
|
||||
this.writes++;
|
||||
position = Cesium.Cartesian3.clone(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scene(entries, { tracked = null } = {}) {
|
||||
const state = {
|
||||
_points: new Map(),
|
||||
_catalog: new Map(),
|
||||
_trackedNorad: tracked,
|
||||
_denseIds: [],
|
||||
_denseCursor: 0,
|
||||
};
|
||||
for (const [id, entry] of Object.entries(entries)) {
|
||||
state._points.set(id, entry.point);
|
||||
state._catalog.set(id, { satrec: ISS, group: 'core', ...entry.sat });
|
||||
}
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
services: { focus: {} },
|
||||
parts: {},
|
||||
});
|
||||
return { state, rendering };
|
||||
}
|
||||
|
||||
function sgp4At(atMs) {
|
||||
const pos = orbits.propagatePosition(ISS, new Date(atMs));
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
}
|
||||
|
||||
test('a shown point moves to its satellite at the world time; hidden and tracked ones do not', () => {
|
||||
const shown = point();
|
||||
const hidden = point(false);
|
||||
const tracked = point();
|
||||
const { rendering } = scene(
|
||||
{
|
||||
shown: { point: shown },
|
||||
hidden: { point: hidden },
|
||||
tracked: { point: tracked },
|
||||
},
|
||||
{ tracked: 'tracked' },
|
||||
);
|
||||
rendering._reckonPoints(AT_MS);
|
||||
assert.ok(Cesium.Cartesian3.distance(shown.position, sgp4At(AT_MS)) < 1);
|
||||
assert.equal(hidden.writes, 0);
|
||||
assert.equal(tracked.writes, 0);
|
||||
// The same time again (the clock paused): nothing is written.
|
||||
rendering._reckonPoints(AT_MS);
|
||||
assert.equal(shown.writes, 1);
|
||||
// Shown while paused, a hidden point moves at once.
|
||||
hidden.show = true;
|
||||
rendering._reckonPoints(AT_MS);
|
||||
assert.equal(hidden.writes, 1);
|
||||
});
|
||||
|
||||
test('between samples the point keeps to the track; far from its sample, a core satellite is sampled again', () => {
|
||||
const p = point();
|
||||
const { state, rendering } = scene({ iss: { point: p } });
|
||||
rendering._reckonPoints(AT_MS);
|
||||
const sample = state._catalog.get('iss').orbitSample;
|
||||
assert.equal(sample[0], AT_MS);
|
||||
// Within the horizon the same sample moves it on.
|
||||
const soon = AT_MS + ORBIT_SAMPLE_HORIZON_MS / 2;
|
||||
rendering._reckonPoints(soon);
|
||||
assert.equal(state._catalog.get('iss').orbitSample[0], AT_MS);
|
||||
assert.ok(Cesium.Cartesian3.distance(p.position, sgp4At(soon)) < 5);
|
||||
// A seek an hour back samples it again there.
|
||||
const back = AT_MS - 3_600_000;
|
||||
rendering._reckonPoints(back);
|
||||
assert.equal(state._catalog.get('iss').orbitSample[0], back);
|
||||
assert.ok(Cesium.Cartesian3.distance(p.position, sgp4At(back)) < 1);
|
||||
});
|
||||
|
||||
test('a dense extra is left for its round-robin, and stays put once too far from its sample', () => {
|
||||
const p = point();
|
||||
const { rendering } = scene({
|
||||
starlink: {
|
||||
point: p,
|
||||
sat: { group: 'dense', orbitSample: sampleOrbit(ISS, AT_MS) },
|
||||
},
|
||||
});
|
||||
rendering._reckonPoints(AT_MS + ORBIT_SAMPLE_HORIZON_MS * 2);
|
||||
assert.equal(p.writes, 1, 'reckoned from its own sample');
|
||||
rendering._reckonPoints(AT_MS + ORBIT_RECKON_LIMIT_MS + 1);
|
||||
assert.equal(p.writes, 1, 'too far: left where it is');
|
||||
});
|
||||
|
||||
test('the dense slice grows with how fast the world time moves, up to its cap', () => {
|
||||
const sampled = (count, worldStepMs) => {
|
||||
const state = {
|
||||
_denseIds: [],
|
||||
_denseCursor: 0,
|
||||
_catalog: new Map(),
|
||||
_trackedNorad: null,
|
||||
now: () => AT_MS,
|
||||
};
|
||||
for (let i = 0; i < count; i++) {
|
||||
state._denseIds.push(String(i));
|
||||
state._catalog.set(String(i), { satrec: ISS, group: 'dense' });
|
||||
}
|
||||
createCatalog({
|
||||
state,
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
})._propagateDenseChunk(worldStepMs);
|
||||
return [...state._catalog.values()].filter((sat) => sat.orbitSample).length;
|
||||
};
|
||||
// Real time: the set over DENSE_REFRESH_FRAMES frames.
|
||||
assert.equal(sampled(3000, 16), Math.ceil(3000 / DENSE_REFRESH_FRAMES));
|
||||
// A tenth of the horizon a frame: a tenth of the set.
|
||||
assert.equal(sampled(3000, ORBIT_SAMPLE_HORIZON_MS / 10), 300);
|
||||
// A seek, or the first frame: as many as the cap allows.
|
||||
assert.equal(sampled(3000, Number.NaN), DENSE_SAMPLES_PER_FRAME_MAX);
|
||||
assert.equal(sampled(1000, -ORBIT_SAMPLE_HORIZON_MS * 5), 1000);
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { reckonEarthFixed, sampleOrbit } from './motion.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
ORBIT_REBAKE_MS,
|
||||
ORBIT_RECKON_LIMIT_MS,
|
||||
ORBIT_SAMPLE_HORIZON_MS,
|
||||
POSITION_UPDATE_MS,
|
||||
RING_ROTATION_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** Scratch for a point's reckoned position. */
|
||||
const _reckoned = new Cesium.Cartesian3();
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -158,40 +164,73 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate all CORE satellite positions and update point primitives.
|
||||
* (About 1,300 core satellites: ~840 measured at ≈ 1.6 ms/pass, so ≈ 2.5 ms,
|
||||
* fine at the 1s/200ms cadence.) Dense extras are
|
||||
* excluded: they refresh on the round-robin budget in _propagateDenseChunk.
|
||||
* Take a fresh SGP4 sample of every CORE satellite (motion.js); the points
|
||||
* move from these each frame in _reckonPoints. (About 1,300 core
|
||||
* satellites: ~840 measured at ≈ 1.6 ms/pass, so ≈ 2.5 ms, fine at the
|
||||
* 1s/200ms cadence.) Dense extras are excluded: they are sampled on the
|
||||
* round-robin budget in _propagateDenseChunk.
|
||||
* @returns {number} How many satellites were sampled.
|
||||
*/
|
||||
|
||||
function _propagateAll() {
|
||||
const now = new Date(layerState.now());
|
||||
const nowMs = layerState.now();
|
||||
let updated = 0;
|
||||
|
||||
for (const [noradId, sat] of layerState._catalog) {
|
||||
for (const sat of layerState._catalog.values()) {
|
||||
if (sat.group === 'dense') continue;
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, now);
|
||||
if (!pos) continue;
|
||||
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
const point = layerState._points.get(noradId);
|
||||
if (point) {
|
||||
point.position = cartesian;
|
||||
updated++;
|
||||
}
|
||||
if (_sample(sat, nowMs)) updated++;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample one satellite's orbit at `atMs` into its reused sample.
|
||||
* @returns {boolean} Whether SGP4 had a state for that time.
|
||||
*/
|
||||
|
||||
function _sample(sat, atMs) {
|
||||
const sample = sampleOrbit(sat.satrec, atMs, sat.orbitSample);
|
||||
if (!sample) return false;
|
||||
sat.orbitSample = sample;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every shown point to where its satellite is at the world time,
|
||||
* reckoned from its last sample, so points glide every frame between
|
||||
* samples instead of stepping once a second. A core sample more than
|
||||
* ORBIT_SAMPLE_HORIZON_MS from the world time (time playing fast, or a
|
||||
* seek) is taken again first; dense extras are resampled by their own
|
||||
* round-robin. A hidden point is moved when it is shown again, and the
|
||||
* tracked satellite is placed by its per-frame path.
|
||||
* @param {number} nowMs World time, epoch ms.
|
||||
*/
|
||||
|
||||
function _reckonPoints(nowMs) {
|
||||
const gmst = gstime(new Date(nowMs));
|
||||
for (const [noradId, point] of layerState._points) {
|
||||
if (!point.show || noradId === layerState._trackedNorad) continue;
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
if (!sat || sat.reckonedAtMs === nowMs) continue;
|
||||
let sample = sat.orbitSample;
|
||||
if (
|
||||
sat.group !== 'dense' &&
|
||||
(!sample || Math.abs(nowMs - sample[0]) > ORBIT_SAMPLE_HORIZON_MS)
|
||||
)
|
||||
sample = _sample(sat, nowMs) ? sat.orbitSample : null;
|
||||
if (!sample || Math.abs(nowMs - sample[0]) > ORBIT_RECKON_LIMIT_MS)
|
||||
continue;
|
||||
point.position = reckonEarthFixed(sample, nowMs, gmst, _reckoned); // setter clones
|
||||
sat.reckonedAtMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared scene.preRender tick (single definition for init + enable):
|
||||
* - core fleet propagation at 200ms-tracked / 1s-idle cadence,
|
||||
* - dense extras on a per-frame round-robin budget,
|
||||
* - core fleet SGP4 samples at 200ms-tracked / 1s-idle cadence,
|
||||
* - dense extras sampled on a per-frame round-robin budget,
|
||||
* - every point reckoned from its sample each frame (motion.js),
|
||||
* - tracked satellite's point primitive per frame (WS-D2),
|
||||
* - orbit ring GMST re-alignment every ~1s (WS-D1).
|
||||
*/
|
||||
@@ -213,7 +252,12 @@ export function createRendering({
|
||||
layerState._lastPropagation = now;
|
||||
}
|
||||
|
||||
if (layerState._params.showPoints) parts.catalog._propagateDenseChunk();
|
||||
if (layerState._params.showPoints) {
|
||||
const worldMs = layerState.now();
|
||||
parts.catalog._propagateDenseChunk(worldMs - layerState._lastReckonMs);
|
||||
layerState._lastReckonMs = worldMs;
|
||||
_reckonPoints(worldMs);
|
||||
}
|
||||
|
||||
// Keep the tracked dot on the per-frame epoch shared with label + camera —
|
||||
// runs after _propagateAll so the per-frame sample wins over the 200ms one.
|
||||
@@ -339,6 +383,7 @@ export function createRendering({
|
||||
_updateOrbitPathRotations,
|
||||
_hideOrbitPath,
|
||||
_propagateAll,
|
||||
_reckonPoints,
|
||||
_preRenderTick,
|
||||
_updatePointFocus,
|
||||
applySatellitePointFocusDeemphasis,
|
||||
|
||||
@@ -64,6 +64,10 @@ export function createState({ services }) {
|
||||
|
||||
state._lastPropagation = 0;
|
||||
|
||||
// The world time of the last frame that reckoned the points (motion.js),
|
||||
// NaN before the first.
|
||||
state._lastReckonMs = NaN;
|
||||
|
||||
state._lastRingRotation = 0;
|
||||
|
||||
state._lastFocusUpdate = 0;
|
||||
|
||||
@@ -429,7 +429,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
|
||||
// Tracked entity position propagates per evaluation through the per-frame
|
||||
// cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame.
|
||||
// Falls back to the point primitive's 1s-throttled position if SGP4 fails.
|
||||
// Falls back to the point primitive's reckoned position if SGP4 fails.
|
||||
const positionProperty = new Cesium.CallbackProperty(() => {
|
||||
const pos = _getTrackedFramePosition();
|
||||
return pos ? layerState._trackedFrameCartesian : point.position;
|
||||
|
||||
Reference in new issue
Block a user