mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d9f91ad6 | ||
|
|
fda54f6611 | ||
|
|
0760733780 | ||
|
|
6b4b9e43c8 | ||
|
|
c16bfa5abd | ||
|
|
22bdf14be1 | ||
|
|
5aed9ce064 | ||
|
|
96847d5856 | ||
|
|
5eb6515f44 |
No files matched your search
@@ -1,3 +0,0 @@
|
||||
- A still-image CCTV camera no longer keeps the scene rendering 60 times a
|
||||
second. Only a video feed holds continuous rendering; a still redraws when a
|
||||
new image arrives (mazy1998, #1710).
|
||||
@@ -0,0 +1,322 @@
|
||||
# Slowdown audit, November 2026
|
||||
|
||||
A sweep of `main` (`c02ede3bb`, 13 November 2026) for bugs that freeze the page
|
||||
or hold the frame rate down. This page lists what was found, why it happens and
|
||||
how to fix it, and names the pull request that carries each fix.
|
||||
|
||||
[PERFORMANCE.md](PERFORMANCE.md) is the measured baseline of a healthy build;
|
||||
this page is the list of things that fall outside it.
|
||||
|
||||
## How it was measured
|
||||
|
||||
- Keyless dev server, headless Chrome at 1440 x 900 on Apple silicon (Metal
|
||||
through ANGLE), default globe map.
|
||||
- Per scenario: load, a spell at rest, scripted drags and wheel zooms, then a
|
||||
settle. Long tasks were counted with a `PerformanceObserver`, the hot function
|
||||
named from a CPU profile, and `globe.getHeight`, `scene.sampleHeight`,
|
||||
`scene.pick` and friends wrapped to count calls and time.
|
||||
- A long task is one main-thread task over 50 ms. With no layers on, the
|
||||
baseline is two at load (longest about 240 ms) and none afterwards, with zero
|
||||
frames rendered while the camera is still.
|
||||
- Four profiling sessions ran on one machine at the same time, so absolute
|
||||
timings carry some noise. Ratios against the baseline are what to read.
|
||||
|
||||
Nothing here was measured on Google Photorealistic 3D Tiles, on a discrete or
|
||||
integrated PC GPU, or with keyed feeds (AISStream, FIRMS, TomTom). Those rows
|
||||
are marked "read in code".
|
||||
|
||||
## Findings and fixes
|
||||
|
||||
Worst first. "Fix" names the pull request that carries the fix.
|
||||
|
||||
### Measured
|
||||
|
||||
| # | Where | What happens | Fix |
|
||||
| ---: | --- | --- | --- |
|
||||
| 1 | Bike Share, local data layers, Transit, Rail | 18 s freeze switching Bike Share on over New York (2,519 stations); 2.7 s on Data Centers; multi-second freezes with Transit while zooming | #1688, #1689 |
|
||||
| 2 | Shipwrecks | 3 to 5 s freeze when the markers are built over loaded terrain; a share link with the layer on held the loading screen for 22.9 s in one run | #1703 |
|
||||
| 3 | Camera | Rendering stops until reload: at a whole-globe view looking straight down, pan a few times, then scroll one notch | #1704 |
|
||||
| 4 | Street Traffic | Zooming at 250 m over Manhattan: 25 long tasks in 24 s, longest 746 ms, 6.6 s in total | #1705 |
|
||||
| 5 | ALPR cameras | Depth samples inside the overlay paint, repeated on every re-enable: 240 samples and about 1 s across eight toggles. A first-sample freeze of up to 1.9 s was seen in the sweep but not on a second day | #1706 |
|
||||
| 6 | Wind | First zoom after load: up to 14 long tasks (longest 414 ms). Re-measured as one-off shader links, not a standing cost: see below | none proposed |
|
||||
| 7 | Submarine cables | About 2 s of long tasks on load, heap briefly at 1 GB, about 0.5 s again on each re-enable | #1707 (first step), #1722 (batched primitives) |
|
||||
| 8 | Live Flights at whole-globe view with detection labels | Fine on the test machine; at 4x CPU throttle, 8 fps with 143 long tasks in 15 s | #1708 (fleet pass), #1719 (overlay) |
|
||||
| 9 | Radio | 7 to 10 long tasks when the directory loads, longest 447 ms | #1709 |
|
||||
| 10 | Pointer pick | Not reproduced on a second look: see below | none needed |
|
||||
| 11 | CCTV | A still-image camera holds continuous rendering at 60 frames a second although its picture changes about every 10 s | #1710 |
|
||||
|
||||
### Read in code
|
||||
|
||||
| # | Where | What happens | Fix |
|
||||
| ---: | --- | --- | --- |
|
||||
| 12 | `/api/firms` | The whole fire set is filtered, serialised and sent uncompressed on every request; 15 to 55 MB estimated in a heavy season | #1711 |
|
||||
| 13 | AIS store | Both vessel maps are walked on every position message, and sorted whole once past the cap | #1712 |
|
||||
| 14 | Satellite clouds on the 3D Tiles map | A CPU pass over a 4096 x 2048 image, 90 to 180 ms measured on a synthetic canvas, twice in a row | #1713 (canvas path), #1721 (shader) |
|
||||
| 15 | NVG and FLIR styles | Over 100 texture reads per pixel, and 60 renders a second while the style is on. Measured afterwards: the reads cost 0.4 ms a frame at 1440 x 900 and 1.2 ms at 2560 x 1440 | #1718 |
|
||||
| 16 | Wind server | Full 0.25 degree GRIB fields decoded on the event loop. Measured afterwards: 68 ms blocked per warm refresh, about 250 ms on the first, about 400 ms when all six keys refresh together | #1717 |
|
||||
| 17 | Imported geo files and other per-feature ground clamps | Measured afterwards: imported files do not freeze; see below | none needed |
|
||||
|
||||
## Cause and fix, per finding
|
||||
|
||||
### 1. Terrain height reads
|
||||
|
||||
`globe.getHeight` casts a ray against the mesh of the tile under the point, and
|
||||
`scene.sampleHeight` renders a depth pass. Transit asked for every trail fix of
|
||||
every vehicle several times per poll; Bike Share sampled every station in one
|
||||
go. The fix keeps heights per point in one shared reader with a read budget
|
||||
(`src/services/terrainSurface.js`) and spreads the depth samples across frames.
|
||||
|
||||
### 2. Shipwrecks
|
||||
|
||||
`rebuild()` in `src/layers/shipwrecks/index.js` adds 7,958 billboards with
|
||||
`heightReference: CLAMP_TO_GROUND`. Each constructor runs
|
||||
`Billboard._updateClamping`, which intersects a ray with the terrain mesh,
|
||||
synchronously: 4.8 of the 4.9 s. The cost is only paid when terrain is loaded
|
||||
at the time the markers are built, so a cold start with the layer in the URL
|
||||
sometimes escapes it.
|
||||
|
||||
Fix: do not clamp. Wrecks are at sea and the markers already ignore the depth
|
||||
test, so place them on the ellipsoid with `HeightReference.NONE`.
|
||||
|
||||
### 3. Camera: rendering stops after pan then zoom
|
||||
|
||||
Pure Cesium; no application code is involved. Above 1,000 km,
|
||||
`ScreenSpaceCameraController` `handleZoom` rotates the camera about the cross
|
||||
product of the picked point's normal and the screen centre's normal whenever
|
||||
their dot product is between 0 and 1. With the pointer on the exact centre pixel
|
||||
the two are the same vector, yet `n · n` often rounds to `0.9999999999999999`,
|
||||
so the check passes with a zero axis. `Camera.rotate` then throws
|
||||
`DeveloperError: normalized result is not a number` from
|
||||
`Scene.initializeFrame`, outside the scene's own try/catch, so
|
||||
`scene.renderError` never fires and Cesium stops its render loop for good. The
|
||||
pans in the first reproduction only moved the camera to a pose where the
|
||||
rounding goes that way.
|
||||
|
||||
Fix: make `camera.rotate` ignore a zero or non-finite axis, and recover from any
|
||||
stopped loop by restoring the last drawn camera pose and restarting it a bounded
|
||||
number of times before showing Cesium's error panel.
|
||||
|
||||
### 4. Street Traffic road loading
|
||||
|
||||
Three costs in one task after the detail tiles arrive, in
|
||||
`src/layers/traffic/source.js`:
|
||||
|
||||
- `outsideDetailTiles` clips every coarse z12 road against every loaded detail
|
||||
tile (up to 16) for four regions.
|
||||
- The composed set is then handed on with `replace: true`, so
|
||||
`ingestion.js` parses every road again although each tile was parsed as it
|
||||
streamed.
|
||||
- `parseRoads` (`model.js`) asks `cachedGroundFloor` once per coordinate, and
|
||||
each call builds several string keys.
|
||||
|
||||
Fix: clip once per coarse tile against the detail tiles' union box and slice the
|
||||
work across frames (or do it in the vector-tile worker); reuse the roads parsed
|
||||
while streaming and parse only the clipped coarse remainder; look the floor up
|
||||
once per floor cell within a parse.
|
||||
|
||||
This is separate from the one-viewport-tile-at-a-time loading recorded in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md).
|
||||
|
||||
### 5. ALPR cameras
|
||||
|
||||
`refineAnchors` and `anchorFor` in `src/layers/alpr/overlay.js` call
|
||||
`scene.sampleHeight` from the overlay's paint, four per paint. Each is a depth
|
||||
render and a `readPixels`; the first one also compiles the pick shader. Results
|
||||
are kept on the entity, so a rebuild or a re-enable samples everything again.
|
||||
|
||||
Fix: on a terrain globe read `globe.getHeight` through the shared terrain reader
|
||||
instead of sampling; keep the answers in a map keyed by camera that outlives the
|
||||
entities; move what sampling remains out of the paint into a queue of one per
|
||||
frame.
|
||||
|
||||
### 6. Wind
|
||||
|
||||
The first reading blamed the layer's 916,476 vertices for slow depth reads
|
||||
during zoom. Measuring again did not support that: depth reads cost the same
|
||||
with and without Wind, hiding every wind primitive left pick time unchanged, and
|
||||
the stalls appear on the first zoom only, as shader programs link (seven
|
||||
programs, about 0.5 s, long tasks up to 166 ms). Later zooms had none.
|
||||
|
||||
The roughly 30 renders a second are the animation itself. It is already capped
|
||||
at 30 Hz and stops when paused, hidden, out of view or under reduced motion;
|
||||
lowering the rate would change how the layer looks. `disable()` releases the
|
||||
baked geometry on purpose, at the price of one task of about 130 ms on
|
||||
re-enable.
|
||||
|
||||
A third look, timing every program link on a preview build, settled it: Wind
|
||||
owns two shader programs, both link during load in 9 to 18 ms, and none ever
|
||||
linked during a zoom. The programs that do link on a zoom are Cesium's own
|
||||
globe-surface variants, with Wind on or off, and Cesium links them
|
||||
synchronously. No fix is proposed.
|
||||
|
||||
### 7. Submarine cables
|
||||
|
||||
`src/layers/submarineCables/ingestion.js` loads both collections through
|
||||
`Cesium.GeoJsonDataSource` with `clampToGround`. Switching the layer on is one
|
||||
task of about 1.4 s, and 1.2 s of it is Cesium reading the terrain height for
|
||||
the 1,917 ground-clamped landing markers, all created in the first frame,
|
||||
including the ones behind the globe.
|
||||
|
||||
Fix, first step: start the landing markers hidden and let the existing horizon
|
||||
sweep show the visible ones a few dozen per frame.
|
||||
|
||||
Fix, second step: draw from batched primitives in place of the entity data
|
||||
sources: one ground polyline primitive for the lines, one billboard collection
|
||||
for the landing markers, one point collection and one static polyline primitive
|
||||
for the reference stems, all kept while the layer is off. That removes the
|
||||
visualiser tasks, the stall on switch-off and on re-enable, and about 900 MB of
|
||||
heap. The first switch-on still links shaders and builds the ground-line
|
||||
geometry once.
|
||||
|
||||
### 8. Live Flights at whole-globe view
|
||||
|
||||
Two costs that scale with the 7,500 aircraft of a full poll:
|
||||
|
||||
- The fleet pass (`src/layers/flights/rendering.js`, every 80 ms) dead-reckons
|
||||
each visible aircraft and asks `cachedGroundFloor` for it, including aircraft
|
||||
at cruise altitude that can never touch the floor. Each lookup formats numbers
|
||||
to strings several times and rewrites a cache entry: 40% of the busy time.
|
||||
- The detection overlay (`src/data/detection.js` `_drawOverlay`) collects every
|
||||
detectable object and strokes every bracket on every frame.
|
||||
|
||||
Fix for the first: skip the floor path for a contact clearly above any terrain,
|
||||
make the floor-cell key a number, and stop promoting the cache entry on every
|
||||
read.
|
||||
|
||||
The second turned out smaller than it looked. The bracket strokes are already
|
||||
one path per colour, and collecting the objects is about 3 ms of a 56 ms frame
|
||||
at 4x throttle once the first fix is in. What costs is the per-object loop in
|
||||
`_drawOverlay` (projection, occlusion, alpha, twelve path calls per bracket).
|
||||
|
||||
Fix for the second: keep what a sweep built and reuse it on the next paint when
|
||||
the view and every contact are exactly what it was built from, compared value
|
||||
by value so no layer has to announce a change. Between two fleet passes most
|
||||
paints qualify, which cuts frame work by a fifth to a third on a still camera
|
||||
at full speed. At 4x throttle a frame outlasts a fleet pass, no paint
|
||||
qualifies, and the gain is a few milliseconds; the fleet pass is what
|
||||
dominates there.
|
||||
|
||||
### 9. Radio
|
||||
|
||||
`stationTags()` in `src/layers/radio/categories.js` splits and normalises a
|
||||
station's tags on every `hasTag` call, and `buildRadioCategories` runs that for
|
||||
every station against every category, twice.
|
||||
|
||||
Fix: normalise once per station at ingest and count categories from that.
|
||||
|
||||
### 10. Pointer pick
|
||||
|
||||
Three sessions saw a single `scene.pick` of 207 to 342 ms during a gesture and
|
||||
attributed it to `pickPointer` in `src/voice/pointerPick.js`. Timing every pick
|
||||
across pointer rests, drags and wheel bursts did not bear that out: of 78 picks
|
||||
with CCTV on over London all but two took 3 to 8 ms, and with five heavy layers
|
||||
on, 81 picks took 5 to 30 ms apart from the first (135 ms). `pickPointer` picks
|
||||
at most once per pointer rest.
|
||||
|
||||
The slow pick is the first pick after a layer appears, once per page load, most
|
||||
likely the pick shader compiling, and its stack is CCTV's own hover pick
|
||||
(`src/layers/cctv/hover.js`). No fix is proposed: removing the dwell pick would
|
||||
only move the one-off cost to the next click.
|
||||
|
||||
### 11. CCTV still-image cameras
|
||||
|
||||
`src/layers/cctv/projection.js` holds continuous rendering for as long as any
|
||||
projected camera is active and redraws the projection every frame.
|
||||
|
||||
Fix: hold only for video. For a still image, request one render when a new image
|
||||
lands or while a focus fade runs.
|
||||
|
||||
### 12. `/api/firms`
|
||||
|
||||
`server/providers/firms.js` builds the payload and calls `JSON.stringify` per
|
||||
request, with no kept body and no compression, and the client refetches every
|
||||
ten minutes.
|
||||
|
||||
Fix: keep the response bytes and their gzip and serve those, as the OpenSky
|
||||
provider does. The route filters to the trailing 24 hours at serve time, so the
|
||||
bytes can only be reused while that filter keeps the same records (about a
|
||||
minute at a time): bursts and several clients gain most, and every response is
|
||||
about seven times smaller.
|
||||
|
||||
### 13. AIS store
|
||||
|
||||
`server/providers/vessels/ais-store.js` calls `pruneAisStreamCache()` for every
|
||||
message; the static-data map is never pruned.
|
||||
|
||||
Fix: prune on a timer, evict in insertion order, and drop a vessel's static
|
||||
entry with the vessel.
|
||||
|
||||
### 14. Satellite clouds on the 3D Tiles map
|
||||
|
||||
`src/layers/weather/infraredImage.js` runs `getImageData`, a per-pixel power
|
||||
curve and `putImageData` over the whole canvas.
|
||||
|
||||
Fix: a 256-entry lookup table applied in place for the canvas path, which
|
||||
halves it; then, for the shell on the 3D Tiles map, the same curve in its
|
||||
fragment shader from a strength uniform, so the image is never read back and a
|
||||
strength change builds no new image.
|
||||
|
||||
### 15. NVG and FLIR styles
|
||||
|
||||
`src/styles/surveillance.js` loops 11 x 11 texture reads per pixel and
|
||||
`src/styles/thermal.js` totals 106. Timed on the GPU, those loops cost about
|
||||
0.4 ms a frame at 1440 x 900 and 1.2 ms at 2560 x 1440 on the test machine:
|
||||
real, but small there. The standing cost is that a style with animated grain
|
||||
holds continuous rendering at 60 frames a second.
|
||||
|
||||
Fix: split the bloom into a row pass and a column pass, which gives the same
|
||||
picture from a fifth to a half of the reads, and step the grain's clock at
|
||||
15 Hz, asking for one frame per step instead of holding the render loop. The
|
||||
grain then changes 15 times a second, which is visible in motion.
|
||||
|
||||
Switching on any style stage at all costs more than the bloom did (1.8 ms a
|
||||
frame at 1440 x 900 for the one-line noir style). That was not looked into.
|
||||
|
||||
### 16. Wind server
|
||||
|
||||
`server/providers/wind/decode.js` decodes about a million doubles per message
|
||||
synchronously, hourly, for six keys.
|
||||
|
||||
Fix: decode on a worker thread, and keep the sampled U and V of the last
|
||||
forecast file per model so that choosing an overlay fetches only its field.
|
||||
|
||||
### 17. Per-feature ground clamps
|
||||
|
||||
`heightReference: CLAMP_TO_GROUND` per feature is also set in
|
||||
`installations/rendering.js`, `alpr/presentation.js`, `ghostMaps/index.js`,
|
||||
`earthquakes/index.js`, `airQuality/index.js`, `news/index.js` and
|
||||
`osmPlaces/index.js`. Their counts were small in these runs (30 to 1,500) and
|
||||
none froze.
|
||||
|
||||
Imported geo files were the one unbounded case, and measuring them cleared
|
||||
them: 20,000 generated points (the import cap) finish in about 1.5 s with a
|
||||
longest task near 0.5 s, because `settleGroundPins` in
|
||||
`src/data/geoFileImport.js` already places pins with `HeightReference.NONE` and
|
||||
resolves heights 256 at a time with a yield between batches. No fix is needed.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
- Start-up of a production build: viewer ready in about 0.5 s, loading screen
|
||||
gone at 1.6 s, three long tasks totalling about 300 ms.
|
||||
- Idle: no frames rendered with no layers on; every render hold was released
|
||||
when its layer went off.
|
||||
- Leaks: all 41 layers toggled three times and ten of them eight times; no
|
||||
timer, scene listener, primitive or data source grew per cycle. The heap
|
||||
levelled off near 320 MB over 7.5 minutes of toggling eleven layers.
|
||||
- At most one long task after load: Military, Satellites (including 11,903
|
||||
Starlink points), Earthquakes, News, Wildlife, GNSS, Drone Airspace, Military
|
||||
Installations, Fire Perimeters, rain radar, lightning, and satellite clouds on
|
||||
the globe map.
|
||||
- Live Flights at city scale with the same 7,500 aircraft loaded: 60 fps, no
|
||||
long tasks.
|
||||
|
||||
## Not covered
|
||||
|
||||
- Google Photorealistic 3D Tiles: nothing was run there. Depth sampling is the
|
||||
only height source on that map, so findings 4, 5 and 17 may be worse.
|
||||
- Live Vessels, FIRMS with data, TomTom traffic, Local ADS-B.
|
||||
- Frame rate on anything but one Apple silicon machine.
|
||||
- One all-layers run over Manhattan grew the heap from 346 to 638 MB in 60 s;
|
||||
whether that is retained was not established.
|
||||
- One Satellites load showed a 5.2 s long task once and never again in four more
|
||||
loads.
|
||||
@@ -6,6 +6,9 @@ specification and should not be used to predict performance on untested systems.
|
||||
The original capture artifacts are not included here, so this page records
|
||||
results rather than defining a runnable benchmark.
|
||||
|
||||
For slowdowns found since, with their causes and fixes, see
|
||||
[PERFORMANCE-AUDIT-2026-11.md](PERFORMANCE-AUDIT-2026-11.md).
|
||||
|
||||
## Test context
|
||||
|
||||
The baseline was captured on 22 August 2026 with these conditions:
|
||||
|
||||
@@ -144,35 +144,33 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
* the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS.
|
||||
*
|
||||
* @param {Object} record - Camera record with an initialized projection runtime.
|
||||
* @returns {boolean} Whether a new image was put on the plane.
|
||||
*/
|
||||
|
||||
function refreshProjectionTextures(record) {
|
||||
const runtime = record?.projection;
|
||||
if (!runtime || runtime.mode === 'video') return false;
|
||||
if (!runtime || runtime.mode === 'video') return;
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) <
|
||||
PROJECTION_TEXTURE_SWAP_MS
|
||||
)
|
||||
return false;
|
||||
return;
|
||||
|
||||
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
|
||||
if (!planeShowing) return false;
|
||||
if (!planeShowing) return;
|
||||
|
||||
// Only swap when the canvas content actually changed since the last swap.
|
||||
// Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED
|
||||
// canvas re-uploads the texture for nothing, and each material image
|
||||
// reassignment is a flash opportunity on the live plane (owner field test
|
||||
// 2026-07-04: intermittent white flashes on the monitor plane).
|
||||
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return false;
|
||||
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return;
|
||||
|
||||
const buffer = paintNextProjectionBuffer(runtime);
|
||||
if (!buffer) return false;
|
||||
if (!buffer) return;
|
||||
runtime.lastTextureSwapAt = now;
|
||||
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
||||
runtime.planeMaterial.image = buffer;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,11 +123,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
|
||||
|
||||
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
|
||||
|
||||
// A still-image projection changes only when a new still is swapped onto the
|
||||
// plane, so the scene renders for this long after a swap (Cesium binds the new
|
||||
// image over the next frames) and is otherwise left idle.
|
||||
export const PROJECTION_SWAP_RENDER_MS = 500;
|
||||
|
||||
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
|
||||
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
PLANE_OUTLINE_COLOR,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_SWAP_RENDER_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createProjection({
|
||||
@@ -15,15 +14,7 @@ export function createProjection({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender = () => {},
|
||||
} = services.render;
|
||||
// Until this time (performance.now) the projection loop asks for a frame on
|
||||
// every tick: after a still is swapped onto the plane, and after an edge
|
||||
// that re-arms the loop (activation, projection shown).
|
||||
let renderUntil = 0;
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* Build the protected label associated with one active monitor plane.
|
||||
@@ -361,9 +352,12 @@ export function createProjection({
|
||||
}
|
||||
|
||||
function startProjectionLoop() {
|
||||
renderUntil = performance.now() + PROJECTION_SWAP_RENDER_MS;
|
||||
if (layerState._projectionRaf) return;
|
||||
if (!parts.model.projectionLoopIsNeeded()) return;
|
||||
// The armed projection loop uploads video textures / runs focus fades per
|
||||
// frame — the scene must render continuously while it runs. Released when
|
||||
// the tick self-stops. (perf wave 2)
|
||||
holdContinuousRender('cctv-projection');
|
||||
|
||||
const tick = () => {
|
||||
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
|
||||
@@ -372,30 +366,15 @@ export function createProjection({
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const restyled = parts.rendering.refreshCctvFocusStyles(now);
|
||||
parts.rendering.refreshCctvFocusStyles(performance.now());
|
||||
|
||||
const active = parts.selection.getActiveRecord();
|
||||
let video = false;
|
||||
if (layerState._enabled && layerState._showProjection && active) {
|
||||
ensureProjectionRuntime(active);
|
||||
if (active.projection && !active.projection.video) {
|
||||
parts.frames.drawProjectionFrame(active);
|
||||
if (parts.frames.refreshProjectionTextures(active))
|
||||
renderUntil = now + PROJECTION_SWAP_RENDER_MS;
|
||||
} else video = Boolean(active.projection?.video);
|
||||
}
|
||||
|
||||
// Only a video feed changes the scene on every frame, so only video
|
||||
// holds the continuous loop. A still lands about every ten seconds and
|
||||
// a focus fade restyles icons every 80 ms; each asks for the frames it
|
||||
// needs. Holding for stills rendered the scene 60 times a second for as
|
||||
// long as a camera was active. (perf wave 2 held for both.)
|
||||
if (video) holdContinuousRender('cctv-projection');
|
||||
else {
|
||||
releaseContinuousRender('cctv-projection');
|
||||
if (restyled || now < renderUntil)
|
||||
governorRequestRender('cctv-projection');
|
||||
parts.frames.refreshProjectionTextures(active);
|
||||
}
|
||||
}
|
||||
|
||||
layerState._projectionRaf = requestAnimationFrame(tick);
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createProjection } from './projection.js';
|
||||
import { createFrames } from './frames.js';
|
||||
import {
|
||||
PROJECTION_SWAP_RENDER_MS,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** The projection loop on a hand-cranked clock, with the render governor's calls recorded. */
|
||||
function harness(t, { video = false, showProjection = true } = {}) {
|
||||
let clock = 1000;
|
||||
const frames = [];
|
||||
const original = {
|
||||
now: performance.now,
|
||||
request: globalThis.requestAnimationFrame,
|
||||
cancel: globalThis.cancelAnimationFrame,
|
||||
};
|
||||
performance.now = () => clock;
|
||||
globalThis.requestAnimationFrame = (step) => frames.push(step);
|
||||
globalThis.cancelAnimationFrame = () => frames.splice(0);
|
||||
t.after(() => {
|
||||
performance.now = original.now;
|
||||
globalThis.requestAnimationFrame = original.request;
|
||||
globalThis.cancelAnimationFrame = original.cancel;
|
||||
});
|
||||
|
||||
const holds = new Set();
|
||||
const calls = { hold: 0, requests: 0, draws: 0 };
|
||||
const record = {
|
||||
camera: { id: 'cam-1' },
|
||||
projection: { video: video ? {} : null, mode: video ? 'video' : 'image' },
|
||||
};
|
||||
const scene = { needed: true, swap: false, restyled: false };
|
||||
const state = {
|
||||
_viewer: {},
|
||||
_enabled: true,
|
||||
_showProjection: showProjection,
|
||||
_projectionRaf: 0,
|
||||
_projectionEntities: [],
|
||||
};
|
||||
const projection = createProjection({
|
||||
state,
|
||||
services: {
|
||||
render: {
|
||||
holdContinuousRender(owner) {
|
||||
calls.hold++;
|
||||
holds.add(owner);
|
||||
},
|
||||
releaseContinuousRender: (owner) => holds.delete(owner),
|
||||
governorRequestRender: () => calls.requests++,
|
||||
},
|
||||
},
|
||||
parts: {
|
||||
model: { projectionLoopIsNeeded: () => scene.needed },
|
||||
rendering: { refreshCctvFocusStyles: () => scene.restyled },
|
||||
selection: { getActiveRecord: () => record },
|
||||
frames: {
|
||||
drawProjectionFrame: () => calls.draws++,
|
||||
refreshProjectionTextures() {
|
||||
const swapped = scene.swap;
|
||||
scene.swap = false;
|
||||
return swapped;
|
||||
},
|
||||
},
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return {
|
||||
projection,
|
||||
state,
|
||||
record,
|
||||
scene,
|
||||
holds,
|
||||
calls,
|
||||
/** Run `count` animation frames 16 ms apart; returns the frames requested in them. */
|
||||
run(count = 1) {
|
||||
const before = calls.requests;
|
||||
for (let i = 0; i < count; i++) {
|
||||
clock += 16;
|
||||
frames.shift()?.();
|
||||
}
|
||||
return calls.requests - before;
|
||||
},
|
||||
advance(ms) {
|
||||
clock += ms;
|
||||
},
|
||||
get queued() {
|
||||
return frames.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a still-image camera never holds the continuous render loop', (t) => {
|
||||
const h = harness(t);
|
||||
h.projection.startProjectionLoop();
|
||||
h.run(600);
|
||||
assert.equal(h.calls.hold, 0);
|
||||
assert.equal(h.holds.size, 0);
|
||||
assert.equal(h.calls.draws, 600, 'the loop still polls for a new still');
|
||||
assert.equal(h.queued, 1, 'and stays armed');
|
||||
});
|
||||
|
||||
test('a new still on the plane is rendered, then the scene goes idle again', (t) => {
|
||||
const h = harness(t);
|
||||
h.projection.startProjectionLoop();
|
||||
// Arming the loop (activation, projection shown) renders its first frames.
|
||||
assert.ok(h.run(5) === 5);
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS);
|
||||
assert.equal(h.run(120), 0, 'an unchanged still asks for no frame');
|
||||
|
||||
h.scene.swap = true;
|
||||
assert.equal(h.run(1), 1, 'the tick that swaps the image asks for a frame');
|
||||
const settling = Math.floor(PROJECTION_SWAP_RENDER_MS / 16) - 2;
|
||||
assert.equal(h.run(settling), settling, 'and so do the ticks while it binds');
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS);
|
||||
assert.equal(h.run(120), 0);
|
||||
assert.equal(h.holds.size, 0);
|
||||
});
|
||||
|
||||
test('activating another camera while the loop runs renders its first frames', (t) => {
|
||||
const h = harness(t);
|
||||
h.projection.startProjectionLoop();
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS * 2);
|
||||
assert.equal(h.run(10), 0);
|
||||
h.projection.startProjectionLoop();
|
||||
assert.equal(h.run(10), 10);
|
||||
assert.equal(h.queued, 1, 'still one loop');
|
||||
});
|
||||
|
||||
test('a video camera holds continuous render until it stops being video', (t) => {
|
||||
const h = harness(t, { video: true });
|
||||
h.projection.startProjectionLoop();
|
||||
h.run(3);
|
||||
assert.deepEqual([...h.holds], ['cctv-projection']);
|
||||
assert.equal(h.calls.draws, 0, 'Cesium updates the video texture itself');
|
||||
// The stream failed over to stills.
|
||||
h.record.projection.video = null;
|
||||
h.run(1);
|
||||
assert.equal(h.holds.size, 0);
|
||||
h.record.projection.video = {};
|
||||
h.run(1);
|
||||
assert.deepEqual([...h.holds], ['cctv-projection']);
|
||||
h.projection.stopProjectionLoop();
|
||||
assert.equal(h.holds.size, 0);
|
||||
});
|
||||
|
||||
test('a focus fade asks for a frame only on the ticks that restyle an icon', (t) => {
|
||||
const h = harness(t, { showProjection: false });
|
||||
h.projection.startProjectionLoop();
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS * 2);
|
||||
assert.equal(h.run(10), 0);
|
||||
h.scene.restyled = true;
|
||||
assert.equal(h.run(4), 4);
|
||||
h.scene.restyled = false;
|
||||
assert.equal(h.run(10), 0);
|
||||
assert.equal(h.holds.size, 0);
|
||||
});
|
||||
|
||||
test('the loop releases and stops when it has no work', (t) => {
|
||||
const h = harness(t, { video: true });
|
||||
h.projection.startProjectionLoop();
|
||||
h.run(2);
|
||||
h.scene.needed = false;
|
||||
h.run(1);
|
||||
assert.equal(h.holds.size, 0);
|
||||
assert.equal(h.state._projectionRaf, 0);
|
||||
assert.equal(h.queued, 0);
|
||||
});
|
||||
|
||||
test('refreshProjectionTextures reports whether it put a new image on the plane', (t) => {
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({
|
||||
getContext: () => ({ clearRect() {}, drawImage() {} }),
|
||||
}),
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
});
|
||||
const frames = createFrames({
|
||||
state: {},
|
||||
services: {},
|
||||
parts: { model: { safeNumber: (value, fallback) => value ?? fallback } },
|
||||
source: {},
|
||||
});
|
||||
const runtime = {
|
||||
mode: 'image',
|
||||
canvas: {},
|
||||
canvasStamp: 1,
|
||||
planeEntity: { show: true },
|
||||
planeMaterial: { image: null },
|
||||
};
|
||||
const record = { projection: runtime };
|
||||
assert.equal(frames.refreshProjectionTextures(record), true);
|
||||
const first = runtime.planeMaterial.image;
|
||||
assert.ok(first);
|
||||
// Unchanged canvas: nothing to render.
|
||||
runtime.lastTextureSwapAt -= PROJECTION_TEXTURE_SWAP_MS;
|
||||
assert.equal(frames.refreshProjectionTextures(record), false);
|
||||
assert.equal(runtime.planeMaterial.image, first);
|
||||
runtime.canvasStamp = 2;
|
||||
assert.equal(frames.refreshProjectionTextures(record), true);
|
||||
assert.notEqual(runtime.planeMaterial.image, first);
|
||||
runtime.planeEntity.show = false;
|
||||
runtime.canvasStamp = 3;
|
||||
runtime.lastTextureSwapAt -= PROJECTION_TEXTURE_SWAP_MS;
|
||||
assert.equal(frames.refreshProjectionTextures(record), false);
|
||||
assert.equal(frames.refreshProjectionTextures({ projection: { mode: 'video' } }), false);
|
||||
});
|
||||
@@ -61,8 +61,8 @@ export function createRendering({
|
||||
!layerState._viewer ||
|
||||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
||||
)
|
||||
return false;
|
||||
if (nowMs - layerState._lastFocusStyleAt < 80) return false;
|
||||
return;
|
||||
if (nowMs - layerState._lastFocusStyleAt < 80) return;
|
||||
layerState._lastFocusStyleAt = nowMs;
|
||||
const scene = layerState._viewer.scene;
|
||||
const camera = layerState._viewer.camera;
|
||||
@@ -82,8 +82,6 @@ export function createRendering({
|
||||
baseColorFor: cameraColorFor,
|
||||
});
|
||||
layerState._activeFocusStyleCount = result.activeCount;
|
||||
// Whether an icon was restyled, so the caller can ask for a frame.
|
||||
return result.writes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user