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
15 changed files with 317 additions and 260 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).
@@ -1,3 +0,0 @@
- Satellite clouds on the 3D Tiles map apply their transparency in the shader
instead of rewriting each 4096 x 2048 image on the page, and changing the
cloud strength no longer builds a new image (mazy1998, #1721).
+4 -6
View File
@@ -891,11 +891,9 @@ image proxy accepts `bbox=west,south,east,north` for every product: inside the
product bounds, 2:1 within 1 %, rounded to 0.25°, up to 4096×2048 (the
default), cached by product, time, size and bbox. Diagnostics report
`shell.detail` (`bbox`, `size`, `ready`, `enabled`).
Clouds only applies a soft brightness ramp, using
Clouds only applies a soft brightness ramp to decoded pixels once, using
Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40
to 0.70. Globe hosts apply it to decoded pixels once; a shell applies the same
ramp in its fragment shader from a strength uniform, so its images are drawn
as decoded and a strength change neither fetches nor rewrites an image. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
preserved in Full image mode; both modes use the chosen layer opacity. This
is a display filter, not a cloud mask. The cloud-filter slider is a continuous
0–100% strength: 0% is Full, 100% is the existing Clouds only key, and
@@ -911,13 +909,13 @@ and processes it before staging. Globe hosts crop that canvas into 256 px tiles
to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent,
avoiding request-dependent contrast seams.
On globe hosts, regional infrared retains network tiles and processes each
decoded tile once; shells never read a frame's pixels back.
decoded tile once; shells process each whole frame once.
Exact-time tile and image responses are immutable for 24 hours; manifests and
errors remain uncached. Each globe renderer retains up to 6 processed global
mosaics in a least-recently-used cache keyed by observation time and infrared
mode (up to 48 MiB of canvas pixels). Each shell keeps decoded full-extent and
detail images in one least-recently-used cache bounded at 128 MiB, keyed by
time and window: the shown frame and the warmed next frame, each
time, mode and window: the shown frame and the warmed next frame, each
full-extent and detail; nothing older survives them. Disable clears the caches.
Cache hits skip
fetch/decode and report `mosaic.cached: true` with zero decode time.
+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 -36
View File
@@ -1,31 +1,22 @@
export const INFRARED_ALPHA_T0 = 0.4;
export const INFRARED_ALPHA_T1 = 0.7;
/** How much of the cloud key applies, 0–1, for a display mode or a 0–100
* strength. The weather shell passes it to its fragment shader. */
export 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(
@@ -35,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);
}
});
+6 -18
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,22 +31,13 @@ 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);
}
/** Draw pixels a frame source supplied into a new canvas, with the display
* transfer for infrared products, and release them. They must be `size`. */
function drawSuppliedImage(
pixels,
product,
mode,
size,
signal,
createCanvas,
transfer,
) {
function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
try {
signal.throwIfAborted();
const { width, height } = weatherPixelSize(pixels);
@@ -57,7 +48,7 @@ function drawSuppliedImage(
texture.height = height;
const context = texture.getContext('2d');
drawWeatherPixels(context, pixels);
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
if (product === 'clouds' || product === 'clouds-regional')
applyInfraredTransfer(context, texture, mode);
return texture;
} finally {
@@ -97,8 +88,7 @@ export function decodeInfraredImage(blob, signal) {
}
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
* it into a canvas. Infrared frames get the display transfer unless `transfer`
* is false, for a caller that applies it when drawing; a canvas also keeps
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
* image is always released. With a `frameSource` that has `getImage`, the host
* supplies the decoded pixels instead and nothing is fetched or decoded here. */
@@ -117,7 +107,6 @@ export async function acquireWeatherImage(
now = () => performance.now(),
onFetched = () => {},
frameSource = null,
transfer = true,
},
) {
if (frameSource?.getImage) {
@@ -138,7 +127,6 @@ export async function acquireWeatherImage(
size,
signal,
createCanvas,
transfer,
);
return { texture, decodeMs: now() - started };
}
@@ -159,7 +147,7 @@ export async function acquireWeatherImage(
if (image.width !== size.width || image.height !== size.height)
throw new Error('Invalid weather image dimensions');
let texture;
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
if (product === 'clouds' || product === 'clouds-regional')
texture = processInfraredImage(image, mode, createCanvas);
else {
texture = createCanvas();
-69
View File
@@ -26,75 +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('with transfer off an infrared frame is drawn and never read back', async () => {
const drawn = [];
const createCanvas = () => {
const output = {};
output.getContext = () => ({
drawImage: (image) => drawn.push(image),
getImageData: () => {
throw new Error('the frame was read back on the page');
},
putImageData: () => {
throw new Error('the frame was rewritten on the page');
},
});
return output;
};
const size = { width: 4096, height: 2048 };
const image = { ...size, close() {} };
const options = {
signal: new AbortController().signal,
mode: 'filtered',
size,
maxBytes: 1024,
createCanvas,
transfer: false,
};
const fetched = await acquireWeatherImage(
'clouds-regional',
'2026-09-15T20:00:00.000Z',
{
...options,
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
arrayBuffer: async () => new ArrayBuffer(1),
}),
decodeImage: async () => image,
},
);
assert.equal(fetched.texture.width, 4096);
const supplied = { ...size };
await acquireWeatherImage('clouds-regional', '2026-09-15T20:00:00.000Z', {
...options,
frameSource: { getImage: async () => supplied },
});
assert.deepEqual(drawn, [image, supplied]);
});
test('decoded pixels are processed once on a same-sized canvas', () => {
const image = { width: 1, height: 1 };
const output = processInfraredImage(image, 'filtered', canvas);
+17 -34
View File
@@ -1,9 +1,4 @@
import { acquireWeatherImage } from './infraredImage.js';
import {
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
infraredAmount,
} from './infraredAlpha.js';
import { imageryHostStatus } from './imageryHost.js';
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
import { msg } from '../../i18n/index.js';
@@ -41,10 +36,6 @@ const LIFT_STEP = 500;
// draw nothing until the full-extent image has arrived, and sample the detail
// image only once it has arrived. The window is west, south, east, north in the
// full-extent image's texture coordinates.
// `infrared` is how much of the cloud key applies, 0–1 (infraredAlpha.js has
// the same curve for the globe's draped imagery). It is applied here, to the
// image as it was decoded, so a 4096×2048 frame is never read back and
// rewritten on the page, and a strength change is one uniform, not a new image.
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
@@ -55,8 +46,7 @@ const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput material
float useDetail = inside * step(1.5, float(detailDimensions.x));
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
material.diffuse = czm_gammaCorrect(c.rgb);
float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2));
material.alpha = c.a * mix(1.0, key, infrared) * alpha * step(1.5, float(imageDimensions.x));
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
return material;
}
`;
@@ -73,7 +63,6 @@ function registerMaterial(cesium) {
image: cesium.Material.DefaultImageId,
detail: cesium.Material.DefaultImageId,
alpha: 1,
infrared: 0,
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
},
// Diffuse and alpha share these samples, so one source, not components.
@@ -256,12 +245,6 @@ export function createShellSurface({
material.uniforms.alpha = value;
scene.requestRender();
},
/** How much of the infrared cloud key applies, 0–1. */
setInfrared(value) {
if (destroyed || material.uniforms.infrared === value) return;
material.uniforms.infrared = value;
scene.requestRender();
},
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
* image's texture coordinates, 0–1); null clears it. In the same window only
* the image changes, and Cesium keeps drawing the previous texture until
@@ -455,11 +438,10 @@ export function createWeatherShell({
const visible = () => !hidden && !suspended();
const boundsKey = ({ west, south, east, north }) =>
`${west},${south},${east},${north}`;
// One image per time and window: the infrared mode is a uniform, not pixels.
const cacheKey = (time, box = null) =>
box ? `${time}|${boundsKey(box)}` : time;
const cacheKey = (time, mode, box = null) =>
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
const wantedDetail = () =>
current && view ? cacheKey(current.time, view) : null;
current && view ? cacheKey(current.time, current.infrared, view) : null;
function evict(keep = []) {
const kept = new Set([...keep, current?.key, detail?.key]);
@@ -472,11 +454,12 @@ export function createWeatherShell({
}
async function acquire(
time,
mode,
signal,
{ box = null, keep = [], onFetched } = {},
) {
signal.throwIfAborted();
const key = cacheKey(time, box);
const key = cacheKey(time, mode, box);
const entry = images.get(key);
if (entry) {
images.delete(key);
@@ -485,8 +468,7 @@ export function createWeatherShell({
}
const result = await acquireWeatherImage(product, time, {
signal,
// The surface's shader applies the infrared transfer.
transfer: false,
mode,
size: box ? detailSize : size,
bbox: box,
maxBytes: MAX_IMAGE_BYTES,
@@ -582,7 +564,9 @@ export function createWeatherShell({
const job = { key, box: view, controller: new AbortController() };
detailJob = job;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
void acquire(current.time, job.controller.signal, { box: view })
void acquire(current.time, current.infrared, job.controller.signal, {
box: view,
})
.then(({ texture }) => installDetail(job, texture))
.catch(() => failDetail(job));
}
@@ -677,7 +661,6 @@ export function createWeatherShell({
loadMs: now() - frame.startedAt,
};
surface.setAlpha(alpha);
surface.setInfrared(infrared ? infraredAmount(frame.infrared) : 0);
applyVisibility();
lastError = null;
evict();
@@ -732,7 +715,7 @@ export function createWeatherShell({
return changed;
},
cancelPrefetch,
async prefetch(snapshot, time) {
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
let job;
try {
if (
@@ -746,8 +729,8 @@ export function createWeatherShell({
// Warm the next frame's detail window too while the window stays put.
const box =
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
const coarse = cacheKey(time);
const key = box ? cacheKey(time, box) : coarse;
const coarse = cacheKey(time, mode);
const key = box ? cacheKey(time, mode, box) : coarse;
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
@@ -756,8 +739,8 @@ export function createWeatherShell({
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
// Both images at once: one after the other would outlast playback's step.
await Promise.all([
acquire(time, signal, { keep: box ? [key] : [] }),
box ? acquire(time, signal, { box, keep: [coarse] }) : null,
acquire(time, mode, signal, { keep: box ? [key] : [] }),
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
]);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
@@ -799,7 +782,7 @@ export function createWeatherShell({
infrared: mode,
bounds,
product: snapshot.product,
key: cacheKey(time),
key: cacheKey(time, mode),
mosaic: { fetched: false, decodeMs: null, cached: false },
controller: new AbortController(),
startedAt: now(),
@@ -814,7 +797,7 @@ export function createWeatherShell({
});
watch(frame, signal);
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
void acquire(time, frame.controller.signal, {
void acquire(time, mode, frame.controller.signal, {
onFetched: () => {
frame.mosaic.fetched = true;
},
+18 -35
View File
@@ -11,7 +11,6 @@ import {
WEATHER_SHELL_HEIGHTS,
} from './shellRendering.js';
import { NO_IMAGERY_HOST } from './imageryHost.js';
import { INFRARED_ALPHA_T0, INFRARED_ALPHA_T1 } from './infraredAlpha.js';
import {
createShellCesium,
createShellScene,
@@ -148,13 +147,12 @@ test('surface builds a flat raised rectangle drawn in primitive order without de
image: Cesium.Material.DefaultImageId,
detail: Cesium.Material.DefaultImageId,
alpha: 1,
infrared: 0,
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
});
const { source } = template.fabric;
assert.match(
source,
/material\.alpha = c\.a \* mix\(1\.0, key, infrared\) \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
/material\.alpha = c\.a \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
'nothing draws before the full-extent image',
);
assert.match(
@@ -375,7 +373,6 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an
/uniform sampler2D image_\d+;/,
/uniform sampler2D detail_\d+;/,
/uniform float alpha_\d+;/,
/uniform float infrared_\d+;/,
/uniform vec4 window_\d+;/,
/uniform ivec3 imageDimensions_\d+;/,
/uniform ivec3 detailDimensions_\d+;/,
@@ -387,16 +384,9 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an
assert.match(shader, /step\(1\.5, float\(detailDimensions_\d+\.x\)\)/);
assert.match(
shader,
/material\.alpha = c\.a \* mix\(1\.0, key, infrared_\d+\) \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/,
/material\.alpha = c\.a \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/,
'the uniform is renamed, the material field is not',
);
// The cloud key the globe's draped imagery gets from infraredAlpha.js:
// brightest channel, 2.2 power, the shared thresholds.
assert.ok(
shader.includes(
`float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2));`,
),
);
assert.match(shader, /float useDetail = inside \* step/, 'locals keep names');
const detail = { width: 4096, height: 2048 };
surface.setDetail(detail, { west: 0.1, south: 0.2, east: 0.3, north: 0.4 });
@@ -753,12 +743,12 @@ test('prefetch warms a decoded frame without staging and cancels cleanly', async
h.shell.clear();
});
test('infrared frames reach the shader as decoded, with the cloud key as a uniform; radar and lightning get none', async () => {
for (const [product, amount] of [
['clouds-regional', 1],
['clouds', 1],
['radar', 0],
['lightning', 0],
test('infrared frames get the display transfer; radar and lightning keep their pixels', async () => {
for (const [product, transfer] of [
['clouds-regional', true],
['clouds', true],
['radar', false],
['lightning', false],
]) {
const h = harness({ product });
const pending = h.shell.setFrame(snapshot(product), times[0]);
@@ -766,10 +756,11 @@ test('infrared frames reach the shader as decoded, with the cloud key as a unifo
const image = h.material().uniforms.image;
assert.equal(image.source, h.decoded[0], 'drawn into a canvas');
assert.equal(h.decoded[0].closed, true);
assert.equal(image.pixels, undefined, `${product}: pixels never rewritten`);
assert.equal(image.pixels !== undefined, transfer, product);
if (transfer)
assert.deepEqual([...image.pixels], [194, 0, 0, 98], 'clouds only');
h.render();
assert.equal(await pending, true);
assert.equal(h.material().uniforms.infrared, amount, product);
assert.equal(
h.shell.getDiagnostics().height,
WEATHER_SHELL_HEIGHTS[product],
@@ -778,28 +769,20 @@ test('infrared frames reach the shader as decoded, with the cloud key as a unifo
}
});
test('an infrared mode change is a uniform on the same image: no fetch, no new canvas', async () => {
test('an infrared mode change restages the same time with its own cached canvas', async () => {
const h = harness({ product: 'clouds' });
assert.equal(await show(h, times[0]), true);
const image = h.material().uniforms.image;
assert.equal(h.material().uniforms.infrared, 1);
const filtered = h.material().uniforms.image;
assert.equal(await show(h, times[0]), true, 'same frame is a no-op');
assert.equal(h.fetches.length, 1);
const renders = h.scene.renders;
assert.equal(await show(h, times[0], { infrared: 'full' }), true);
assert.equal(h.material().uniforms.image, image);
assert.equal(h.material().uniforms.infrared, 0);
const full = h.material().uniforms.image;
assert.notEqual(full, filtered);
assert.deepEqual([...full.pixels], [194, 0, 0, 200]);
assert.equal(h.shell.getDiagnostics().infrared, 'full');
assert.ok(h.scene.renders > renders, 'the new strength is drawn');
assert.equal(await show(h, times[0], { infrared: 40 }), true);
assert.equal(h.material().uniforms.infrared, 0.4);
assert.equal(h.shell.getDiagnostics().infrared, 40);
assert.equal(await show(h, times[0], { infrared: 'filtered' }), true);
assert.equal(h.material().uniforms.image, image);
assert.equal(h.material().uniforms.infrared, 1);
assert.equal(h.fetches.length, 1);
assert.equal(h.decoded.length, 1);
assert.equal(h.shell.getDiagnostics().cache.mosaics, 1);
assert.equal(h.material().uniforms.image, filtered, 'cache hit');
assert.equal(h.fetches.length, 2);
h.shell.clear();
});