mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37e8cf17d | ||
|
|
3e1070a2cb |
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
- 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).
|
||||||
@@ -144,33 +144,35 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
* the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS.
|
* the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS.
|
||||||
*
|
*
|
||||||
* @param {Object} record - Camera record with an initialized projection runtime.
|
* @param {Object} record - Camera record with an initialized projection runtime.
|
||||||
|
* @returns {boolean} Whether a new image was put on the plane.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function refreshProjectionTextures(record) {
|
function refreshProjectionTextures(record) {
|
||||||
const runtime = record?.projection;
|
const runtime = record?.projection;
|
||||||
if (!runtime || runtime.mode === 'video') return;
|
if (!runtime || runtime.mode === 'video') return false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) <
|
now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) <
|
||||||
PROJECTION_TEXTURE_SWAP_MS
|
PROJECTION_TEXTURE_SWAP_MS
|
||||||
)
|
)
|
||||||
return;
|
return false;
|
||||||
|
|
||||||
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
|
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.
|
// 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
|
// 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
|
// canvas re-uploads the texture for nothing, and each material image
|
||||||
// reassignment is a flash opportunity on the live plane (owner field test
|
// reassignment is a flash opportunity on the live plane (owner field test
|
||||||
// 2026-07-04: intermittent white flashes on the monitor plane).
|
// 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);
|
const buffer = paintNextProjectionBuffer(runtime);
|
||||||
if (!buffer) return;
|
if (!buffer) return false;
|
||||||
runtime.lastTextureSwapAt = now;
|
runtime.lastTextureSwapAt = now;
|
||||||
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
||||||
runtime.planeMaterial.image = buffer;
|
runtime.planeMaterial.image = buffer;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
|
|||||||
|
|
||||||
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
|
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
|
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
|
||||||
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
||||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PLANE_OUTLINE_COLOR,
|
PLANE_OUTLINE_COLOR,
|
||||||
PROJECTION_CANVAS_WIDTH,
|
PROJECTION_CANVAS_WIDTH,
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
PROJECTION_SWAP_RENDER_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
export function createProjection({
|
export function createProjection({
|
||||||
@@ -14,7 +15,15 @@ export function createProjection({
|
|||||||
parts,
|
parts,
|
||||||
source,
|
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.
|
* Build the protected label associated with one active monitor plane.
|
||||||
@@ -352,12 +361,9 @@ export function createProjection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startProjectionLoop() {
|
function startProjectionLoop() {
|
||||||
|
renderUntil = performance.now() + PROJECTION_SWAP_RENDER_MS;
|
||||||
if (layerState._projectionRaf) return;
|
if (layerState._projectionRaf) return;
|
||||||
if (!parts.model.projectionLoopIsNeeded()) 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 = () => {
|
const tick = () => {
|
||||||
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
|
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
|
||||||
@@ -366,15 +372,30 @@ export function createProjection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.rendering.refreshCctvFocusStyles(performance.now());
|
const now = performance.now();
|
||||||
|
const restyled = parts.rendering.refreshCctvFocusStyles(now);
|
||||||
|
|
||||||
const active = parts.selection.getActiveRecord();
|
const active = parts.selection.getActiveRecord();
|
||||||
|
let video = false;
|
||||||
if (layerState._enabled && layerState._showProjection && active) {
|
if (layerState._enabled && layerState._showProjection && active) {
|
||||||
ensureProjectionRuntime(active);
|
ensureProjectionRuntime(active);
|
||||||
if (active.projection && !active.projection.video) {
|
if (active.projection && !active.projection.video) {
|
||||||
parts.frames.drawProjectionFrame(active);
|
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);
|
layerState._projectionRaf = requestAnimationFrame(tick);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -61,8 +61,8 @@ export function createRendering({
|
|||||||
!layerState._viewer ||
|
!layerState._viewer ||
|
||||||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
||||||
)
|
)
|
||||||
return;
|
return false;
|
||||||
if (nowMs - layerState._lastFocusStyleAt < 80) return;
|
if (nowMs - layerState._lastFocusStyleAt < 80) return false;
|
||||||
layerState._lastFocusStyleAt = nowMs;
|
layerState._lastFocusStyleAt = nowMs;
|
||||||
const scene = layerState._viewer.scene;
|
const scene = layerState._viewer.scene;
|
||||||
const camera = layerState._viewer.camera;
|
const camera = layerState._viewer.camera;
|
||||||
@@ -82,6 +82,8 @@ export function createRendering({
|
|||||||
baseColorFor: cameraColorFor,
|
baseColorFor: cameraColorFor,
|
||||||
});
|
});
|
||||||
layerState._activeFocusStyleCount = result.activeCount;
|
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