From 3e1070a2cb2d1cb9a9bf7abd9b7ba1b71bf7d921 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 13 Nov 2026 16:03:18 +0100 Subject: [PATCH] perf(cctv): hold continuous rendering for video only A still-image camera kept the scene rendering 60 times a second for as long as it was active, though its picture changes about every ten seconds. The projection loop now holds the continuous loop only for a video feed; a still asks for frames for half a second after a new image is swapped onto the plane, and a focus fade asks for one when it restyles an icon. Co-Authored-By: Claude Fable 5.2 --- src/layers/cctv/frames.js | 12 +- src/layers/cctv/policy.js | 5 + src/layers/cctv/projection.js | 37 ++++- src/layers/cctv/projectionLoop.test.mjs | 211 ++++++++++++++++++++++++ src/layers/cctv/rendering.js | 6 +- 5 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 src/layers/cctv/projectionLoop.test.mjs diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index aab106e64..2631a98a7 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -144,33 +144,35 @@ 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; + if (!runtime || runtime.mode === 'video') return false; const now = Date.now(); if ( now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) < PROJECTION_TEXTURE_SWAP_MS ) - return; + return false; const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial); - if (!planeShowing) return; + if (!planeShowing) return false; // 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; + if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return false; const buffer = paintNextProjectionBuffer(runtime); - if (!buffer) return; + if (!buffer) return false; runtime.lastTextureSwapAt = now; runtime.lastSwappedCanvasStamp = runtime.canvasStamp; runtime.planeMaterial.image = buffer; + return true; } /** diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index 574f87527..f13d0c336 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -123,6 +123,11 @@ 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. diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js index 19290584b..2216c08e1 100644 --- a/src/layers/cctv/projection.js +++ b/src/layers/cctv/projection.js @@ -6,6 +6,7 @@ import { PLANE_OUTLINE_COLOR, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, + PROJECTION_SWAP_RENDER_MS, } from './policy.js'; export function createProjection({ @@ -14,7 +15,15 @@ export function createProjection({ parts, source, }) { - const { holdContinuousRender, releaseContinuousRender } = services.render; + 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; /** * Build the protected label associated with one active monitor plane. @@ -352,12 +361,9 @@ 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()) { @@ -366,15 +372,30 @@ export function createProjection({ return; } - parts.rendering.refreshCctvFocusStyles(performance.now()); + const now = performance.now(); + const restyled = parts.rendering.refreshCctvFocusStyles(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); - parts.frames.refreshProjectionTextures(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'); } layerState._projectionRaf = requestAnimationFrame(tick); diff --git a/src/layers/cctv/projectionLoop.test.mjs b/src/layers/cctv/projectionLoop.test.mjs new file mode 100644 index 000000000..8fb2298ea --- /dev/null +++ b/src/layers/cctv/projectionLoop.test.mjs @@ -0,0 +1,211 @@ +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); +}); diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index dde6e2973..a22dfcbd5 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -61,8 +61,8 @@ export function createRendering({ !layerState._viewer || !focusPassIsNeeded(target, layerState._activeFocusStyleCount) ) - return; - if (nowMs - layerState._lastFocusStyleAt < 80) return; + return false; + if (nowMs - layerState._lastFocusStyleAt < 80) return false; layerState._lastFocusStyleAt = nowMs; const scene = layerState._viewer.scene; const camera = layerState._viewer.camera; @@ -82,6 +82,8 @@ 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; } /**