Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d37e8cf17d docs(changelog): add the #1710 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:06:01 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 3e1070a2cb 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 <[email protected]>
2026-11-13 16:03:18 +01:00
11 changed files with 274 additions and 118 deletions

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).
-3
View File
@@ -1,3 +0,0 @@
- Halve the time the satellite cloud image takes to prepare on the 3D Tiles
map by applying its transparency curve from a lookup table, in place
(mazy1998, #1713).
+7 -5
View File
@@ -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;
}
/**
+5
View File
@@ -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.
+29 -8
View File
@@ -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);
+211
View File
@@ -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);
});
+4 -2
View File
@@ -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;
}
/**
+13 -34
View File
@@ -1,29 +1,22 @@
export const INFRARED_ALPHA_T0 = 0.4;
export const INFRARED_ALPHA_T1 = 0.7;
function infraredAmount(strength) {
return strength === 'full'
? 0
: strength === 'filtered' || !Number.isFinite(strength)
? 1
: Math.max(0, Math.min(100, strength)) / 100;
}
/** Scale each pixel's alpha in place; RGB is left as it is. The scale depends
* only on the brightest channel, so it is worked out once for each of the 256
* byte values instead of once per pixel: a 4096×2048 image has 8.4 million
* pixels and the power curve per pixel held the page for over 100 ms. The
* table holds the same numbers the per-pixel formula produced, so the output
* bytes are unchanged.
/** Return display pixels without changing RGB or the source RGBA bytes.
* `strength` is 0–100: zero is the full image and 100 is the cloud key.
*/
export function applyInfraredAlpha(pixels, strength = 'filtered') {
const amount = infraredAmount(strength);
export function infraredAlpha(rgba, strength = 'filtered') {
const pixels = new Uint8ClampedArray(rgba);
const amount =
strength === 'full'
? 0
: strength === 'filtered' || !Number.isFinite(strength)
? 1
: Math.max(0, Math.min(100, strength)) / 100;
if (amount === 0) return pixels;
const scale = new Float64Array(256);
for (let value = 0; value < 256; value++) {
for (let i = 0; i < pixels.length; i += 4) {
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
const linearMax = (value / 255) ** 2.2;
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
@@ -33,21 +26,7 @@ export function applyInfraredAlpha(pixels, strength = 'filtered') {
),
);
const filtered = t * t * (3 - 2 * t);
scale[value] = 1 - amount + filtered * amount;
}
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const max = r > g ? (r > b ? r : b) : g > b ? g : b;
pixels[i + 3] = pixels[i + 3] * scale[max];
pixels[i + 3] = rgba[i + 3] * (1 - amount + filtered * amount);
}
return pixels;
}
/** Return display pixels without changing RGB or the source RGBA bytes.
* `strength` is 0–100: zero is the full image and 100 is the cloud key.
*/
export function infraredAlpha(rgba, strength = 'filtered') {
return applyInfraredAlpha(new Uint8ClampedArray(rgba), strength);
}
-41
View File
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyInfraredAlpha,
infraredAlpha,
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
@@ -62,43 +61,3 @@ test('full infrared preserves every channel including partial and zero alpha', (
]);
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
});
test('the lookup table gives the per-pixel curve byte for byte, in place', () => {
// The transfer as it was written per pixel, before the table.
const perPixel = (rgba, amount) => {
const out = new Uint8ClampedArray(rgba);
for (let i = 0; i < out.length; i += 4) {
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
1,
(linearMax - INFRARED_ALPHA_T0) /
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
),
);
out[i + 3] = rgba[i + 3] * (1 - amount + t * t * (3 - 2 * t) * amount);
}
return out;
};
// Every brightest-channel value in each channel, against a spread of alphas.
const bytes = [];
for (let value = 0; value < 256; value++)
for (const alpha of [0, 1, 37, 128, 200, 254, 255]) {
bytes.push(value, 0, 0, alpha);
bytes.push(value >> 1, value, 0, alpha);
bytes.push(0, value >> 2, value, alpha);
}
const source = new Uint8ClampedArray(bytes);
for (const strength of [1, 33, 50, 99, 100, 'filtered', Number.NaN]) {
const amount = Number.isFinite(strength) ? strength / 100 : 1;
const pixels = source.slice();
assert.equal(applyInfraredAlpha(pixels, strength), pixels);
assert.deepEqual(pixels, perPixel(source, amount), `strength ${strength}`);
}
for (const strength of [0, 'full']) {
const pixels = source.slice();
assert.deepEqual(applyInfraredAlpha(pixels, strength), source);
}
});
+2 -3
View File
@@ -6,7 +6,7 @@ import {
drawWeatherPixels,
closeWeatherPixels,
} from './source.js';
import { applyInfraredAlpha } from './infraredAlpha.js';
import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
@@ -31,8 +31,7 @@ export function processInfraredImage(
function applyInfraredTransfer(context, canvas, mode) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
// In place: a copy of a 4096×2048 image is 33 MB, made and written back.
applyInfraredAlpha(pixels.data, mode);
pixels.data.set(infraredAlpha(pixels.data, mode));
context.putImageData(pixels, 0, 0);
}
-22
View File
@@ -26,28 +26,6 @@ function canvas() {
return output;
}
test('the transfer is applied to the canvas pixels in place, with no copy written back', () => {
let read = null;
const output = {};
output.getContext = () => ({
drawImage() {},
getImageData: () => {
read = { data: new Uint8ClampedArray([194, 0, 0, 200]) };
// A second full-size buffer copied over the first is what this guards.
read.data.set = () => {
throw new Error('the canvas pixels were overwritten from a copy');
};
return read;
},
putImageData: (pixels) => {
output.pixels = pixels;
},
});
processInfraredImage({ width: 1, height: 1 }, 'filtered', () => output);
assert.equal(output.pixels, read);
assert.deepEqual([...read.data], [194, 0, 0, 98]);
});
test('decoded pixels are processed once on a same-sized canvas', () => {
const image = { width: 1, height: 1 };
const output = processInfraredImage(image, 'filtered', canvas);