mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04308e45d | ||
|
|
9582f2f28b |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- 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).
|
||||
@@ -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).
|
||||
+12
-10
@@ -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.
|
||||
@@ -1515,10 +1513,14 @@ 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 (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
|
||||
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
|
||||
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
|
||||
|
||||
+139
-57
@@ -4,10 +4,10 @@ import {
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
ANCHOR_SAMPLES_PER_PAINT,
|
||||
REMEMBERED_ANCHORS,
|
||||
SURFACE_BELOW_FLOOR_M,
|
||||
SURFACE_ABOVE_FLOOR_M,
|
||||
SURFACE_SAMPLES_PER_PAINT,
|
||||
SURFACE_SAMPLES_PER_FRAME,
|
||||
} from './policy.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
@@ -19,11 +19,17 @@ 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,
|
||||
@@ -33,7 +39,12 @@ export function createAlprOverlay({
|
||||
hits = [],
|
||||
images = [],
|
||||
destroyed = false,
|
||||
lastCamera = null;
|
||||
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();
|
||||
const requestPaint = () => {
|
||||
if (!destroyed && state.enabled) lane?.requestPaint();
|
||||
};
|
||||
@@ -92,38 +103,72 @@ export function createAlprOverlay({
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
nativeVisible(entity, true);
|
||||
}
|
||||
surfaceAnswers.clear();
|
||||
state.lastAnchorSampleAt = 0;
|
||||
retryUntil = Date.now() + 15000;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
function anchorFor(record, entity, budget) {
|
||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||
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) {
|
||||
const scene = state.viewer.scene;
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
// 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).
|
||||
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().
|
||||
let height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
);
|
||||
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
|
||||
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);
|
||||
else if (scene.globe.show)
|
||||
height = scene.globe.getHeight?.(
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
|
||||
);
|
||||
if (!validAlprGroundHeight(height)) return null;
|
||||
setAnchor(record, entity, height);
|
||||
return entity.gevAlprCanvasPosition;
|
||||
@@ -158,14 +203,19 @@ export function createAlprOverlay({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function refineAnchors(budget) {
|
||||
function sampleAnchors() {
|
||||
sampling = false;
|
||||
if (destroyed || !state.enabled) return;
|
||||
const camera = state.viewer.camera;
|
||||
const scene = state.viewer.scene;
|
||||
const still =
|
||||
@@ -188,27 +238,41 @@ export function createAlprOverlay({
|
||||
position: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(camera.directionWC),
|
||||
};
|
||||
if (!scene.sampleHeightSupported) return false;
|
||||
let pending = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
const anchor = entity?.gevAlprCanvasPosition;
|
||||
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
|
||||
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
|
||||
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
|
||||
continue;
|
||||
if (!still || !surfaceSettled(scene) || samples <= 0) {
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
samples -= 1;
|
||||
progressed = true;
|
||||
const sample = surfaceHeight(record, entity);
|
||||
let height = Cesium.Cartographic.fromCartesian(anchor).height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
@@ -218,21 +282,43 @@ 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 - current) > 1)
|
||||
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
|
||||
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - height) > 1) {
|
||||
height = sample + MARKER_FLOOR_LIFT_M;
|
||||
setAnchor(record, entity, height);
|
||||
moved = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return pending;
|
||||
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);
|
||||
}
|
||||
|
||||
function paint({ ctx, width, height, keyhole, occluder }) {
|
||||
hits = [];
|
||||
if (!state.enabled || destroyed) return;
|
||||
let unresolved = false;
|
||||
let waiting = false;
|
||||
const painted = [];
|
||||
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
|
||||
const canSample =
|
||||
state.viewer.scene.globe?.show ||
|
||||
state.viewer.scene.sampleHeightSupported;
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
if (!entity) continue;
|
||||
@@ -246,12 +332,14 @@ export function createAlprOverlay({
|
||||
nativeVisible(entity, true);
|
||||
continue;
|
||||
}
|
||||
const anchor = anchorFor(record, entity, budget);
|
||||
const anchor = anchorFor(record, entity);
|
||||
if (!anchor) {
|
||||
nativeVisible(entity, true);
|
||||
unresolved = true;
|
||||
waiting = true;
|
||||
continue;
|
||||
}
|
||||
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
|
||||
waiting = true;
|
||||
canvasOwned(entity, true);
|
||||
if (occluder && !occluder.isPointVisible(anchor)) {
|
||||
setShown(entity, false);
|
||||
@@ -335,15 +423,9 @@ export function createAlprOverlay({
|
||||
ctx.restore();
|
||||
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
|
||||
}
|
||||
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);
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -45,8 +45,10 @@ 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: {
|
||||
@@ -106,6 +108,12 @@ 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' } });
|
||||
},
|
||||
@@ -243,41 +251,149 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
|
||||
});
|
||||
|
||||
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.
|
||||
const h = harness(t, 2, { floor: 150 });
|
||||
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
|
||||
function photoreal(h, heightAt = () => 172) {
|
||||
const scene = h.state.viewer.scene;
|
||||
const camera = h.state.viewer.camera;
|
||||
camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
scene.globe.show = false;
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.primitives = { length: 0, get: () => null };
|
||||
let samples = 0;
|
||||
const sampled = [];
|
||||
scene.sampleHeight = (carto) => {
|
||||
samples++;
|
||||
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
|
||||
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));
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const height = (id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
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');
|
||||
assert.equal(
|
||||
samples,
|
||||
sampled.length,
|
||||
0,
|
||||
'no depth samples while the camera may still be moving',
|
||||
);
|
||||
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:0') - 173.5) < 0.01,
|
||||
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
|
||||
'rendered surface once still',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:1') - 151.5) < 0.01,
|
||||
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
|
||||
'a rooftop-height sample is rejected',
|
||||
);
|
||||
const taken = samples;
|
||||
assert.equal(sampled.length, 2);
|
||||
h.paint();
|
||||
assert.equal(samples, taken, 'each anchor is checked once');
|
||||
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');
|
||||
});
|
||||
@@ -66,14 +66,15 @@ 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 overlay paint may take (each is a depth render). */
|
||||
export const ANCHOR_SAMPLES_PER_PAINT = 4;
|
||||
/** 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;
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user