Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 17dc7c8a69 docs(changelog): add the #1721 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 18:03:30 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 e3f28a391f perf(weather): apply the infrared cloud key in the shell's shader
On the 3D Tiles host each Satellite clouds image (4096x2048 for the
regional product and for every detail window) was drawn to a canvas, read
back, given its transparency curve on the page and written back. The
lookup table halved that pass; this removes it. The shell's material now
samples the image as it was decoded and applies the same curve (brightest
channel, 2.2 power, smoothstep 0.40-0.70) in its fragment shader, scaled
by a strength uniform.

The shell's image cache no longer keys on the infrared mode, so changing
the filter strength sets one uniform: no fetch, no decode and no second
33 MB canvas per mode. Globe hosts keep the per-pixel transfer for their
draped imagery.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:05:56 +01:00
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
13 changed files with 331 additions and 351 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
- ALPR camera badges no longer take depth samples while the overlay paints, or
again each time the layer is switched back on. On a terrain globe their
height comes from the loaded terrain and is remembered per camera; some
badges move a few metres onto the drawn surface (mazy1998, #1706).
+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).
@@ -0,0 +1,3 @@
- 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).
+10 -12
View File
@@ -891,9 +891,11 @@ 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 to decoded pixels once, using
Clouds only applies a soft brightness ramp, using
Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40
to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
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
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
@@ -909,13 +911,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 process each whole frame once.
decoded tile once; shells never read a frame's pixels back.
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, mode and window: the shown frame and the warmed next frame, each
time 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.
@@ -1513,14 +1515,10 @@ tile streamed in; floors resolve before new badges appear (bounded wait), and a
badge whose cell has not resolved clamps until it does, then moves in place.
Overlay anchors start on the same floor and write marker properties only on
change. Once the camera is still and tiles have settled, each overlay anchor is
replaced once by the rendered surface under it (accepted only within -30/+40 m
of the floor cell), so badges do not float or sink on slopes; wedge corners
follow their own floor cells instead of staying level with the apex. The
surface is read outside the overlay paint, one camera per frame: a terrain
globe answers from its loaded mesh (`globe.getHeight`) and only photoreal
tiles are depth-sampled (`scene.sampleHeight`). Answers are kept per camera
until the map source changes, so a refresh or turning the layer off and on
samples nothing again. Unknown bearings never acquire a wedge. The selected
replaced once by the rendered surface under it (two depth samples per paint,
accepted only within -30/+40 m of the floor cell), so badges do not float or
sink on slopes; wedge corners follow their own floor cells instead of staying
level with the apex. Unknown bearings never acquire a wedge. The selected
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
of 359.5° or more reads `0°` rather than `360°`. The row reads
`N nearby · M on screen`; when every loaded camera is outside the view it adds
+57 -139
View File
@@ -4,10 +4,10 @@ import {
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
REMEMBERED_ANCHORS,
ANCHOR_SAMPLES_PER_PAINT,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_FRAME,
SURFACE_SAMPLES_PER_PAINT,
} from './policy.js';
import {
MARKER_IMAGE,
@@ -19,17 +19,11 @@ import {
validAlprGroundHeight,
} from './visuals.js';
const nextAnimationFrame = (step) =>
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(step)
: setTimeout(step, 16);
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({
state,
services,
onSurfaceChange = () => {},
requestFrame = nextAnimationFrame,
}) {
let lane,
removeMapListener,
@@ -39,12 +33,7 @@ export function createAlprOverlay({
hits = [],
images = [],
destroyed = false,
lastCamera = null,
sampling = false;
// Rendered-surface answers by camera id. Entities are rebuilt by a refresh
// and by turning the layer off and on; keeping the answers on them meant
// every rebuild sampled the same cameras again.
const surfaceAnswers = new Map();
lastCamera = null;
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
@@ -103,72 +92,38 @@ export function createAlprOverlay({
entity.gevAlprDisplayPosition = null;
nativeVisible(entity, true);
}
surfaceAnswers.clear();
state.lastAnchorSampleAt = 0;
retryUntil = Date.now() + 15000;
requestPaint();
}
function rememberSurface(record, height, checked) {
surfaceAnswers.delete(record.id);
surfaceAnswers.set(record.id, {
latitude: record.latitude,
longitude: record.longitude,
height,
checked,
});
if (surfaceAnswers.size > REMEMBERED_ANCHORS)
surfaceAnswers.delete(surfaceAnswers.keys().next().value);
}
/**
* Rendered surface height under a camera. A terrain globe answers from its
* loaded mesh; only photoreal tiles need `sampleHeight`, which renders depth
* and compiles its shaders on first use (a freeze of up to 1.9 s at load).
*/
function surfaceHeight(record, entity) {
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
if (scene.globe?.show) return scene.globe.getHeight?.(location);
if (!scene.sampleHeightSupported) return undefined;
try {
return scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
return undefined;
}
}
/** The anchor a paint can have without sampling, or null while it waits for one. */
function anchorFor(record, entity) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const answer = surfaceAnswers.get(record.id);
if (
answer?.latitude === record.latitude &&
answer.longitude === record.longitude
) {
setAnchor(record, entity, answer.height);
if (answer.checked)
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
return entity.gevAlprCanvasPosition;
}
// A floor-placed marker anchors where its native badge sits. A marker
// still waiting for its floor reads the terrain globe, or on photoreal
// tiles waits for sampleAnchors().
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (each sample is a depth render).
let height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (scene.globe.show)
height = scene.globe.getHeight?.(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
);
else if (scene.sampleHeightSupported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
}
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
@@ -203,19 +158,14 @@ export function createAlprOverlay({
}
/**
* Sample the rendered surface for the overlay cameras, outside the paint.
*
* A camera with no floor yet gets its anchor from a sample. An anchored one
* is checked against the surface once: floors come from ~111 m cells, so on
* a slope a badge could float or sink by tens of metres. Checks run only
* while the camera is still and tiles have settled, and samples far outside
* the floor prior (a roof or a streaming tile) are rejected. Each frame
* takes SURFACE_SAMPLES_PER_FRAME samples, so no frame carries the whole
* cohort; sampling inside paint() put up to six depth renders in one frame.
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few depth samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
*/
function sampleAnchors() {
sampling = false;
if (destroyed || !state.enabled) return;
function refineAnchors(budget) {
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const still =
@@ -238,41 +188,27 @@ export function createAlprOverlay({
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
};
const canSample = scene.globe?.show || scene.sampleHeightSupported;
let samples = SURFACE_SAMPLES_PER_FRAME,
pending = false,
progressed = false,
moved = false;
// Cameras with no anchor come first: they are not on the overlay yet.
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity || entity.gevAlprCanvasPosition) continue;
// The floor may have resolved since the paint that found none.
if (anchorFor(record, entity)) moved = true;
else if (!canSample || samples <= 0) pending = true;
else {
samples -= 1;
const sample = surfaceHeight(record, entity);
if (validAlprGroundHeight(sample)) {
setAnchor(record, entity, sample);
rememberSurface(record, sample, false);
moved = progressed = true;
} else pending = true;
}
}
if (!scene.sampleHeightSupported) return false;
let pending = false;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
continue;
if (!still || !surfaceSettled(scene) || samples <= 0) {
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
pending = true;
continue;
}
samples -= 1;
progressed = true;
const sample = surfaceHeight(record, entity);
let height = Cesium.Cartographic.fromCartesian(anchor).height;
budget.samples -= 1;
let sample;
try {
sample = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
[entity],
);
} catch {
/* streaming tiles */
}
const current = Cesium.Cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
@@ -282,43 +218,21 @@ export function createAlprOverlay({
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - height) > 1) {
height = sample + MARKER_FLOOR_LIFT_M;
setAnchor(record, entity, height);
moved = true;
}
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
// A surface that did not answer is asked again after a rebuild.
if (validAlprGroundHeight(sample)) rememberSurface(record, height, true);
if (entity.id === state.selectedId)
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
}
if (moved) requestPaint();
if (!pending || Date.now() >= retryUntil) return;
// Take the next ration on the next frame; when nothing could be sampled
// (camera moving, tiles streaming, no floor yet) look again in 250 ms.
if (progressed) scheduleSampling();
else if (!retryTimer)
retryTimer = setTimeout(() => {
retryTimer = null;
scheduleSampling();
}, 250);
}
function scheduleSampling() {
if (sampling || destroyed) return;
sampling = true;
requestFrame(sampleAnchors);
return pending;
}
function paint({ ctx, width, height, keyhole, occluder }) {
hits = [];
if (!state.enabled || destroyed) return;
let waiting = false;
let unresolved = false;
const painted = [];
const canSample =
state.viewer.scene.globe?.show ||
state.viewer.scene.sampleHeightSupported;
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
@@ -332,14 +246,12 @@ export function createAlprOverlay({
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity);
const anchor = anchorFor(record, entity, budget);
if (!anchor) {
nativeVisible(entity, true);
waiting = true;
unresolved = true;
continue;
}
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
waiting = true;
canvasOwned(entity, true);
if (occluder && !occluder.isPointVisible(anchor)) {
setShown(entity, false);
@@ -423,9 +335,15 @@ export function createAlprOverlay({
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
// Anchors still to find or to check are sampled after this paint, a few
// per frame, never inside it.
if (waiting && !retryTimer && Date.now() < retryUntil) scheduleSampling();
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
}, 250);
}
}
return {
+21 -137
View File
@@ -45,10 +45,8 @@ function harness(t, count = 2, { floor = null } = {}) {
active,
removed = 0,
paints = 0;
const frames = [];
const overlay = createAlprOverlay({
state,
requestFrame: (step) => frames.push(step),
services: {
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
@@ -108,12 +106,6 @@ function harness(t, count = 2, { floor = null } = {}) {
entities,
draw,
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
/** Run the sampling step queued for the next animation frame, if any. */
frame() {
const step = frames.shift();
step?.();
return Boolean(step);
},
mapChanged() {
mapListener({ detail: { status: 'ready' } });
},
@@ -251,149 +243,41 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
});
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
function photoreal(h, heightAt = () => 172) {
const scene = h.state.viewer.scene;
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.globe.show = false;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
const sampled = [];
scene.sampleHeight = (carto) => {
sampled.push(carto);
return heightAt(Cesium.Math.toDegrees(carto.latitude));
};
return sampled;
}
const anchorHeight = (h, id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
/** Run sampling frames until none is queued, waiting out the 250 ms look-again. */
function settle(t, h) {
for (let i = 0; i < 400; i++) if (!h.frame()) t.mock.timers.tick(250);
}
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 2, { floor: 150 });
const sampled = photoreal(h, (latitude) => (latitude > 30.00005 ? 400 : 172));
const scene = h.state.viewer.scene;
const camera = h.state.viewer.camera;
camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
let samples = 0;
scene.sampleHeight = (carto) => {
samples++;
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
};
h.overlay.sync(h.records);
h.paint();
assert.ok(
Math.abs(anchorHeight(h, 'camera:0') - 151.5) < 0.01,
'floor anchor first',
);
assert.equal(h.frame(), true, 'the paint queued a sampling frame');
const height = (id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
assert.equal(
sampled.length,
samples,
0,
'no depth samples while the camera may still be moving',
);
settle(t, h);
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
h.paint();
assert.ok(
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
Math.abs(height('camera:0') - 173.5) < 0.01,
'rendered surface once still',
);
assert.ok(
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
Math.abs(height('camera:1') - 151.5) < 0.01,
'a rooftop-height sample is rejected',
);
assert.equal(sampled.length, 2);
const taken = samples;
h.paint();
settle(t, h);
assert.equal(sampled.length, 2, 'each anchor is checked once');
});
test('a paint never samples the surface; frames take one sample each', (t) => {
// Sampling inside paint() put up to six depth renders into one frame, and
// the first of them compiles shaders: a freeze of 0.8 to 1.9 s at load.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 8);
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
h.paint();
assert.equal(sampled.length, 0, 'no sample inside a paint');
assert.equal(h.draw.length, 0, 'unanchored cameras keep their native badge');
for (let taken = 1; taken <= 8; taken++) {
assert.equal(h.frame(), true);
assert.equal(sampled.length, taken, 'one sample per frame');
}
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 172) < 0.01);
h.paint();
assert.ok(h.draw.length > 0, 'anchored cameras paint on the overlay');
});
test('a terrain globe is read from its mesh, never depth-sampled', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 3, { floor: 150 });
const sampled = photoreal(h);
const scene = h.state.viewer.scene;
scene.globe.show = true;
scene.globe.tilesLoaded = true;
let reads = 0;
scene.globe.getHeight = () => {
reads++;
return 172;
};
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 0);
assert.equal(reads, 3);
// The same anchor the depth sample gave: the surface plus the marker lift.
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 173.5) < 0.01);
});
test('surface answers outlive rebuilt markers and a re-enable, until the map changes', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 4, { floor: 150 });
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 4);
const before = h.records.map((record) => anchorHeight(h, record.id));
// A refresh or a toggle removes the entities and builds new ones.
const rebuild = () => {
h.overlay.clear();
h.entities.removeAll();
for (const record of h.records)
h.entities.add({
id: record.id,
billboard: { image: 'fallback', show: true },
polyline: { show: true },
polygon: { show: true },
});
h.overlay.sync(h.records);
h.paint();
settle(t, h);
};
rebuild();
h.state.enabled = false;
h.overlay.clear();
h.state.enabled = true;
rebuild();
assert.equal(sampled.length, 4, 'no camera is sampled again');
assert.deepEqual(
h.records.map((record) => anchorHeight(h, record.id)),
before,
);
// A camera that moved is a different question.
h.records[0] = { ...h.records[0], latitude: h.records[0].latitude + 0.001 };
rebuild();
assert.equal(sampled.length, 5);
h.mapChanged();
h.paint();
settle(t, h);
assert.equal(sampled.length, 9, 'a new map surface is sampled afresh');
assert.equal(samples, taken, 'each anchor is checked once');
});
+4 -5
View File
@@ -66,15 +66,14 @@ export const MAX_CANVAS_FRUSTUMS = 64;
/** Metres a floor-placed marker sits above its resolved ground cell. */
export const MARKER_FLOOR_LIFT_M = 1.5;
/** Rendered-surface samples one frame may take (on photoreal tiles each is a
* depth render of several milliseconds, so they are spread over frames). */
export const SURFACE_SAMPLES_PER_FRAME = 1;
/** Surface answers kept by camera id, so a refresh or re-enable asks nothing. */
export const REMEMBERED_ANCHORS = 2048;
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
export const ANCHOR_SAMPLES_PER_PAINT = 4;
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
* sits within it; a rooftop or an unstreamed tile usually does not). */
export const SURFACE_BELOW_FLOOR_M = 30;
/** Rendered-surface refinements per paint while the camera is still. */
export const SURFACE_SAMPLES_PER_PAINT = 2;
export const SURFACE_ABOVE_FLOOR_M = 40;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
+36 -13
View File
@@ -1,22 +1,31 @@
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.
/** 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.
* `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 +35,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);
}
});
+18 -6
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,13 +31,22 @@ 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);
}
/** 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) {
function drawSuppliedImage(
pixels,
product,
mode,
size,
signal,
createCanvas,
transfer,
) {
try {
signal.throwIfAborted();
const { width, height } = weatherPixelSize(pixels);
@@ -48,7 +57,7 @@ function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
texture.height = height;
const context = texture.getContext('2d');
drawWeatherPixels(context, pixels);
if (product === 'clouds' || product === 'clouds-regional')
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
applyInfraredTransfer(context, texture, mode);
return texture;
} finally {
@@ -88,7 +97,8 @@ 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; a canvas also keeps
* 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
* 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. */
@@ -107,6 +117,7 @@ export async function acquireWeatherImage(
now = () => performance.now(),
onFetched = () => {},
frameSource = null,
transfer = true,
},
) {
if (frameSource?.getImage) {
@@ -127,6 +138,7 @@ export async function acquireWeatherImage(
size,
signal,
createCanvas,
transfer,
);
return { texture, decodeMs: now() - started };
}
@@ -147,7 +159,7 @@ export async function acquireWeatherImage(
if (image.width !== size.width || image.height !== size.height)
throw new Error('Invalid weather image dimensions');
let texture;
if (product === 'clouds' || product === 'clouds-regional')
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
texture = processInfraredImage(image, mode, createCanvas);
else {
texture = createCanvas();
+69
View File
@@ -26,6 +26,75 @@ 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);
+34 -17
View File
@@ -1,4 +1,9 @@
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';
@@ -36,6 +41,10 @@ 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);
@@ -46,7 +55,8 @@ 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);
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
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));
return material;
}
`;
@@ -63,6 +73,7 @@ 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.
@@ -245,6 +256,12 @@ 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
@@ -438,10 +455,11 @@ export function createWeatherShell({
const visible = () => !hidden && !suspended();
const boundsKey = ({ west, south, east, north }) =>
`${west},${south},${east},${north}`;
const cacheKey = (time, mode, box = null) =>
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
// 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 wantedDetail = () =>
current && view ? cacheKey(current.time, current.infrared, view) : null;
current && view ? cacheKey(current.time, view) : null;
function evict(keep = []) {
const kept = new Set([...keep, current?.key, detail?.key]);
@@ -454,12 +472,11 @@ export function createWeatherShell({
}
async function acquire(
time,
mode,
signal,
{ box = null, keep = [], onFetched } = {},
) {
signal.throwIfAborted();
const key = cacheKey(time, mode, box);
const key = cacheKey(time, box);
const entry = images.get(key);
if (entry) {
images.delete(key);
@@ -468,7 +485,8 @@ export function createWeatherShell({
}
const result = await acquireWeatherImage(product, time, {
signal,
mode,
// The surface's shader applies the infrared transfer.
transfer: false,
size: box ? detailSize : size,
bbox: box,
maxBytes: MAX_IMAGE_BYTES,
@@ -564,9 +582,7 @@ export function createWeatherShell({
const job = { key, box: view, controller: new AbortController() };
detailJob = job;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
void acquire(current.time, current.infrared, job.controller.signal, {
box: view,
})
void acquire(current.time, job.controller.signal, { box: view })
.then(({ texture }) => installDetail(job, texture))
.catch(() => failDetail(job));
}
@@ -661,6 +677,7 @@ export function createWeatherShell({
loadMs: now() - frame.startedAt,
};
surface.setAlpha(alpha);
surface.setInfrared(infrared ? infraredAmount(frame.infrared) : 0);
applyVisibility();
lastError = null;
evict();
@@ -715,7 +732,7 @@ export function createWeatherShell({
return changed;
},
cancelPrefetch,
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
async prefetch(snapshot, time) {
let job;
try {
if (
@@ -729,8 +746,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, mode);
const key = box ? cacheKey(time, mode, box) : coarse;
const coarse = cacheKey(time);
const key = box ? cacheKey(time, box) : coarse;
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
@@ -739,8 +756,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, mode, signal, { keep: box ? [key] : [] }),
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
acquire(time, signal, { keep: box ? [key] : [] }),
box ? acquire(time, signal, { box, keep: [coarse] }) : null,
]);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
@@ -782,7 +799,7 @@ export function createWeatherShell({
infrared: mode,
bounds,
product: snapshot.product,
key: cacheKey(time, mode),
key: cacheKey(time),
mosaic: { fetched: false, decodeMs: null, cached: false },
controller: new AbortController(),
startedAt: now(),
@@ -797,7 +814,7 @@ export function createWeatherShell({
});
watch(frame, signal);
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
void acquire(time, mode, frame.controller.signal, {
void acquire(time, frame.controller.signal, {
onFetched: () => {
frame.mosaic.fetched = true;
},
+35 -18
View File
@@ -11,6 +11,7 @@ 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,
@@ -147,12 +148,13 @@ 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 \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
/material\.alpha = c\.a \* mix\(1\.0, key, infrared\) \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
'nothing draws before the full-extent image',
);
assert.match(
@@ -373,6 +375,7 @@ 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+;/,
@@ -384,9 +387,16 @@ 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 \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/,
/material\.alpha = c\.a \* mix\(1\.0, key, infrared_\d+\) \* 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 });
@@ -743,12 +753,12 @@ test('prefetch warms a decoded frame without staging and cancels cleanly', async
h.shell.clear();
});
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],
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],
]) {
const h = harness({ product });
const pending = h.shell.setFrame(snapshot(product), times[0]);
@@ -756,11 +766,10 @@ test('infrared frames get the display transfer; radar and lightning keep their p
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, transfer, product);
if (transfer)
assert.deepEqual([...image.pixels], [194, 0, 0, 98], 'clouds only');
assert.equal(image.pixels, undefined, `${product}: pixels never rewritten`);
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],
@@ -769,20 +778,28 @@ test('infrared frames get the display transfer; radar and lightning keep their p
}
});
test('an infrared mode change restages the same time with its own cached canvas', async () => {
test('an infrared mode change is a uniform on the same image: no fetch, no new canvas', async () => {
const h = harness({ product: 'clouds' });
assert.equal(await show(h, times[0]), true);
const filtered = h.material().uniforms.image;
const image = h.material().uniforms.image;
assert.equal(h.material().uniforms.infrared, 1);
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);
const full = h.material().uniforms.image;
assert.notEqual(full, filtered);
assert.deepEqual([...full.pixels], [194, 0, 0, 200]);
assert.equal(h.material().uniforms.image, image);
assert.equal(h.material().uniforms.infrared, 0);
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, filtered, 'cache hit');
assert.equal(h.fetches.length, 2);
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);
h.shell.clear();
});