Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 ad369eb0de docs(changelog): add the #1713 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:06:06 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 0b5ae6386e perf(weather): apply the infrared cloud transfer from a lookup table, in place
The Satellite clouds transfer ran a power curve per pixel and wrote the
result back from a full-size copy. On the 4096x2048 images the 3D Tiles
host draws, that held the page for about 150 ms per image. The scale
depends only on the brightest channel, so it is now worked out once per
byte value and applied to the canvas pixels in place. Output bytes are
unchanged.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 14:42:57 +01:00
11 changed files with 118 additions and 274 deletions

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).
+3
View File
@@ -0,0 +1,3 @@
- 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).
+5 -7
View File
@@ -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;
}
/**
-5
View File
@@ -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.
+8 -29
View File
@@ -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);
-211
View File
@@ -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);
});
+2 -4
View File
@@ -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;
}
/**
+34 -13
View File
@@ -1,22 +1,29 @@
export const INFRARED_ALPHA_T0 = 0.4;
export const INFRARED_ALPHA_T1 = 0.7;
/** Return display pixels without changing RGB or the source RGBA bytes.
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.
* `strength` is 0–100: zero is the full image and 100 is the cloud key.
*/
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;
export function applyInfraredAlpha(pixels, strength = 'filtered') {
const amount = infraredAmount(strength);
if (amount === 0) return pixels;
for (let i = 0; i < pixels.length; i += 4) {
const scale = new Float64Array(256);
for (let value = 0; value < 256; value++) {
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const linearMax = (value / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
@@ -26,7 +33,21 @@ export function infraredAlpha(rgba, strength = 'filtered') {
),
);
const filtered = t * t * (3 - 2 * t);
pixels[i + 3] = rgba[i + 3] * (1 - amount + filtered * amount);
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];
}
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,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyInfraredAlpha,
infraredAlpha,
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
@@ -61,3 +62,43 @@ 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);
}
});
+3 -2
View File
@@ -6,7 +6,7 @@ import {
drawWeatherPixels,
closeWeatherPixels,
} from './source.js';
import { infraredAlpha } from './infraredAlpha.js';
import { applyInfraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
@@ -31,7 +31,8 @@ export function processInfraredImage(
function applyInfraredTransfer(context, canvas, mode) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
pixels.data.set(infraredAlpha(pixels.data, mode));
// In place: a copy of a 4096×2048 image is 33 MB, made and written back.
applyInfraredAlpha(pixels.data, mode);
context.putImageData(pixels, 0, 0);
}
+22
View File
@@ -26,6 +26,28 @@ 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);