diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9a530406c..fad19c1f9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7459,6 +7459,28 @@ apply to `public/` assets or built-preview serving. holds the way it stripped detection's would take bracket promptness with it, silently. - Known, pre-existing, and deliberately out of scope here: the overlay's backing store does not re-derive on a DPR change mid-session (`worldOverlay` sizing — untouched by this work). +- **A detection paint reuses the sweep before it when nothing it reads has changed + (`src/data/detectionPaintCache.js`).** The sweep in `_drawOverlay` (project every contact, fade + it, append its bracket, build callout candidates) depends only on the view and on the contacts. + Each paint still asks every layer for its contacts, then compares them with what the last sweep + was built from: the same objects in the same order, the same position coordinates, and the same + value in every field the sweep reads (`PAINTED_OBJECT_FIELDS`), under the same view (profile, + style, canvas size, keyhole, camera position, view-projection matrix, UI rectangles; see + `_paintViewOf`). On a match the kept bracket paths, counts and callout candidates are used again + and only the strokes and callouts are repainted. No layer has to announce a change, so none can + leave a stale picture behind. A sweep that READS a new object field or a new view value must add + it to those two lists; `detectionHost.test.mjs` compares 41 frames against a full sweep, canvas + call for canvas call. + - Never reused or kept: a solve paint (the sweep gathers the cohort), any paint while the Cyber + sonar sweep or a Global Context echo is live (brackets change with the clock), and any sweep + that reached a depth-tested contact (its answer comes from the scene). + - Where it pays: a held camera with contacts that move in steps. Live Flights re-positions the + fleet every 80 ms, so at 60 fps about seven paints in ten reuse a sweep. Once a frame takes + longer than 80 ms every paint sees moved aircraft and none is reused; after eight such sweeps + the contacts are only recorded on every eighth, so the comparison costs nothing there. + Satellites move on every frame and are never reused. + - `getDetectionDiagnostics().paintReuseCount` (and `data-paint-reuse-count`) counts reused + paints since `initDetection`; `placementBuildCount` is 0 on a reused paint. - The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is developer telemetry and is HIDDEN by default. It paints only under `?detectDebug=1` (the `trafficDebug` convention), resolved once per diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 918e3ea61..521afe83a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2690,6 +2690,7 @@ "src/data/detectionDraw.js", "src/data/detectionPolicy.js", "src/data/detectionOcclusion.js", + "src/data/detectionPaintCache.js", "src/data/detectionPresentation.js", "src/data/detectionRenderDemand.js", "src/data/sceneOcclusionProbe.js", @@ -4372,6 +4373,7 @@ "src/data/detectionRenderDemand.js", "src/data/detectionPresentation.js", "src/data/detectionOcclusion.js", + "src/data/detectionPaintCache.js", "src/data/sceneOcclusionProbe.js", "src/data/modelEligibility.js", "src/app/layers/recentImagery.js", diff --git a/src/data/detection.js b/src/data/detection.js index 770b63fef..be6d355bb 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -51,6 +51,7 @@ import { } from './detectionPolicy.js'; import { detectionBracketOpacity } from './detectionPresentation.js'; import { SurfaceOcclusion } from './detectionOcclusion.js'; +import { createDetectionPaintCache } from './detectionPaintCache.js'; import { SceneOcclusionProbe } from './sceneOcclusionProbe.js'; import { createCyberSonarSampler, @@ -243,6 +244,15 @@ let _hostLane = null; let _calloutLane = null; let _transitBracketPaths = new Map(); let _transitBracketAlpha = 1; +/** + * What the last full sweep of the objects built, kept for the paints that + * follow it with the same view and the same objects. See `_drawOverlay`. + */ +const _paintCache = createDetectionPaintCache(); +/** Scratch for `_paintViewOf`; the cache copies it when it keeps a paint. */ +const _paintView = []; +/** Paints since `initDetection` that reused a kept sweep (diagnostics). */ +let _paintReuseCount = 0; /** * Callouts solved by the sensor lane this frame, replayed by the callout lane. * The array and its rows are POOLED — `_calloutCount` is the live length — so a @@ -379,6 +389,8 @@ export function destroyDetection() { _calloutLane?.unregister?.(); _calloutLane = null; _transitBracketPaths.clear(); + _paintCache.clear(); + _paintReuseCount = 0; _calloutCount = 0; _viewer = null; _layers = []; @@ -643,6 +655,7 @@ function _publishDiagnostics() { dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0); dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0); dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0); + dataset.paintReuseCount = String(_lastDiagnostics.paintReuseCount ?? 0); } /** @@ -718,6 +731,7 @@ function _applyModeState() { _syncSurfaceVisibility(); _setLanesActive(false); _labelArbiter.clear(); + _paintCache.clear(); _surfaceOcclusion.clear(); _surfaceSuppressedKeys.clear(); _occlusionProbe.reset(); @@ -1259,15 +1273,53 @@ function _materializeCandidate( }; } +/** + * Every value of the view that the object sweep in `_drawOverlay` and the + * callout candidates it builds read, in a fixed order, for + * `detectionPaintCache`. A value the sweep starts to read belongs here. + */ +function _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive) { + const view = _paintView; + const { keyhole, cameraPosition, viewProjectionMatrix, uiRects } = frame; + let n = 0; + view[n++] = _mode; + view[n++] = _theme; + view[n++] = _charWidth; + view[n++] = frame.width; + view[n++] = frame.height; + view[n++] = keyholeOutsideOpacity; + view[n++] = cyberMapActive; + view[n++] = keyhole?.centerX; + view[n++] = keyhole?.centerY; + view[n++] = keyhole?.radius; + view[n++] = keyhole?.featherPx; + view[n++] = cameraPosition?.x; + view[n++] = cameraPosition?.y; + view[n++] = cameraPosition?.z; + for (let i = 0; i < 16; i++) view[n++] = viewProjectionMatrix[i]; + const rectCount = Array.isArray(uiRects) ? uiRects.length : -1; + view[n++] = rectCount; + for (let i = 0; i < rectCount; i++) { + const rect = uiRects[i]; + view[n++] = rect?.x; + view[n++] = rect?.y; + view[n++] = rect?.w; + view[n++] = rect?.h; + } + view.length = n; + return view; +} + /** * Paint detection against the shared host frame. Brackets remain broad and * per-frame; rich placements rebuild only for selected/fading identities and * for a bounded cohort on solve ticks. */ function _drawOverlay(frame) { - _transitBracketPaths.clear(); const { width, height } = frame; if (!_ctx || width <= 0 || height <= 0) { + _transitBracketPaths.clear(); + _paintCache.forget(); return { didSolve: false, solveMs: 0, @@ -1309,6 +1361,8 @@ function _drawOverlay(frame) { // callout lane repaints the last solve on frames the sensor lane skips, // leaving rows here would strand the final callsigns on screen after the // last data layer is switched off. + _transitBracketPaths.clear(); + _paintCache.clear(); _calloutCount = 0; _ctx.font = FONT; _drawScanlines(width, height, now); @@ -1361,6 +1415,9 @@ function _drawOverlay(frame) { // every host lane. Detection retains the manual scalar projection below. const occluder = frame.occluder; const camPos = frame.cameraPosition; + const camX = camPos.x; + const camY = camPos.y; + const camZ = camPos.z; // Answers to the occlusion tests started on earlier frames land here. _surfaceOcclusion.beginFrame(now); _occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now); @@ -1390,7 +1447,7 @@ function _drawOverlay(frame) { if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6; const tiers = _theme.tiers || null; const colorFor = (key) => (tiers && tiers[key]) || _theme.line; - const bracketPaths = new Map(); + let bracketPaths = new Map(); // Global Context sonar echoes: a second bracket, banded by strength, around // each flight or vessel a pulse front has just crossed. // Resting pulses light nothing, so the per-contact echo work is skipped. @@ -1415,14 +1472,41 @@ function _drawOverlay(frame) { }; let visibleCount = 0; - const bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 }; - const aircraftBracketSectors = { left: 0, front: 0, right: 0 }; + let bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 }; + let aircraftBracketSectors = { left: 0, front: 0, right: 0 }; let protectedVisibleCount = 0; - const candidateMap = new Map(); + let candidateMap = new Map(); const cohortBuilders = shouldSolve ? new Map() : null; const demandByLayer = shouldSolve ? new Map() : null; let placementBuildCount = 0; - for (let i = 0; i < objects.length; i++) { + + // The sweep below is the cost of this paint: every object projected, faded + // and given its bracket. What it builds depends only on the view and on the + // objects, so when both are exactly what the last sweep had, that sweep's + // paths, counts and callout candidates are used again and only the strokes + // and the callouts are repainted. Three things keep a paint from being + // reused or kept: + // - the Cyber sonar sweep and Global Context echoes change brackets with the + // clock alone; + // - a solve needs the sweep to gather its cohort; + // - a depth-tested contact gets its answer from the scene, not from here. + // A kept solve paint holds the solve's own candidates too, so the callouts a + // solve has just chosen are placed from the same map either way. + const paintKeepable = !sonarActive && !echoField; + const paintView = paintKeepable + ? _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive) + : null; + const kept = + paintKeepable && !shouldSolve + ? _paintCache.reuse(paintView, objects) + : null; + let depthTestedSwept = false; + let identityLayerId; + let renderSources; + let selectedSources; + if (!kept) _transitBracketPaths.clear(); + const sweepCount = kept ? 0 : objects.length; + for (let i = 0; i < sweepCount; i++) { const obj = objects[i]; if (!obj.position) continue; // Skip objects occluded by the ellipsoid (behind the horizon) @@ -1450,8 +1534,12 @@ function _drawOverlay(frame) { let halfW; let halfH; if (obj.type === 'AIR') { + // Cartesian3.distance, without its two argument checks per object. + const dx = camX - px; + const dy = camY - py; + const dz = camZ - pz; const bscale = nearFarScale( - Cesium.Cartesian3.distance(camPos, obj.position), + Math.sqrt(dx * dx + dy * dy + dz * dz), BILL_NEAR, BILL_NEAR_SCALE, BILL_FAR, @@ -1477,6 +1565,7 @@ function _drawOverlay(frame) { // Something the scene drew in front of a depth-tested contact hides its // bracket and callout too, on every frame, not only on solves. if (obj.depthTested === true) { + depthTestedSwept = true; const testedLayerId = obj._layerId || 'unknown'; const testedSourceId = obj.sourceId ?? obj.id ?? i; const hidden = _surfaceOcclusion.isHidden( @@ -1557,14 +1646,29 @@ function _drawOverlay(frame) { if (!primary && !micro) continue; const layerId = obj._layerId || 'unknown'; const sourceId = obj.sourceId ?? obj.id ?? i; - const liveForRender = renderIdentities.get(layerId)?.has(sourceId) || false; + // Objects arrive a layer at a time, so the layer's sets are looked up once + // per run of objects, not once per object. + if (layerId !== identityLayerId) { + identityLayerId = layerId; + renderSources = renderIdentities.get(layerId); + selectedSources = selectedIdentities?.get(layerId); + } + const liveForRender = renderSources?.has(sourceId) || false; if (!shouldSolve && !liveForRender) continue; obj._cohortSourceId = sourceId; obj._cohortPriority = _semanticPriority(obj); obj._cohortBand = admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8); - obj._cohortHash = stableIdentityHash(layerId, sourceId); + // The hash is of the identity alone, so it is worked out once per identity. + if ( + obj._cohortHashLayerId !== layerId || + obj._cohortHashSourceId !== sourceId + ) { + obj._cohortHash = stableIdentityHash(layerId, sourceId); + obj._cohortHashLayerId = layerId; + obj._cohortHashSourceId = sourceId; + } obj._candidateScreenX = sx; obj._candidateScreenY = sy; obj._candidateHalfW = halfW; @@ -1578,8 +1682,7 @@ function _drawOverlay(frame) { demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1); if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256)); - const incumbent = - selectedIdentities?.get(layerId)?.has(sourceId) || false; + const incumbent = selectedSources?.has(sourceId) || false; cohortBuilders.get(layerId).consider(obj, incumbent); } @@ -1599,6 +1702,29 @@ function _drawOverlay(frame) { } // Start this frame's occlusion tests; nothing here waits for them. _occlusionProbe.endFrame(); + if (kept) { + _paintReuseCount++; + ({ + bracketPaths, + visibleCount, + bracketOpacityCounts, + aircraftBracketSectors, + protectedVisibleCount, + candidateMap, + } = kept); + } else if (paintKeepable && !depthTestedSwept) { + // `candidateMap` is kept by reference: a solve below adds to it. + _paintCache.keep(paintView, objects, { + bracketPaths, + visibleCount, + bracketOpacityCounts, + aircraftBracketSectors, + protectedVisibleCount, + candidateMap, + }); + } else { + _paintCache.forget(); + } const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9; const collectiveBudget = labelBudgetFor(altitude, _densityPct); @@ -1782,6 +1908,7 @@ function _drawOverlay(frame) { // drawing them cost it. surfaceProbeTests: _occlusionProbe.lastFrameTests, surfaceProbeMs: _occlusionProbe.lastFrameMs, + paintReuseCount: _paintReuseCount, }; } else { const selectedCount = _labelArbiter.selectedKeys.size; @@ -1795,6 +1922,7 @@ function _drawOverlay(frame) { _lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors; _lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity; _lastDiagnostics.protectedVisibleCount = protectedVisibleCount; + _lastDiagnostics.paintReuseCount = _paintReuseCount; } _drawScanlines(width, height, now); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index c60255993..1a69580ea 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -1661,3 +1661,234 @@ test('a WebGL 1 scene reads depth synchronously and reports it once', () => { env.cleanup(); } }); + +// --- Reusing a paint ------------------------------------------------------- + +/** + * Contacts as the fleet layers return them: the same objects on every paint, + * changed in place. `fresh` returns equal copies instead, which no paint can + * reuse, so the two can be compared. + */ +function steadyContacts({ fresh = false } = {}) { + const objects = [ + { position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752), sourceId: 'air-1', id: 'AIRONE', metric: 'FL350', type: 'AIR' }, + { position: new Cesium.Cartesian3(0.4, -0.2, 6_356_752), sourceId: 'sat-1', id: 'SATONE', metric: '412KM', type: 'SAT' }, + { position: new Cesium.Cartesian3(0.1, 0.5, 6_356_752), sourceId: 'sea-1', id: 'SEAONE', metric: '12 kn', type: 'SEA' }, + { position: new Cesium.Cartesian3(-0.2, -0.5, 6_356_752), sourceId: 'mil-1', id: 'MILONE', metric: 'FL410', type: 'AIR', tier: 'military' }, + ]; + let calls = 0; + return { + objects, + get calls() { return calls; }, + layer: { + id: 'flights', + getDetectableObjects() { + calls += 1; + return fresh + ? objects.map((object) => ({ ...object, position: Cesium.Cartesian3.clone(object.position) })) + : objects; + }, + }, + }; +} + +/** Everything painted since the trace was last emptied, with each path as its points. */ +function takePainted(env) { + const painted = env.paintTrace.map((call) => + call.map((value) => (value instanceof MockPath2D ? value.points.map((point) => [...point]) : value))); + env.paintTrace.length = 0; + return painted; +} + +const bracketPointCount = (env) => env.detectionCtx.calls + .filter(([name]) => name === 'stroke') + .reduce((sum, [, path]) => sum + (path?.points?.length ?? 0), 0); + +test('a paint with the same view and the same contacts reuses the last sweep, and paints exactly what a full sweep paints', () => { + // The same frames twice: once with contacts a paint can reuse, once with + // equal copies it cannot. Every canvas call must match, frame for frame. + const frames = (fresh) => { + const env = installEnvironment(); + const contacts = steadyContacts({ fresh }); + const painted = []; + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [contacts.layer], () => {}); + setMode('DENSE'); + settleFrame(env); + painted.push(takePainted(env)); + for (let frame = 1; frame <= 40; frame++) { + // Through the run: a contact moves, one is relabelled, one changes + // tier, and the camera turns. + if (frame === 9) contacts.objects[0].position.x += 0.05; + if (frame === 17) contacts.objects[1].metric = '398KM'; + if (frame === 23) contacts.objects[2].tier = 'military'; + if (frame === 31) env.viewer.camera.viewMatrix[12] = 0.03; + nextFrame(env); + painted.push(takePainted(env)); + } + return { painted, reused: getDetectionDiagnostics().paintReuseCount }; + } finally { + env.cleanup(); + } + }; + const reusing = frames(false); + const sweeping = frames(true); + assert.equal(sweeping.reused, 0, 'copies are swept on every paint'); + // 40 frames 16 ms apart: a solve about every eighth, four changes, and the + // rest reuse the sweep before them. + assert.ok(reusing.reused >= 28, `${reusing.reused} of 40 paints reused a sweep`); + assert.equal(reusing.painted.length, sweeping.painted.length); + reusing.painted.forEach((frame, index) => { + assert.ok(frame.some(([target, name]) => target === 'detection' && name === 'stroke'), `frame ${index} strokes brackets`); + assert.deepEqual(frame, sweeping.painted[index], `frame ${index}`); + }); + assert.ok( + reusing.painted.some((frame) => frame.some(([, name, text]) => name === 'fillText' && text === 'AIRONE')), + 'callouts were painted during the run', + ); +}); + +test('a reused paint builds no bracket paths and still strokes the kept ones', () => { + const env = installEnvironment(); + const contacts = steadyContacts(); + const builtPaths = []; + class CountingPath2D extends MockPath2D { + constructor() { super(); builtPaths.push(this); } + } + try { + globalThis.Path2D = CountingPath2D; + initWorldOverlay(env.viewer); + initDetection(env.viewer, [contacts.layer], () => {}); + setMode('DENSE'); + settleFrame(env); + assert.equal(getDetectionDiagnostics().paintReuseCount, 0); + const built = builtPaths.length; + const points = builtPaths.reduce((sum, path) => sum + path.points.length, 0); + assert.ok(points >= 4 * 12, 'four brackets of twelve points were built'); + + env.detectionCtx.calls.length = 0; + nextFrame(env); + assert.equal(getDetectionDiagnostics().paintReuseCount, 1); + assert.equal(builtPaths.length, built, 'no path was built'); + assert.equal(builtPaths.reduce((sum, path) => sum + path.points.length, 0), points, 'no point was added to a kept path'); + assert.equal(bracketPointCount(env), 4 * 12, 'the kept brackets are stroked'); + assert.equal(getDetectionDiagnostics().visibleCount, 4); + assert.equal(getDetectionDiagnostics().placementBuildCount, 0, 'no placement was built on the reused paint'); + } finally { + env.cleanup(); + } +}); + +test('any change to a contact or to the view is swept on the very next paint', () => { + const changes = { + 'a contact moves': (contacts) => { contacts.objects[0].position.y -= 0.01; }, + 'a contact gets a new position object': (contacts) => { + contacts.objects[0].position = new Cesium.Cartesian3(0.3, 0.3, 6_356_752); + }, + 'a contact is renamed': (contacts) => { contacts.objects[1].id = 'SATTWO'; }, + 'a contact reading changes': (contacts) => { contacts.objects[0].metric = 'FL360'; }, + 'a contact becomes the tracked one': (contacts) => { contacts.objects[0].skipLabel = true; }, + 'a contact changes tier': (contacts) => { contacts.objects[3].tier = undefined; }, + 'a contact changes type': (contacts) => { contacts.objects[2].type = 'VEH'; }, + 'a contact joins': (contacts) => { + contacts.objects.push({ position: new Cesium.Cartesian3(0.6, 0.6, 6_356_752), sourceId: 'air-2', id: 'AIRTWO', type: 'AIR' }); + }, + 'a contact leaves': (contacts) => { contacts.objects.pop(); }, + 'a contact is replaced by an equal one': (contacts) => { contacts.objects[1] = { ...contacts.objects[1] }; }, + 'two contacts swap places in the list': (contacts) => { contacts.objects.reverse(); }, + 'the camera turns': (contacts, env) => { env.viewer.camera.viewMatrix[13] = 0.02; }, + 'the camera moves': (contacts, env) => { + env.viewer.camera.positionWC = new Cesium.Cartesian3(0, 100, 10_000_000); + }, + 'the style changes': () => { setDetectionStyle('thermal'); }, + 'the profile changes': () => { setMode('SPARSE'); }, + }; + for (const [name, change] of Object.entries(changes)) { + const env = installEnvironment(); + const contacts = steadyContacts(); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [contacts.layer], () => {}); + setDetectionStyle('normal'); + setMode('DENSE'); + settleFrame(env); + nextFrame(env); + assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: steady paints reuse`); + + change(contacts, env); + nextFrame(env); + assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: swept again`); + assert.equal(getDetectionDiagnostics().observationCount, contacts.objects.length, name); + } finally { + env.cleanup(); + } + } +}); + +test('a depth-tested contact, the sonar sweep and echoes are swept on every paint', () => { + const run = ({ depthTested = false, sonar = false, echo = false } = {}) => { + const env = installEnvironment(); + const contacts = steadyContacts(); + if (depthTested) { + contacts.objects[0].type = 'VEH'; + contacts.objects[0].depthTested = true; + } + if (sonar) { + env.document.documentElement = { + dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' }, + style: { setProperty() {}, removeProperty() {} }, + }; + const hud = env.document.createElement('div'); + hud.id = 'intel-hud'; + hud.classList = { contains: (className) => className === 'active' }; + env.document.body.appendChild(hud); + } + try { + if (echo) + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo: { layerIds: ['flights'], fadeMs: 60_000, color: 'rgb(1, 2, 3)', spreadPx: 8, lineWidth: 3.25 }, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [contacts.layer], () => {}); + setMode('DENSE'); + settleFrame(env); + const sweeps = contacts.calls; + for (let i = 0; i < 30; i++) nextFrame(env); + assert.ok(contacts.calls > sweeps, 'frames painted'); + return getDetectionDiagnostics().paintReuseCount; + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } + }; + assert.ok(run() > 15, 'the same contacts with none of these are reused'); + assert.equal(run({ depthTested: true }), 0, 'depth-tested'); + assert.equal(run({ sonar: true }), 0, 'sonar'); + assert.equal(run({ echo: true }), 0, 'echo'); +}); + +test('turning detection off lets go of the kept sweep', () => { + const env = installEnvironment(); + const contacts = steadyContacts(); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [contacts.layer], () => {}); + setMode('DENSE'); + settleFrame(env); + nextFrame(env); + assert.equal(getDetectionDiagnostics().paintReuseCount, 1); + setMode('OFF'); + setMode('DENSE'); + // The first paint back is a solve, and the one after it reuses that. + nextFrame(env); + assert.equal(getDetectionDiagnostics().paintReuseCount, 1); + nextFrame(env); + assert.equal(getDetectionDiagnostics().paintReuseCount, 2); + } finally { + env.cleanup(); + } +}); diff --git a/src/data/detectionPaintCache.js b/src/data/detectionPaintCache.js new file mode 100644 index 000000000..4fac97ec5 --- /dev/null +++ b/src/data/detectionPaintCache.js @@ -0,0 +1,206 @@ +/** + * @module detectionPaintCache + * @description Lets the detection overlay keep what one paint built (bracket + * paths, counts, callout candidates) and use it again on the next paint when + * that paint would build exactly the same thing. + * + * Nothing announces a change to this cache. Each paint hands over the values + * the sweep reads from the view and the objects the layers returned, and the + * cache compares them, value by value, with what the kept paint was built + * from. A layer therefore cannot leave a stale picture behind by forgetting to + * say it changed: an object that moved, appeared, left or was relabelled is + * seen here, and the sweep runs again. + * + * Pure: no Cesium, no DOM. + */ + +/** + * The object fields the detection sweep and its callout candidates read. + * Position is compared separately, by coordinate. `sameFields` and + * `recordFields` below name each of these outright, in this order, because a + * field read by a name held in a variable costs several times as much, and + * this runs for every object on every paint. + */ +export const PAINTED_OBJECT_FIELDS = Object.freeze([ + '_layerId', + 'type', + 'tier', + 'skipLabel', + 'id', + 'metric', + 'sourceId', + 'bracketHalfWidth', + 'bracketHalfHeight', + 'depthTested', + 'cardSourceId', + 'cardEntryId', +]); + +/** + * After this many sweeps in a row with no reuse between them, the objects are + * only recorded on every RECORD_STRIDE-th sweep. A scene whose objects move on + * every paint can never reuse one, so recording each of them would be work + * with no return; a scene that settles is picked up again within RECORD_STRIDE + * paints. + */ +export const SWEEPS_BEFORE_BACKOFF = 8; +export const RECORD_STRIDE = 8; + +const FIELD_COUNT = PAINTED_OBJECT_FIELDS.length; + +function sameFields(object, fields, f) { + return ( + object._layerId === fields[f] && + object.type === fields[f + 1] && + object.tier === fields[f + 2] && + object.skipLabel === fields[f + 3] && + object.id === fields[f + 4] && + object.metric === fields[f + 5] && + object.sourceId === fields[f + 6] && + object.bracketHalfWidth === fields[f + 7] && + object.bracketHalfHeight === fields[f + 8] && + object.depthTested === fields[f + 9] && + object.cardSourceId === fields[f + 10] && + object.cardEntryId === fields[f + 11] + ); +} + +/** Most fields are what they were at the last sweep; only the others are written. */ +function recordFields(object, fields, f) { + let value; + if ((value = object._layerId) !== fields[f]) fields[f] = value; + if ((value = object.type) !== fields[f + 1]) fields[f + 1] = value; + if ((value = object.tier) !== fields[f + 2]) fields[f + 2] = value; + if ((value = object.skipLabel) !== fields[f + 3]) fields[f + 3] = value; + if ((value = object.id) !== fields[f + 4]) fields[f + 4] = value; + if ((value = object.metric) !== fields[f + 5]) fields[f + 5] = value; + if ((value = object.sourceId) !== fields[f + 6]) fields[f + 6] = value; + if ((value = object.bracketHalfWidth) !== fields[f + 7]) + fields[f + 7] = value; + if ((value = object.bracketHalfHeight) !== fields[f + 8]) + fields[f + 8] = value; + if ((value = object.depthTested) !== fields[f + 9]) fields[f + 9] = value; + if ((value = object.cardSourceId) !== fields[f + 10]) fields[f + 10] = value; + if ((value = object.cardEntryId) !== fields[f + 11]) fields[f + 11] = value; +} + +/** + * @returns {{ + * reuse: (view: Array, objects: Array) => (object|null), + * keep: (view: Array, objects: Array, built: object) => boolean, + * forget: () => void, + * clear: () => void, + * }} + */ +export function createDetectionPaintCache() { + let built = null; + let viewKept = []; + let count = 0; + let references = []; + let fields = []; + let coordinates = new Float64Array(0); + let positioned = new Uint8Array(0); + let sweepsSinceReuse = 0; + + function sameView(view) { + if (view.length !== viewKept.length) return false; + for (let i = 0; i < view.length; i++) + if (view[i] !== viewKept[i]) return false; + return true; + } + + function sameObjects(objects) { + if (objects.length !== count) return false; + for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) { + const object = objects[i]; + if (object !== references[i]) return false; + const position = object.position; + if (position) { + // A coordinate that is not a number never equals the kept one, so such + // an object is swept again on every paint. + if ( + positioned[i] !== 1 || + position.x !== coordinates[c] || + position.y !== coordinates[c + 1] || + position.z !== coordinates[c + 2] + ) + return false; + } else if (positioned[i] !== 0) { + return false; + } + if (!sameFields(object, fields, f)) return false; + } + return true; + } + + return { + /** + * What the kept paint built, when `view` and `objects` are what it was + * built from; otherwise null. + * @param {Array} view - Every value of the view the sweep reads, in a fixed order. + * @param {Array} objects - The objects the layers returned this paint. + */ + reuse(view, objects) { + if (!built || !sameView(view) || !sameObjects(objects)) return null; + sweepsSinceReuse = 0; + return built; + }, + + /** + * Keep `result` as the result of sweeping `objects` under `view`. + * @returns {boolean} False when recording was skipped (see RECORD_STRIDE). + */ + keep(view, objects, result) { + sweepsSinceReuse++; + if ( + sweepsSinceReuse > SWEEPS_BEFORE_BACKOFF && + (sweepsSinceReuse - SWEEPS_BEFORE_BACKOFF) % RECORD_STRIDE !== 0 + ) { + built = null; + return false; + } + viewKept = view.slice(); + count = objects.length; + if (positioned.length < count) { + // Room to spare, so a feed that grows by a few contacts a poll does + // not allocate on each of them. + const room = Math.ceil(count * 1.25); + coordinates = new Float64Array(room * 3); + positioned = new Uint8Array(room); + references = new Array(room).fill(undefined); + fields = new Array(room * FIELD_COUNT).fill(undefined); + } + for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) { + const object = objects[i]; + if (references[i] !== object) references[i] = object; + const position = object.position; + positioned[i] = position ? 1 : 0; + if (position) { + coordinates[c] = position.x; + coordinates[c + 1] = position.y; + coordinates[c + 2] = position.z; + } + recordFields(object, fields, f); + } + built = result; + return true; + }, + + /** Reuse nothing until the next `keep`. */ + forget() { + built = null; + }, + + /** Forget the kept paint and let go of its objects. */ + clear() { + built = null; + viewKept = []; + count = 0; + references = []; + fields = []; + coordinates = new Float64Array(0); + positioned = new Uint8Array(0); + sweepsSinceReuse = 0; + }, + }; +} diff --git a/src/data/detectionPaintCache.test.mjs b/src/data/detectionPaintCache.test.mjs new file mode 100644 index 000000000..3b68dc3c1 --- /dev/null +++ b/src/data/detectionPaintCache.test.mjs @@ -0,0 +1,197 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createDetectionPaintCache, + PAINTED_OBJECT_FIELDS, + RECORD_STRIDE, + SWEEPS_BEFORE_BACKOFF, +} from './detectionPaintCache.js'; + +const contacts = () => [ + { + position: { x: 1, y: 2, z: 3 }, + _layerId: 'flights', + sourceId: 'a', + id: 'A', + metric: 'FL300', + type: 'AIR', + }, + { + position: { x: 4, y: 5, z: 6 }, + _layerId: 'vessels', + sourceId: 'b', + id: 'B', + type: 'SEA', + }, + { _layerId: 'cctv', sourceId: 'c', id: 'C', type: 'CAM' }, +]; +const view = () => ['DENSE', 800, 600, 0.25, 1, 0, 0, 1]; + +function kept() { + const cache = createDetectionPaintCache(); + const objects = contacts(); + const built = { paths: 'kept' }; + assert.equal(cache.keep(view(), objects, built), true); + return { cache, objects, built }; +} + +test('the kept sweep is handed back for the same view and the same objects', () => { + const { cache, objects, built } = kept(); + assert.equal(cache.reuse(view(), objects), built); + assert.equal(cache.reuse(view(), objects), built, 'and again'); + // The same objects in a new list are still the same objects. + assert.equal(cache.reuse(view(), [...objects]), built); +}); + +test('nothing is handed back before a sweep is kept, or after it is forgotten', () => { + const cache = createDetectionPaintCache(); + const objects = contacts(); + assert.equal(cache.reuse(view(), objects), null); + cache.keep(view(), objects, {}); + cache.forget(); + assert.equal(cache.reuse(view(), objects), null); + cache.keep(view(), objects, {}); + cache.clear(); + assert.equal(cache.reuse(view(), objects), null); +}); + +test('any value of the view that differs is a miss', () => { + for (let i = 0; i < view().length; i++) { + const { cache, objects } = kept(); + const changed = view(); + changed[i] = typeof changed[i] === 'number' ? changed[i] + 1e-9 : 'SPARSE'; + assert.equal(cache.reuse(changed, objects), null, `view value ${i}`); + } + const { cache, objects } = kept(); + assert.equal(cache.reuse([...view(), 0], objects), null, 'one value more'); + assert.equal( + cache.reuse(view().slice(0, -1), objects), + null, + 'one value fewer', + ); +}); + +test('a view value that is not a number never matches itself', () => { + const cache = createDetectionPaintCache(); + const objects = contacts(); + cache.keep([NaN], objects, {}); + assert.equal(cache.reuse([NaN], objects), null); +}); + +test('the view is copied when kept, so the caller may write the next one over it', () => { + const cache = createDetectionPaintCache(); + const objects = contacts(); + const scratch = view(); + const built = {}; + cache.keep(scratch, objects, built); + scratch[1] = 1024; + assert.equal(cache.reuse(scratch, objects), null); + scratch[1] = 800; + assert.equal(cache.reuse(scratch, objects), built); +}); + +test('a coordinate changed in place is a miss, however small', () => { + for (const axis of ['x', 'y', 'z']) { + const { cache, objects } = kept(); + objects[1].position[axis] += 1e-9; + assert.equal(cache.reuse(view(), objects), null, axis); + } +}); + +test('a position object swapped for an equal one is still the same paint', () => { + const { cache, objects, built } = kept(); + objects[0].position = { x: 1, y: 2, z: 3 }; + assert.equal(cache.reuse(view(), objects), built); +}); + +test('a position that appears, goes away or is not a number is a miss', () => { + let { cache, objects } = kept(); + objects[2].position = { x: 0, y: 0, z: 0 }; + assert.equal(cache.reuse(view(), objects), null, 'appears'); + + ({ cache, objects } = kept()); + objects[0].position = null; + assert.equal(cache.reuse(view(), objects), null, 'goes away'); + + cache = createDetectionPaintCache(); + objects = contacts(); + objects[0].position.x = NaN; + cache.keep(view(), objects, {}); + assert.equal(cache.reuse(view(), objects), null, 'not a number'); +}); + +test('every field the sweep reads is compared', () => { + for (const field of PAINTED_OBJECT_FIELDS) { + const { cache, objects, built } = kept(); + const before = objects[0][field]; + objects[0][field] = before === 'changed' ? 'again' : 'changed'; + assert.equal(cache.reuse(view(), objects), null, field); + objects[0][field] = before; + assert.equal(cache.reuse(view(), objects), built, `${field} put back`); + } +}); + +test('a field the sweep does not read may change freely', () => { + const { cache, objects, built } = kept(); + objects[0]._candidateScreenX = 412; + objects[0]._cohortHash = 7; + assert.equal(cache.reuse(view(), objects), built); +}); + +test('a different object, count or order is a miss', () => { + let { cache, objects } = kept(); + assert.equal(cache.reuse(view(), contacts()), null, 'equal copies'); + + ({ cache, objects } = kept()); + assert.equal(cache.reuse(view(), objects.slice(0, 2)), null, 'one fewer'); + assert.equal(cache.reuse(view(), [...objects, objects[0]]), null, 'one more'); + + ({ cache, objects } = kept()); + assert.equal( + cache.reuse(view(), [objects[1], objects[0], objects[2]]), + null, + 'order', + ); +}); + +test('keeping again replaces what was kept, for more or fewer objects', () => { + const { cache, objects, built } = kept(); + const more = [...objects, ...contacts(), ...contacts()]; + const builtMore = {}; + cache.keep(view(), more, builtMore); + assert.equal(cache.reuse(view(), objects), null); + assert.equal(cache.reuse(view(), more), builtMore); + cache.keep(view(), objects, built); + assert.equal(cache.reuse(view(), more), null); + assert.equal(cache.reuse(view(), objects), built); +}); + +test('objects that change on every paint are recorded only now and then', () => { + const cache = createDetectionPaintCache(); + const objects = contacts(); + const recorded = []; + const sweep = () => { + objects[0].position.x += 1; + assert.equal(cache.reuse(view(), objects), null); + recorded.push(cache.keep(view(), objects, {})); + }; + for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++) sweep(); + assert.ok(recorded.every(Boolean), 'the first sweeps are all recorded'); + + recorded.length = 0; + for (let i = 0; i < RECORD_STRIDE * 5; i++) sweep(); + assert.equal(recorded.filter(Boolean).length, 5, 'then one in every stride'); + + // A skipped sweep leaves nothing behind to reuse by mistake. + const built = {}; + let wasRecorded = cache.keep(view(), objects, built); + assert.equal(wasRecorded, false); + assert.equal(cache.reuse(view(), objects), null); + + // Once the objects hold still, the next recorded sweep is reused, and + // every sweep after a reuse is recorded again. + while (!wasRecorded) wasRecorded = cache.keep(view(), objects, built); + assert.equal(cache.reuse(view(), objects), built); + for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++) + assert.equal(cache.keep(view(), objects, built), true); +});