Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 6521fd2316 docs(changelog): add the #1705 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:05:53 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 346a08a42e perf(traffic): stop clipping and re-parsing the whole road set in one task
After the detail tiles arrived, every coarse road was clipped against every
detail tile for four regions, the composed set was parsed again although
each tile had been parsed as it streamed, and every coordinate asked the
ground floor service for its cell. A road wholly inside one region is now
answered from its extent, records already parsed in a pass are reused, and
the floor is read once per cell within a parse (when floors are per cell).

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:03:19 +01:00
18 changed files with 688 additions and 272 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- Cut the stutter while zooming and panning with Street Traffic on in dense
cities. Coarse roads are no longer clipped against every detail tile in one
task, roads already parsed in a load are not parsed again, and ground floors
are read once per cell (mazy1998, #1705).
-3
View File
@@ -1,3 +0,0 @@
- Halve the time the satellite cloud image takes to prepare on the 3D Tiles
map by applying its transparency curve from a lookup table, in place
(mazy1998, #1713).
@@ -1,3 +0,0 @@
- Satellite clouds on the 3D Tiles map apply their transparency in the shader
instead of rewriting each 4096 x 2048 image on the page, and changing the
cloud strength no longer builds a new image (mazy1998, #1721).
+4 -6
View File
@@ -891,11 +891,9 @@ image proxy accepts `bbox=west,south,east,north` for every product: inside the
product bounds, 2:1 within 1 %, rounded to 0.25°, up to 4096×2048 (the
default), cached by product, time, size and bbox. Diagnostics report
`shell.detail` (`bbox`, `size`, `ready`, `enabled`).
Clouds only applies a soft brightness ramp, using
Clouds only applies a soft brightness ramp to decoded pixels once, using
Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40
to 0.70. Globe hosts apply it to decoded pixels once; a shell applies the same
ramp in its fragment shader from a strength uniform, so its images are drawn
as decoded and a strength change neither fetches nor rewrites an image. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
preserved in Full image mode; both modes use the chosen layer opacity. This
is a display filter, not a cloud mask. The cloud-filter slider is a continuous
0–100% strength: 0% is Full, 100% is the existing Clouds only key, and
@@ -911,13 +909,13 @@ and processes it before staging. Globe hosts crop that canvas into 256 px tiles
to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent,
avoiding request-dependent contrast seams.
On globe hosts, regional infrared retains network tiles and processes each
decoded tile once; shells never read a frame's pixels back.
decoded tile once; shells process each whole frame once.
Exact-time tile and image responses are immutable for 24 hours; manifests and
errors remain uncached. Each globe renderer retains up to 6 processed global
mosaics in a least-recently-used cache keyed by observation time and infrared
mode (up to 48 MiB of canvas pixels). Each shell keeps decoded full-extent and
detail images in one least-recently-used cache bounded at 128 MiB, keyed by
time and window: the shown frame and the warmed next frame, each
time, mode and window: the shown frame and the warmed next frame, each
full-extent and detail; nothing older survives them. Disable clears the caches.
Cache hits skip
fetch/decode and report `mosaic.cached: true` with zero decode time.
+3 -1
View File
@@ -1,6 +1,7 @@
import { roadIdentity } from './retention.js';
import * as Cesium from 'cesium';
import {
floorCellReader,
prepareRoadSurfaces,
roadHeightStations,
trafficSurfaceReady,
@@ -301,6 +302,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
}
}
if (depthHeight !== undefined) layerState._viewHeightEstimate = depthHeight;
const floorAt = floorCellReader(services.ground);
for (const road of roads) {
road.key ||= roadIdentity(road);
const prepared = !refining && preparedRoads.get(road.key);
@@ -319,7 +321,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
);
for (let i = 0; i < road.coords.length; i++) {
const [lon, lat] = road.coords[i];
const floor = services.ground?.cachedGroundFloor?.(lat, lon);
const floor = floorAt(lat, lon);
const height =
Number.isFinite(floor) && Math.abs(floor) <= 9000
? Math.max(floor, layerState._viewHeightEstimate)
+3 -3
View File
@@ -39,10 +39,10 @@ export function createTrafficLayer({ services, source }) {
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
state._parseRoads = TRAFFIC_TIMING_ENABLED
? (data, trace) =>
? (data, trace, parsedBySource) =>
trace
? parts.timing.parseRoadsTimed(data, trace)
: parts.model.parseRoads(data)
? parts.timing.parseRoadsTimed(data, trace, parsedBySource)
: parts.model.parseRoads(data, trace, parsedBySource)
: parts.model.parseRoads;
state._loadRoadsForBounds = TRAFFIC_TIMING_ENABLED
+7 -1
View File
@@ -348,11 +348,16 @@ export function createIngestion({
layerState._retainedPreview = false;
layerState._detailRoadsReady = false;
let paintRevision = 0;
// The snapshot that ends a pass replaces the streamed roads with the
// composed set, which is mostly the tile records already parsed and
// drawn. Remembering those per pass means only the records new to that
// snapshot (the clipped coarse roads) are parsed, not the whole set again.
let parsedBySource = new WeakMap();
const onTile = (data, passSignal, preview) => {
if (generation !== layerState._loadGeneration || passSignal.aborted)
return;
const start = performance.now();
const parsed = layerState._parseRoads(data, trace);
const parsed = layerState._parseRoads(data, trace, parsedBySource);
phaseTiming('parse', start, { roads: parsed.length, incremental: true });
if (data.replace) streamed = parsed;
else streamed.push(...parsed);
@@ -531,6 +536,7 @@ export function createIngestion({
if (altitude > FAST_FETCH_ALTITUDE) return;
streamed = [];
parsedBySource = new WeakMap();
majorPass = false;
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
+18 -4
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { roadSurfaceChunks } from './surface.js';
import { floorCellReader, roadSurfaceChunks } from './surface.js';
import { t, translateKnown } from '../../i18n/index.js';
import {
flowDensityMult,
@@ -13,22 +13,35 @@ import {
} from './policy.js';
export function createModel({ state: layerState, services, parts, source }) {
/** Build scene waypoints from source records; thinning and terrain remain rendering policy. */
function parseRoads(roadData) {
/**
* Build scene waypoints from source records; thinning and terrain remain rendering policy.
*
* `parsedBySource` (source record → its parsed roads) is filled as records
* are parsed, and a record already in it is answered from it.
*/
function parseRoads(roadData, _trace, parsedBySource) {
if (!roadData || !roadData.roads) {
return [];
}
const roads = [];
const floorAt = floorCellReader(services.ground);
for (const road of roadData.roads) {
if (!road.coordinates || road.coordinates.length < 2) continue;
// A replacing snapshot repeats records this load already parsed.
const known = parsedBySource?.get(road);
if (known) {
for (const parsed of known) roads.push(parsed);
continue;
}
const first = roads.length;
for (const coords of roadSurfaceChunks(road.coordinates)) {
const type = road.type;
const oneway = road.oneway;
// Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation
const waypoints = coords.map(([lng, lat]) => {
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
const floor = floorAt(lat, lng);
const h =
(Number.isFinite(floor) && Math.abs(floor) <= 9000
? floor
@@ -60,6 +73,7 @@ export function createModel({ state: layerState, services, parts, source }) {
flow: road.flow || null,
});
}
parsedBySource?.set(road, roads.slice(first));
}
return roads;
+444
View File
@@ -0,0 +1,444 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTrafficSource, outsideDetailTiles } from './source.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { floorCellReader, roadSurfaceChunks } from './surface.js';
import { DOT_HEIGHT_OFFSET } from './policy.js';
import { clipTileLine } from '../../sources/openFreeMap.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
// The clip as it was before the extent shortcut: every road against the four
// regions around every tile. The shortcut must return exactly this.
function clipEveryRoadAgainstEveryTile(roads, tiles) {
return roads.flatMap((road) => {
let lines = [road.coordinates];
for (const tile of tiles) {
const box = tileToBBox(tile.z, tile.x, tile.y);
const regions = [
{ west: -180, east: box.west, south: -85, north: 85 },
{ west: box.east, east: 180, south: -85, north: 85 },
{ west: box.west, east: box.east, south: -85, north: box.south },
{ west: box.west, east: box.east, south: box.north, north: 85 },
];
lines = lines.flatMap((line) =>
regions.flatMap((region) => clipTileLine(line, region)),
);
}
return lines.map((coordinates) => ({ ...road, coordinates }));
});
}
function seeded(seed) {
let state = seed >>> 0;
return () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
function tileBlock(x, y, size = 4) {
const tiles = [];
for (let dx = 0; dx < size; dx++)
for (let dy = 0; dy < size; dy++)
tiles.push({ z: 14, x: x + dx, y: y + dy });
return tiles;
}
/** Roads in and around a tile block: wandering, straight, tiny, and on tile edges. */
function roadsAround(tiles, random, count = 400) {
const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
const west = Math.min(...boxes.map((box) => box.west)),
east = Math.max(...boxes.map((box) => box.east)),
south = Math.min(...boxes.map((box) => box.south)),
north = Math.max(...boxes.map((box) => box.north));
const width = east - west,
height = north - south;
const roads = [];
for (let i = 0; i < count; i++) {
const step = i % 7 === 0 ? 0.00002 : i % 3 === 0 ? 0.01 : 0.002;
let lon = west - width / 2 + random() * width * 2,
lat = south - height / 2 + random() * height * 2;
const coordinates = [[lon, lat]];
const points = 1 + Math.floor(random() * 12);
for (let j = 0; j < points; j++) {
lon += (random() - 0.5) * step;
lat += (random() - 0.5) * step;
coordinates.push([lon, lat]);
}
roads.push({ type: 'primary', oneway: i % 2, id: i, coordinates });
}
const box = boxes[5];
const midLat = (box.south + box.north) / 2,
midLon = (box.west + box.east) / 2;
roads.push(
// Along a tile edge, ending on a corner, and touching an edge from outside.
{
id: 'edge',
coordinates: [
[box.west, box.south - 0.01],
[box.west, box.north + 0.01],
],
},
{
id: 'corner',
coordinates: [
[box.west - 0.01, box.south - 0.01],
[box.west, box.south],
[box.east, box.north],
],
},
{
id: 'touch',
coordinates: [
[box.west - 0.01, midLat],
[box.west, midLat],
],
},
// Across the whole block, and a repeated vertex.
{
id: 'across',
coordinates: [
[west - 0.01, midLat],
[east + 0.01, midLat + 0.0003],
],
},
{
id: 'repeat',
coordinates: [
[midLon, midLat],
[midLon, midLat],
[midLon + 0.001, midLat],
],
},
// Records the clip drops or leaves alone.
{ id: 'single', coordinates: [[west - 0.02, midLat]] },
{ id: 'empty', coordinates: [] },
{
id: 'negative-zero',
coordinates: [
[-0, south - 0.02],
[0.001, south - 0.02],
],
},
{
id: 'polar',
coordinates: [
[west - 0.02, 86],
[west - 0.02, 86.01],
],
},
);
return roads;
}
for (const [place, x, y] of [
['Manhattan', 4823, 6158],
['London, across longitude 0', 8190, 5446],
['longitude 0 and latitude 0', 8190, 8190],
['the southern and eastern hemispheres', 15070, 9826],
])
test(`coarse roads clipped around detail tiles match the every-tile clip: ${place}`, () => {
const tiles = tileBlock(x, y);
const roads = roadsAround(tiles, seeded(x * 31 + y));
for (const loaded of [tiles, tiles.slice(0, 5), tiles.slice(3, 4), []]) {
const expected = clipEveryRoadAgainstEveryTile(roads, loaded);
assert.ok(loaded.length === 0 || expected.length !== roads.length);
// deepStrictEqual separates -0 from 0 and compares every vertex exactly.
assert.deepStrictEqual(outsideDetailTiles(roads, loaded), expected);
}
});
test('only a road that reaches a detail tile is clipped against it', () => {
const tiles = tileBlock(4823, 6158);
const block = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
const west = Math.min(...block.map((box) => box.west)),
north = Math.max(...block.map((box) => box.north));
// 300 roads beside the block and between its tile edges, none touching one.
const clear = [];
for (let i = 0; i < 300; i++) {
const box = block[i % block.length];
const lon = i % 2 ? west - 0.02 - i * 1e-5 : box.west + 0.004;
const lat = i % 2 ? box.south + 0.004 : north + 0.01 + i * 1e-5;
clear.push({
coordinates: [
[lon, lat],
[lon + 0.002, lat + 0.0005],
[lon + 0.003, lat + 0.001],
],
});
}
const first = block[0];
const crossing = {
coordinates: [
[first.west + 0.004, north + 0.01],
[first.west + 0.004, (first.south + first.north) / 2],
],
};
let clips = 0;
const counted = (line, region) => {
clips++;
return clipTileLine(line, region);
};
const roads = outsideDetailTiles([...clear, crossing], tiles, counted);
assert.deepStrictEqual(
roads,
clipEveryRoadAgainstEveryTile([...clear, crossing], tiles),
);
assert.equal(roads.length, 301);
// Four regions for each tile the crossing road reaches, never the 19,264
// clips of every road against every tile.
assert.ok(clips >= 4, `expected the crossing road to be clipped: ${clips}`);
assert.ok(clips <= 4 * tiles.length, `clipped ${clips} times`);
});
/** A DEM-regime ground service: one floor per toFixed(3) cell, counting reads. */
function cellGround({ mesh = false, warm = () => true } = {}) {
const ground = {
reads: 0,
meshFloorPreferred: () => mesh,
cachedGroundFloor(lat, lon) {
ground.reads++;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
// Number() as the shared cache does it: "-0.000" and "0.000" are one cell.
const cell = `${Number(lat.toFixed(3))},${Number(lon.toFixed(3))}`;
if (!warm(cell)) return null;
let hash = 0;
for (const character of cell)
hash = (Math.imul(hash, 31) + character.charCodeAt(0)) | 0;
// The mesh regime answers per point, not per cell.
return (hash % 4000) / 10 + (mesh ? lat - Math.floor(lat * 1000) / 1000 : 0);
},
};
return ground;
}
test('floor reads are shared per cell and equal the per-coordinate reads', () => {
const random = seeded(7);
const points = [];
for (let i = 0; i < 4000; i++)
points.push([40.75 + random() * 0.01, -73.99 + random() * 0.01]);
// Cell edges, where toFixed(3) and Math.round can disagree, in each sign.
for (const lat of [40.7505, -40.7505, 0.0005, -0.0005, 1.0005, 8.5745])
for (const lon of [-73.9905, 73.9905, 0.0005, -0.0005, 1.0005, 2.3515])
points.push([lat, lon], [lat + 1e-9, lon - 1e-9], [lat - 1e-9, lon]);
points.push([Number.NaN, 0], [0, Infinity], [0, 0], [-0, -0]);
const direct = cellGround();
const expected = points.map(([lat, lon]) =>
direct.cachedGroundFloor(lat, lon),
);
const ground = cellGround();
const floorAt = floorCellReader(ground);
assert.deepStrictEqual(
points.map(([lat, lon]) => floorAt(lat, lon)),
expected,
);
// 4000 points in a 0.01 degree square share about 121 cells.
assert.ok(ground.reads < 600, `read the shared cache ${ground.reads} times`);
// A cold cell is remembered as cold for the pass, not asked again.
const cold = cellGround({ warm: () => false });
const coldAt = floorCellReader(cold);
assert.equal(coldAt(40.7512, -73.9812), null);
assert.equal(coldAt(40.7513, -73.9813), null);
assert.equal(cold.reads, 1);
});
test('mesh floors and unknown ground services are read per coordinate', () => {
const mesh = cellGround({ mesh: true });
const meshAt = floorCellReader(mesh);
const a = meshAt(40.7512, -73.9812),
b = meshAt(40.75121, -73.9812);
assert.equal(mesh.reads, 2);
assert.notEqual(a, b);
// No regime to ask: nothing says a floor is the same across a cell.
let reads = 0;
const bareAt = floorCellReader({ cachedGroundFloor: () => ++reads });
bareAt(40.7512, -73.9812);
bareAt(40.7512, -73.9812);
assert.equal(reads, 2);
assert.equal(floorCellReader(undefined)(1, 2), undefined);
assert.equal(floorCellReader({})(1, 2), undefined);
});
function road(lon, lat, points = 40) {
const coordinates = [];
for (let i = 0; i < points; i++)
coordinates.push([lon + i * 0.00004, lat + i * 0.00003]);
return { type: 'residential', oneway: 1, coordinates };
}
test('parsing reads each floor cell once and builds the same waypoints', () => {
const ground = cellGround({ warm: (cell) => !cell.endsWith('3') });
const state = { _viewHeightEstimate: 12 };
const model = createModel({
state,
services: { ground },
parts: {},
source: {},
});
const records = [road(-73.99, 40.75), road(-73.9899, 40.7501)];
const parsed = model.parseRoads({ roads: records });
const coordinates = records.reduce(
(count, record) => count + record.coordinates.length,
0,
);
assert.ok(
ground.reads < coordinates / 3,
`${ground.reads} floor reads for ${coordinates} coordinates`,
);
const direct = cellGround({ warm: (cell) => !cell.endsWith('3') });
const expected = records.flatMap((record) =>
roadSurfaceChunks(record.coordinates).map((coords) =>
coords.map(([lon, lat]) => {
const floor = direct.cachedGroundFloor(lat, lon);
return Cesium.Cartesian3.fromDegrees(
lon,
lat,
(floor ?? state._viewHeightEstimate) + DOT_HEIGHT_OFFSET,
);
}),
),
);
assert.ok(direct.reads >= coordinates);
assert.deepStrictEqual(
parsed.map((entry) => entry.waypoints),
expected,
);
});
test('a record parsed earlier in the pass is not parsed again', () => {
const ground = cellGround();
const model = createModel({
state: {},
services: { ground },
parts: {},
source: {},
});
const first = { ...road(-73.99, 40.75), oneway: 0 },
second = road(-73.98, 40.76),
short = { type: 'primary', coordinates: [[-73.97, 40.77]] };
const parsedBySource = new WeakMap();
const tile = model.parseRoads({ roads: [first] }, null, parsedBySource);
assert.equal(tile.length, 2, 'a two-way record parses to both directions');
const reads = ground.reads;
const replaced = model.parseRoads(
{ roads: [second, short, first], replace: true },
null,
parsedBySource,
);
assert.equal(replaced.length, 3);
// The same parsed roads, in the snapshot's order: dots stay attached.
assert.equal(replaced[1], tile[0]);
assert.equal(replaced[2], tile[1]);
const fresh = cellGround();
createModel({
state: {},
services: { ground: fresh },
parts: {},
source: {},
}).parseRoads({ roads: [second] });
assert.equal(ground.reads - reads, fresh.reads, 'only the new record read');
// Without the map every call parses, as before.
const again = model.parseRoads({ roads: [first] });
assert.notEqual(again[0], tile[0]);
assert.deepStrictEqual(again, tile);
});
test('the snapshot that ends a pass parses only the records it adds', async () => {
const detail = { z: 14, x: 3743, y: 6745 };
const core = tileToBBox(detail.z, detail.x, detail.y);
const local = {
type: 'residential',
oneway: 1,
coordinates: [
[core.west + 0.002, core.south + 0.002],
[core.west + 0.004, core.south + 0.004],
],
};
const distant = {
type: 'primary',
oneway: 1,
coordinates: [
[core.east + 0.01, core.south],
[core.east + 0.02, core.south],
],
};
const bounds = {
...core,
east: core.east + 0.03,
coverage: {
detail: [detail],
coarse: [{ z: 12, x: 935, y: 1686 }],
key: 'test',
},
};
const source = createTrafficSource({
mapTiles: {
async fetchBounds(box, { zoom, onTile }) {
const tile = { roads: zoom === 14 ? [local] : [distant] };
onTile?.(tile);
return {
tiles: [tile],
loadedTiles: zoom === 14 ? [detail] : bounds.coverage.coarse,
partial: false,
};
},
},
});
const parsedRecords = [];
const painted = [];
const state = {
_enabled: true,
_loadGeneration: 0,
_tileCache: new Map(),
_roadMode: null,
_liveMode: false,
// The model's contract: fill the map, answer known records from it.
_parseRoads: (data, trace, parsedBySource) =>
data.roads.flatMap((record) => {
const known = parsedBySource?.get(record);
if (known) return known;
parsedRecords.push(record);
const parsed = [{ record }];
parsedBySource?.set(record, parsed);
return parsed;
}),
};
const ingestion = createIngestion({
state,
source,
services: {},
parts: {
viewport: {
clampBounds: (b) => b,
getBoundsCenter: () => ({ lat: 30.26, lon: -97.74 }),
},
flow: {
warmFlow: () => Promise.resolve([]),
applyFlowThenRender: async (roads, box, generation, altitude, label) => {
if (label === 'Loaded full') painted.push(roads);
return true;
},
},
},
});
await ingestion.loadRoadsForBounds(bounds, 350);
assert.equal(
parsedRecords.filter((record) => record === local).length,
1,
'the detail tile record is parsed when its tile streams, not again',
);
// The full graph is still the detail record plus the coarse road outside it.
assert.equal(painted.at(-1).length, 2);
assert.equal(painted.at(-1)[0].record, local);
assert.deepStrictEqual(
painted.at(-1)[1].record.coordinates,
distant.coordinates,
);
});
+100 -15
View File
@@ -33,23 +33,108 @@ export function trafficDetailBounds(box) {
return detail;
}
/** Keep coarse roads only outside the detailed tile cores. */
export function outsideDetailTiles(roads, tiles) {
/** Longitude/latitude extent of one line; NaN coordinates leave it unusable. */
function lineExtent(coordinates) {
let west = Infinity,
east = -Infinity,
south = Infinity,
north = -Infinity;
for (const [lon, lat] of coordinates) {
if (!(lon >= west)) west = lon;
if (!(lon <= east)) east = lon;
if (!(lat >= south)) south = lat;
if (!(lat <= north)) north = lat;
}
return { coordinates, west, east, south, north, settled: false };
}
/**
* What `clipTileLine` returns for a line lying wholly inside the clip region:
* the same line with each vertex recomputed from the one before it. Returns
* null for a line under the 12 m floor `clipTileLine` drops.
*/
function unclippedLine(coordinates) {
if (coordinates.length < 2) return null;
const out = [];
let metres = 0;
for (let i = 1; i < coordinates.length; i++) {
const a = coordinates[i - 1],
b = coordinates[i];
const dx = b[0] - a[0],
dy = b[1] - a[1];
if (i === 1) out.push([a[0] + 0 * dx, a[1] + 0 * dy]);
out.push([a[0] + dx, a[1] + dy]);
metres +=
Math.hypot(
(out[i][0] - out[i - 1][0]) * Math.cos((out[i][1] * Math.PI) / 180),
out[i][1] - out[i - 1][1],
) * 111320;
}
return metres >= 12 ? out : null;
}
const sameLine = (a, b) =>
a.every(
(point, i) => Object.is(point[0], b[i][0]) && Object.is(point[1], b[i][1]),
);
/**
* Keep coarse roads only outside the detailed tile cores.
*
* Each tile splits a line into the four regions around its core. Nearly every
* coarse road lies wholly inside one of those regions for nearly every tile,
* where the clip hands the line back unchanged, so that case is answered from
* the line's extent and only a line that reaches a tile's core or edge is
* clipped. Clipping every road against every tile froze the page for hundreds
* of milliseconds per road load at street level.
*
* `clip` is injectable for tests.
*/
export function outsideDetailTiles(roads, tiles, clip = clipTileLine) {
const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
return roads.flatMap((road) => {
let lines = [road.coordinates];
for (const tile of tiles) {
const box = tileToBBox(tile.z, tile.x, tile.y);
const regions = [
{ west: -180, east: box.west, south: -85, north: 85 },
{ west: box.east, east: 180, south: -85, north: 85 },
{ west: box.west, east: box.east, south: -85, north: box.south },
{ west: box.west, east: box.east, south: box.north, north: 85 },
];
lines = lines.flatMap((line) =>
regions.flatMap((region) => clipTileLine(line, region)),
);
let lines = [lineExtent(road.coordinates)];
for (const box of boxes) {
const next = [];
for (const line of lines) {
// Strictly inside one region, and inside the bounds every region
// shares: the other three regions return nothing.
const clear =
line.west > -180 &&
line.east < 180 &&
line.south > -85 &&
line.north < 85 &&
(line.east < box.west ||
line.west > box.east ||
(line.west > box.west &&
line.east < box.east &&
(line.north < box.south || line.south > box.north)));
if (!clear) {
const regions = [
{ west: -180, east: box.west, south: -85, north: 85 },
{ west: box.east, east: 180, south: -85, north: 85 },
{ west: box.west, east: box.east, south: -85, north: box.south },
{ west: box.west, east: box.east, south: box.north, north: 85 },
];
for (const region of regions)
for (const coordinates of clip(line.coordinates, region))
next.push(lineExtent(coordinates));
} else if (line.settled) {
next.push(line);
} else {
// The first unclipped pass can still move a vertex by a rounding
// step (a road crossing longitude or latitude 0); once a pass
// changes nothing, no later one will.
const coordinates = unclippedLine(line.coordinates);
if (!coordinates) continue;
const kept = lineExtent(coordinates);
kept.settled = sameLine(coordinates, line.coordinates);
next.push(kept);
}
}
lines = next;
}
return lines.map((coordinates) => ({ ...road, coordinates }));
return lines.map(({ coordinates }) => ({ ...road, coordinates }));
});
}
+39
View File
@@ -69,6 +69,45 @@ export function trafficSurfaceReady(scene) {
return currentRenderedFrame(scene)?.ready === true;
}
/**
* Ground-floor reads for one synchronous pass over road coordinates, asking
* the shared cache once per floor cell instead of once per coordinate.
*
* A road tile puts thousands of coordinates into a few hundred ~111 m cells,
* and each shared read formats and looks up two string keys, which added up
* to seconds per street-level load. Mesh floors answer per point (the nearest
* reading in the cell), so they are read through unchanged; only the DEM
* regime is one value per cell. Build a reader per pass: cells warm up
* between passes.
*/
export function floorCellReader(ground) {
const read = ground?.cachedGroundFloor;
if (!read) return () => undefined;
if (ground.meshFloorPreferred?.() !== false)
return (lat, lon) => read(lat, lon);
const cells = new Map();
return (lat, lon) => {
const y = lat * 1000,
x = lon * 1000;
const row = Math.round(y),
column = Math.round(x);
// The shared cache rounds with toFixed(3). At a cell edge that and
// Math.round can pick different cells, so an edge point asks directly.
if (!(
Math.abs(Math.abs(y - row) - 0.5) > 1e-6 &&
Math.abs(Math.abs(x - column) - 0.5) > 1e-6
))
return read(lat, lon);
const key = row * 1e6 + column;
let floor = cells.get(key);
if (floor === undefined) {
floor = read(lat, lon) ?? null;
cells.set(key, floor);
}
return floor;
};
}
/** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */
export function roadSurfaceChunks(coordinates) {
const chunks = [];
+12 -3
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { roadSurfaceChunks } from './surface.js';
import { floorCellReader, roadSurfaceChunks } from './surface.js';
import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js';
export function createTiming({ state: layerState, services, parts, source }) {
@@ -190,7 +190,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
* waypoint-materialization time independently.
*/
function parseRoadsTimed(roadData, trace) {
function parseRoadsTimed(roadData, trace, parsedBySource) {
/* TRACE_ONLY_BEGIN */
const _trafficTimingState = trafficTimingPass(
trace,
@@ -231,11 +231,19 @@ export function createTiming({ state: layerState, services, parts, source }) {
}
const roads = [];
const floorAt = floorCellReader(services.ground);
/* TRACE_ONLY_BEGIN */
let _trafficTimingWaypointMaterializationMs = 0;
/* TRACE_ONLY_END */
for (const road of roadData.roads) {
if (!road.coordinates || road.coordinates.length < 2) continue;
// A replacing snapshot repeats records this load already parsed.
const known = parsedBySource?.get(road);
if (known) {
for (const parsed of known) roads.push(parsed);
continue;
}
const first = roads.length;
for (const coords of roadSurfaceChunks(road.coordinates)) {
const type = road.type;
@@ -245,7 +253,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
const _trafficTimingMaterializeStart = performance.now();
/* TRACE_ONLY_END */
const waypoints = coords.map(([lng, lat]) => {
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
const floor = floorAt(lat, lng);
const h =
(Number.isFinite(floor) && Math.abs(floor) <= 9000
? floor
@@ -279,6 +287,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
flow: road.flow || null,
});
}
parsedBySource?.set(road, roads.slice(first));
}
/* TRACE_ONLY_BEGIN */
const _trafficTimingMetrics = {
+13 -36
View File
@@ -1,31 +1,22 @@
export const INFRARED_ALPHA_T0 = 0.4;
export const INFRARED_ALPHA_T1 = 0.7;
/** How much of the cloud key applies, 0–1, for a display mode or a 0–100
* strength. The weather shell passes it to its fragment shader. */
export function infraredAmount(strength) {
return strength === 'full'
? 0
: strength === 'filtered' || !Number.isFinite(strength)
? 1
: Math.max(0, Math.min(100, strength)) / 100;
}
/** Scale each pixel's alpha in place; RGB is left as it is. The scale depends
* only on the brightest channel, so it is worked out once for each of the 256
* byte values instead of once per pixel: a 4096×2048 image has 8.4 million
* pixels and the power curve per pixel held the page for over 100 ms. The
* table holds the same numbers the per-pixel formula produced, so the output
* bytes are unchanged.
/** Return display pixels without changing RGB or the source RGBA bytes.
* `strength` is 0–100: zero is the full image and 100 is the cloud key.
*/
export function applyInfraredAlpha(pixels, strength = 'filtered') {
const amount = infraredAmount(strength);
export function infraredAlpha(rgba, strength = 'filtered') {
const pixels = new Uint8ClampedArray(rgba);
const amount =
strength === 'full'
? 0
: strength === 'filtered' || !Number.isFinite(strength)
? 1
: Math.max(0, Math.min(100, strength)) / 100;
if (amount === 0) return pixels;
const scale = new Float64Array(256);
for (let value = 0; value < 256; value++) {
for (let i = 0; i < pixels.length; i += 4) {
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
const linearMax = (value / 255) ** 2.2;
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
@@ -35,21 +26,7 @@ export function applyInfraredAlpha(pixels, strength = 'filtered') {
),
);
const filtered = t * t * (3 - 2 * t);
scale[value] = 1 - amount + filtered * amount;
}
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const max = r > g ? (r > b ? r : b) : g > b ? g : b;
pixels[i + 3] = pixels[i + 3] * scale[max];
pixels[i + 3] = rgba[i + 3] * (1 - amount + filtered * amount);
}
return pixels;
}
/** Return display pixels without changing RGB or the source RGBA bytes.
* `strength` is 0–100: zero is the full image and 100 is the cloud key.
*/
export function infraredAlpha(rgba, strength = 'filtered') {
return applyInfraredAlpha(new Uint8ClampedArray(rgba), strength);
}
-41
View File
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyInfraredAlpha,
infraredAlpha,
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
@@ -62,43 +61,3 @@ test('full infrared preserves every channel including partial and zero alpha', (
]);
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
});
test('the lookup table gives the per-pixel curve byte for byte, in place', () => {
// The transfer as it was written per pixel, before the table.
const perPixel = (rgba, amount) => {
const out = new Uint8ClampedArray(rgba);
for (let i = 0; i < out.length; i += 4) {
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
1,
(linearMax - INFRARED_ALPHA_T0) /
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
),
);
out[i + 3] = rgba[i + 3] * (1 - amount + t * t * (3 - 2 * t) * amount);
}
return out;
};
// Every brightest-channel value in each channel, against a spread of alphas.
const bytes = [];
for (let value = 0; value < 256; value++)
for (const alpha of [0, 1, 37, 128, 200, 254, 255]) {
bytes.push(value, 0, 0, alpha);
bytes.push(value >> 1, value, 0, alpha);
bytes.push(0, value >> 2, value, alpha);
}
const source = new Uint8ClampedArray(bytes);
for (const strength of [1, 33, 50, 99, 100, 'filtered', Number.NaN]) {
const amount = Number.isFinite(strength) ? strength / 100 : 1;
const pixels = source.slice();
assert.equal(applyInfraredAlpha(pixels, strength), pixels);
assert.deepEqual(pixels, perPixel(source, amount), `strength ${strength}`);
}
for (const strength of [0, 'full']) {
const pixels = source.slice();
assert.deepEqual(applyInfraredAlpha(pixels, strength), source);
}
});
+6 -18
View File
@@ -6,7 +6,7 @@ import {
drawWeatherPixels,
closeWeatherPixels,
} from './source.js';
import { applyInfraredAlpha } from './infraredAlpha.js';
import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
@@ -31,22 +31,13 @@ export function processInfraredImage(
function applyInfraredTransfer(context, canvas, mode) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
// In place: a copy of a 4096×2048 image is 33 MB, made and written back.
applyInfraredAlpha(pixels.data, mode);
pixels.data.set(infraredAlpha(pixels.data, mode));
context.putImageData(pixels, 0, 0);
}
/** Draw pixels a frame source supplied into a new canvas, with the display
* transfer for infrared products, and release them. They must be `size`. */
function drawSuppliedImage(
pixels,
product,
mode,
size,
signal,
createCanvas,
transfer,
) {
function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
try {
signal.throwIfAborted();
const { width, height } = weatherPixelSize(pixels);
@@ -57,7 +48,7 @@ function drawSuppliedImage(
texture.height = height;
const context = texture.getContext('2d');
drawWeatherPixels(context, pixels);
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
if (product === 'clouds' || product === 'clouds-regional')
applyInfraredTransfer(context, texture, mode);
return texture;
} finally {
@@ -97,8 +88,7 @@ export function decodeInfraredImage(blob, signal) {
}
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
* it into a canvas. Infrared frames get the display transfer unless `transfer`
* is false, for a caller that applies it when drawing; a canvas also keeps
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
* image is always released. With a `frameSource` that has `getImage`, the host
* supplies the decoded pixels instead and nothing is fetched or decoded here. */
@@ -117,7 +107,6 @@ export async function acquireWeatherImage(
now = () => performance.now(),
onFetched = () => {},
frameSource = null,
transfer = true,
},
) {
if (frameSource?.getImage) {
@@ -138,7 +127,6 @@ export async function acquireWeatherImage(
size,
signal,
createCanvas,
transfer,
);
return { texture, decodeMs: now() - started };
}
@@ -159,7 +147,7 @@ export async function acquireWeatherImage(
if (image.width !== size.width || image.height !== size.height)
throw new Error('Invalid weather image dimensions');
let texture;
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
if (product === 'clouds' || product === 'clouds-regional')
texture = processInfraredImage(image, mode, createCanvas);
else {
texture = createCanvas();
-69
View File
@@ -26,75 +26,6 @@ function canvas() {
return output;
}
test('the transfer is applied to the canvas pixels in place, with no copy written back', () => {
let read = null;
const output = {};
output.getContext = () => ({
drawImage() {},
getImageData: () => {
read = { data: new Uint8ClampedArray([194, 0, 0, 200]) };
// A second full-size buffer copied over the first is what this guards.
read.data.set = () => {
throw new Error('the canvas pixels were overwritten from a copy');
};
return read;
},
putImageData: (pixels) => {
output.pixels = pixels;
},
});
processInfraredImage({ width: 1, height: 1 }, 'filtered', () => output);
assert.equal(output.pixels, read);
assert.deepEqual([...read.data], [194, 0, 0, 98]);
});
test('with transfer off an infrared frame is drawn and never read back', async () => {
const drawn = [];
const createCanvas = () => {
const output = {};
output.getContext = () => ({
drawImage: (image) => drawn.push(image),
getImageData: () => {
throw new Error('the frame was read back on the page');
},
putImageData: () => {
throw new Error('the frame was rewritten on the page');
},
});
return output;
};
const size = { width: 4096, height: 2048 };
const image = { ...size, close() {} };
const options = {
signal: new AbortController().signal,
mode: 'filtered',
size,
maxBytes: 1024,
createCanvas,
transfer: false,
};
const fetched = await acquireWeatherImage(
'clouds-regional',
'2026-09-15T20:00:00.000Z',
{
...options,
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
arrayBuffer: async () => new ArrayBuffer(1),
}),
decodeImage: async () => image,
},
);
assert.equal(fetched.texture.width, 4096);
const supplied = { ...size };
await acquireWeatherImage('clouds-regional', '2026-09-15T20:00:00.000Z', {
...options,
frameSource: { getImage: async () => supplied },
});
assert.deepEqual(drawn, [image, supplied]);
});
test('decoded pixels are processed once on a same-sized canvas', () => {
const image = { width: 1, height: 1 };
const output = processInfraredImage(image, 'filtered', canvas);
+17 -34
View File
@@ -1,9 +1,4 @@
import { acquireWeatherImage } from './infraredImage.js';
import {
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
infraredAmount,
} from './infraredAlpha.js';
import { imageryHostStatus } from './imageryHost.js';
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
import { msg } from '../../i18n/index.js';
@@ -41,10 +36,6 @@ const LIFT_STEP = 500;
// draw nothing until the full-extent image has arrived, and sample the detail
// image only once it has arrived. The window is west, south, east, north in the
// full-extent image's texture coordinates.
// `infrared` is how much of the cloud key applies, 0–1 (infraredAlpha.js has
// the same curve for the globe's draped imagery). It is applied here, to the
// image as it was decoded, so a 4096×2048 frame is never read back and
// rewritten on the page, and a strength change is one uniform, not a new image.
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
@@ -55,8 +46,7 @@ const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput material
float useDetail = inside * step(1.5, float(detailDimensions.x));
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
material.diffuse = czm_gammaCorrect(c.rgb);
float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2));
material.alpha = c.a * mix(1.0, key, infrared) * alpha * step(1.5, float(imageDimensions.x));
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
return material;
}
`;
@@ -73,7 +63,6 @@ function registerMaterial(cesium) {
image: cesium.Material.DefaultImageId,
detail: cesium.Material.DefaultImageId,
alpha: 1,
infrared: 0,
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
},
// Diffuse and alpha share these samples, so one source, not components.
@@ -256,12 +245,6 @@ export function createShellSurface({
material.uniforms.alpha = value;
scene.requestRender();
},
/** How much of the infrared cloud key applies, 0–1. */
setInfrared(value) {
if (destroyed || material.uniforms.infrared === value) return;
material.uniforms.infrared = value;
scene.requestRender();
},
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
* image's texture coordinates, 0–1); null clears it. In the same window only
* the image changes, and Cesium keeps drawing the previous texture until
@@ -455,11 +438,10 @@ export function createWeatherShell({
const visible = () => !hidden && !suspended();
const boundsKey = ({ west, south, east, north }) =>
`${west},${south},${east},${north}`;
// One image per time and window: the infrared mode is a uniform, not pixels.
const cacheKey = (time, box = null) =>
box ? `${time}|${boundsKey(box)}` : time;
const cacheKey = (time, mode, box = null) =>
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
const wantedDetail = () =>
current && view ? cacheKey(current.time, view) : null;
current && view ? cacheKey(current.time, current.infrared, view) : null;
function evict(keep = []) {
const kept = new Set([...keep, current?.key, detail?.key]);
@@ -472,11 +454,12 @@ export function createWeatherShell({
}
async function acquire(
time,
mode,
signal,
{ box = null, keep = [], onFetched } = {},
) {
signal.throwIfAborted();
const key = cacheKey(time, box);
const key = cacheKey(time, mode, box);
const entry = images.get(key);
if (entry) {
images.delete(key);
@@ -485,8 +468,7 @@ export function createWeatherShell({
}
const result = await acquireWeatherImage(product, time, {
signal,
// The surface's shader applies the infrared transfer.
transfer: false,
mode,
size: box ? detailSize : size,
bbox: box,
maxBytes: MAX_IMAGE_BYTES,
@@ -582,7 +564,9 @@ export function createWeatherShell({
const job = { key, box: view, controller: new AbortController() };
detailJob = job;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
void acquire(current.time, job.controller.signal, { box: view })
void acquire(current.time, current.infrared, job.controller.signal, {
box: view,
})
.then(({ texture }) => installDetail(job, texture))
.catch(() => failDetail(job));
}
@@ -677,7 +661,6 @@ export function createWeatherShell({
loadMs: now() - frame.startedAt,
};
surface.setAlpha(alpha);
surface.setInfrared(infrared ? infraredAmount(frame.infrared) : 0);
applyVisibility();
lastError = null;
evict();
@@ -732,7 +715,7 @@ export function createWeatherShell({
return changed;
},
cancelPrefetch,
async prefetch(snapshot, time) {
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
let job;
try {
if (
@@ -746,8 +729,8 @@ export function createWeatherShell({
// Warm the next frame's detail window too while the window stays put.
const box =
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
const coarse = cacheKey(time);
const key = box ? cacheKey(time, box) : coarse;
const coarse = cacheKey(time, mode);
const key = box ? cacheKey(time, mode, box) : coarse;
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
@@ -756,8 +739,8 @@ export function createWeatherShell({
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
// Both images at once: one after the other would outlast playback's step.
await Promise.all([
acquire(time, signal, { keep: box ? [key] : [] }),
box ? acquire(time, signal, { box, keep: [coarse] }) : null,
acquire(time, mode, signal, { keep: box ? [key] : [] }),
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
]);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
@@ -799,7 +782,7 @@ export function createWeatherShell({
infrared: mode,
bounds,
product: snapshot.product,
key: cacheKey(time),
key: cacheKey(time, mode),
mosaic: { fetched: false, decodeMs: null, cached: false },
controller: new AbortController(),
startedAt: now(),
@@ -814,7 +797,7 @@ export function createWeatherShell({
});
watch(frame, signal);
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
void acquire(time, frame.controller.signal, {
void acquire(time, mode, frame.controller.signal, {
onFetched: () => {
frame.mosaic.fetched = true;
},
+18 -35
View File
@@ -11,7 +11,6 @@ import {
WEATHER_SHELL_HEIGHTS,
} from './shellRendering.js';
import { NO_IMAGERY_HOST } from './imageryHost.js';
import { INFRARED_ALPHA_T0, INFRARED_ALPHA_T1 } from './infraredAlpha.js';
import {
createShellCesium,
createShellScene,
@@ -148,13 +147,12 @@ test('surface builds a flat raised rectangle drawn in primitive order without de
image: Cesium.Material.DefaultImageId,
detail: Cesium.Material.DefaultImageId,
alpha: 1,
infrared: 0,
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
});
const { source } = template.fabric;
assert.match(
source,
/material\.alpha = c\.a \* mix\(1\.0, key, infrared\) \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
/material\.alpha = c\.a \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
'nothing draws before the full-extent image',
);
assert.match(
@@ -375,7 +373,6 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an
/uniform sampler2D image_\d+;/,
/uniform sampler2D detail_\d+;/,
/uniform float alpha_\d+;/,
/uniform float infrared_\d+;/,
/uniform vec4 window_\d+;/,
/uniform ivec3 imageDimensions_\d+;/,
/uniform ivec3 detailDimensions_\d+;/,
@@ -387,16 +384,9 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an
assert.match(shader, /step\(1\.5, float\(detailDimensions_\d+\.x\)\)/);
assert.match(
shader,
/material\.alpha = c\.a \* mix\(1\.0, key, infrared_\d+\) \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/,
/material\.alpha = c\.a \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/,
'the uniform is renamed, the material field is not',
);
// The cloud key the globe's draped imagery gets from infraredAlpha.js:
// brightest channel, 2.2 power, the shared thresholds.
assert.ok(
shader.includes(
`float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2));`,
),
);
assert.match(shader, /float useDetail = inside \* step/, 'locals keep names');
const detail = { width: 4096, height: 2048 };
surface.setDetail(detail, { west: 0.1, south: 0.2, east: 0.3, north: 0.4 });
@@ -753,12 +743,12 @@ test('prefetch warms a decoded frame without staging and cancels cleanly', async
h.shell.clear();
});
test('infrared frames reach the shader as decoded, with the cloud key as a uniform; radar and lightning get none', async () => {
for (const [product, amount] of [
['clouds-regional', 1],
['clouds', 1],
['radar', 0],
['lightning', 0],
test('infrared frames get the display transfer; radar and lightning keep their pixels', async () => {
for (const [product, transfer] of [
['clouds-regional', true],
['clouds', true],
['radar', false],
['lightning', false],
]) {
const h = harness({ product });
const pending = h.shell.setFrame(snapshot(product), times[0]);
@@ -766,10 +756,11 @@ test('infrared frames reach the shader as decoded, with the cloud key as a unifo
const image = h.material().uniforms.image;
assert.equal(image.source, h.decoded[0], 'drawn into a canvas');
assert.equal(h.decoded[0].closed, true);
assert.equal(image.pixels, undefined, `${product}: pixels never rewritten`);
assert.equal(image.pixels !== undefined, transfer, product);
if (transfer)
assert.deepEqual([...image.pixels], [194, 0, 0, 98], 'clouds only');
h.render();
assert.equal(await pending, true);
assert.equal(h.material().uniforms.infrared, amount, product);
assert.equal(
h.shell.getDiagnostics().height,
WEATHER_SHELL_HEIGHTS[product],
@@ -778,28 +769,20 @@ test('infrared frames reach the shader as decoded, with the cloud key as a unifo
}
});
test('an infrared mode change is a uniform on the same image: no fetch, no new canvas', async () => {
test('an infrared mode change restages the same time with its own cached canvas', async () => {
const h = harness({ product: 'clouds' });
assert.equal(await show(h, times[0]), true);
const image = h.material().uniforms.image;
assert.equal(h.material().uniforms.infrared, 1);
const filtered = h.material().uniforms.image;
assert.equal(await show(h, times[0]), true, 'same frame is a no-op');
assert.equal(h.fetches.length, 1);
const renders = h.scene.renders;
assert.equal(await show(h, times[0], { infrared: 'full' }), true);
assert.equal(h.material().uniforms.image, image);
assert.equal(h.material().uniforms.infrared, 0);
const full = h.material().uniforms.image;
assert.notEqual(full, filtered);
assert.deepEqual([...full.pixels], [194, 0, 0, 200]);
assert.equal(h.shell.getDiagnostics().infrared, 'full');
assert.ok(h.scene.renders > renders, 'the new strength is drawn');
assert.equal(await show(h, times[0], { infrared: 40 }), true);
assert.equal(h.material().uniforms.infrared, 0.4);
assert.equal(h.shell.getDiagnostics().infrared, 40);
assert.equal(await show(h, times[0], { infrared: 'filtered' }), true);
assert.equal(h.material().uniforms.image, image);
assert.equal(h.material().uniforms.infrared, 1);
assert.equal(h.fetches.length, 1);
assert.equal(h.decoded.length, 1);
assert.equal(h.shell.getDiagnostics().cache.mosaics, 1);
assert.equal(h.material().uniforms.image, filtered, 'cache hit');
assert.equal(h.fetches.length, 2);
h.shell.clear();
});