Draw weather, wind and Bhote Koshi through engine.imagery, one display each

engine.imagery gains what these rasters need, on every engine:
- geographic (EPSG:4326) tile schemes and a transform applied to each
  decoded tile (the satellite infrared mask);
- staged source changes, which keep the drawn source until the new one
  has loaded (the weather frame swap), and credit-only changes in place;
- raised layers that draw on a shell above terrain and 3D tiles, rising
  with the camera, with a detail image inside a rectangle. On Cesium this
  is the weather shell primitive, moved into the engine; on Navara the
  pixel's ray meets the ellipsoid grown by the height.

Rain radar, satellite clouds, lightning, the wind field and the Bhote
Koshi event and locator each draw through one render/display.js on both
engines; their Navara renderers and Navara's weather surface pass go.
Cesium's pixels are unchanged in every compared scene. On Navara the
Bhote Koshi images are no longer upside down. Two engine-specific parts
stay behind one backend option each: the wind's streamlines and the
Bhote Koshi story entities.

The coupling report now counts defineGroundEffect calls, not its own
declaration. Coupling: Navara renderer lines fall from 2304 to 1695,
Navara ground passes from 3 to 1 (the Global Context pulses), engine
kind checks from 5 to 2. The contract suite passes on both engines
(Cesium 71/71, Navara 74/74).
This commit is contained in:
German Khokhlov committed 2026-11-07 11:14:50 +04:00
1 parent b53b6a48a7
commit c73c5382d4
39 files changed
+4677 -1114

No files matched your search

+85 -27
View File
@@ -289,7 +289,7 @@ moving, under Cesium's 23 ms.
## Imagery: rasters on the ground
`engine.imagery.createLayer({ id, source, alpha, show, split, order })`
`engine.imagery.createLayer({ id, source, alpha, show, split, order, raise })`
makes one raster layer draped over the terrain and the 3D tiles, under
every ground fill, mover, line and model. Its colours are the image's own,
blended by the texel's alpha times the layer's `alpha`.
@@ -307,15 +307,36 @@ Both take:
crosses the antimeridian): where the layer draws, the globe when omitted;
- `credit`: HTML shown while the layer is.
Tiles are Web Mercator XYZ tiles and stop at ±85.05°. `opaque` tiles have
no alpha channel, and `maxRequests` caps the layer's tile requests in
flight. An image is stretched over its rectangle in longitude and latitude,
row 0 on the north edge; one over the whole globe wraps across 180°.
Tiles are Web Mercator XYZ tiles, which stop at ±85.05°, or with
`scheme: 'geographic'` EPSG:4326 tiles (2×1 at level 0, y down from 90°N,
Cesium's `GeographicTilingScheme`). `opaque` tiles have no alpha channel,
and `maxRequests` caps the layer's tile requests in flight.
`transform(image)` turns each decoded tile (row 0 on the north edge) into
the canvas or image that draws: the weather's infrared mask. An image is
stretched over its rectangle in longitude and latitude, row 0 on the north
edge; one over the whole globe wraps across 180°.
`raise: heightM` puts an image layer on a shell that high above the
ellipsoid instead of draping it: it draws over terrain, 3D tiles and the
sky's edge, hidden only by what is nearer. The shell rises with the camera
(4 m per kilometre of camera height, at most 60 km, in 500 m steps) so the
coarse 3D tiles seen from far never hide it. Raised layers take image
sources only.
Work with a layer like this:
- `setSource(source)` draws another source in its place, at its place in
the order; the same canvas or bytes again draw their new pixels;
the order; the same canvas or bytes again draw their new pixels, and a
source that differs only in its `credit` keeps its pixels;
- `setSource(source, { staged: true, signal })` keeps the drawn source
until the new one is complete (the image loaded; the tiles in view in),
then swaps them. It resolves true then, and false when the new source
failed, `signal` aborted it, or another `setSource` replaced it first. A
hidden layer takes it at once. `staging()` says one is on its way;
- `setDetail({ image, rectangle } | null)` (raised layers): inside the
rectangle the layer draws the detail image in place of its own (a
sharper view of the area in view). The last detail draws until the new
one is uploaded; it resolves true once it draws. null clears it;
- `setAlpha(alpha)` fades it: at 0 it draws nothing but stays on its
surface, so it comes back without a gap (the FIRMS hand-off);
- `show(visible)` shows or hides it (Cesium unmaps a hidden layer's tiles);
@@ -353,7 +374,26 @@ On the engine:
- an image: one geographic tile over the rectangle (as
`SingleTileImageryProvider`), handed to Cesium as the canvas or image
itself, with no PNG round trip;
- `split` is the layer's `splitDirection`; Cesium splits on the globe only.
- geographic tiles: a `GeographicTilingScheme`; a transform wraps
`requestImage` (Cesium's tile bitmaps arrive flipped, so the transform
gets them upright on a canvas);
- a credit is read through the provider's `credit` getter every frame, so a
new credit of the same source needs no new layer;
- a staged source is a second `ImageryLayer` at alpha 0 right over the
drawn one, whose tiles Cesium loads while the drawn one shows. Its own
requests are counted (deferrals too); once they are in (the globe's tiles
all loaded, or its own requests quiet for 200 ms) for two frames in a
row, it takes the drawn one's alpha and the drawn one goes after the next
frame. Throttled tiles (429, 503) retry three times; any other failed
tile fails the source;
- a raised layer is a primitive of its own (`imageryShell.js`): a
rectangle at the height with a material of the image and the detail in
its window, flat, alpha blended in the opaque pass without depth writes,
first in `scene.primitives` in the imagery order, lifted by a uniform
scale about the Earth's centre. A new image on the same rectangle swaps
once uploaded; another rectangle is a new shell at alpha 0 until drawn;
- `split` is the layer's `splitDirection`; Cesium splits on the globe only
(not raised layers).
**Navara** (`src/globe/navara/imagery/`) draws every shown layer in one
screen pass right after tone mapping, under the other drapes and
@@ -365,10 +405,18 @@ screen pass right after tone mapping, under the other drapes and
each in its rectangle and on its side of the split (the top 8 when more
are shown);
- an image is one mipmapped texture, sampled in longitude and latitude;
- tiles are an atlas of at most 8×8 Web Mercator tiles over the part of the
rectangle in view, at the level the camera's height asks for; it is
rebuilt when the camera moves to another plan and filled as tiles arrive,
6 loads at a time (or `maxRequests`);
- tiles are an atlas of at most 8×8 Web Mercator or geographic tiles over
the part of the rectangle in view, at the level the camera's height asks
for (a geographic level one below Web Mercator's); it is rebuilt when the
camera moves to another plan and filled as tiles arrive, each through the
transform, 6 loads at a time (or `maxRequests`);
- a staged source builds its own texture or atlas (for the view now, again
when the camera moves) while the drawn one shows, and takes its place
once every tile of it has settled; a failed tile fails it;
- a raised slot is sampled where the pixel's ray meets the ellipsoid grown
by its lifted height, where nothing of the scene is nearer; a detail is a
second slot right over its layer's, which draws nothing inside the
detail's rectangle;
- texels are blended in display space, as Cesium's globe blends imagery;
mip levels come from the derivatives of the ground position, taken
before any branch, so rectangle edges and the antimeridian show no seams.
@@ -381,23 +429,33 @@ and nothing on 3D tiles. It stays until those overlays move, which needs a
finer Navara tiles path first: a global overlay seen obliquely gets coarse
near the camera from one 8×8 atlas over the whole view.
### What is still to move
### Who draws through imagery
- **Weather radar, satellite clouds and lightning** (`src/layers/weather/`):
tile templates in geographic and Web Mercator tilings, decoded canvases
tiled into a provider, staged frames (a new frame loads under the shown
one at alpha 0 and replaces it once its tiles are in), a per-tile image
transform (the infrared mask), a detail window over the global image,
and the raised shells Cesium draws over 3D tiles at a height. They need a
geographic `tiles` scheme, a staged `setSource` (or `ready()` per tile
set) and a tile transform; the raised shell is a separate primitive.
- **The wind field** (`src/layers/wind/`): one image over the globe at a
fixed alpha, faded by camera height — an `image` layer — and the same
raised shell on 3D tiles.
- **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/`, Cesium in
`src/data/bhoteKoshiEvent.js`): two image URLs over one rectangle, split
left and right, alpha 0.96 — two `image` layers with `url` and `split`.
Navara still draws them with `src/layers/recentImagery/render/navaraGround.js`.
- **FIRMS heat** (`src/layers/firms/render/display.js`): one image.
- **Recent Imagery** (`src/layers/recentImagery/render/display.js`): two
Web Mercator tile layers under a split.
- **Weather radar, satellite clouds and lightning**
(`src/layers/weather/render/display.js`): on the globe, the product's
geographic tiles (the regional infrared through its mask, a tile
transform), or the global infrared mosaic as one image; each new frame a
staged source; clouds, radar and lightning at orders 21, 22, 23. On 3D
tiles, a raised layer at the product's shell height
(`shellRendering.js` `WEATHER_SHELL_HEIGHTS`) with the detail window
around the view as its detail.
- **The wind field** (`src/layers/wind/render/display.js`): one image over
the globe at order 20, faded with the camera's height while streamlines
draw, raised at 5 km on 3D tiles. The streamlines stay each engine's,
behind the display's `flow` backend: Cesium's wind renderer without its
field (`rendering.js`, from the layer), Navara's flow mesh
(`render/navaraFlow.js`, from the manifest).
- **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/render/display.js`):
two `url` images over the event's rectangle at alpha 0.96, split left
and right at the engine's split position, order 15. The event's and the
locator's story entities still draw on Cesium itself and through
Navara's story backend (`src/layers/shared/render/navaraStory.js`).
`engine.imagery` is still not used by the open map overlays (Sea Marks, Ski
Pistes; see above).
### Choosing a primitive
+5 -5
View File
@@ -1,16 +1,16 @@
{
"metrics": {
"cesiumLayerRendererLines": 330,
"navaraLayerRendererLines": 2304,
"navaraLayerRendererLines": 1695,
"cesiumImportFiles": 27,
"viewerLines": 1232,
"navaraKindChecks": 5,
"navaraKindChecks": 2,
"engineSniffCalls": 0,
"engineExtensionReads": 8,
"groundEffectPasses": 3,
"groundEffectPasses": 1,
"cesiumSdkLoads": 4,
"screenSpaceHandlerFiles": 4,
"engineNameLines": 66
"engineNameLines": 63
},
"measuredAt": "after animated and binned ground corridors, 2026-11-07"
"measuredAt": "after weather, wind and Bhote Koshi on engine.imagery, 2026-11-07"
}
+5
View File
@@ -1645,6 +1645,7 @@
"src/layers/directions/edgeCases.test.mjs",
"src/layers/streetLevel/edgeCases.test.mjs",
"scripts/qa-imagery-compare.mjs",
"scripts/qa-weather-compare.mjs",
"src/globe/cesium/imagery.js",
"src/globe/cesium/imagery.test.mjs",
"src/globe/imagery.js",
@@ -1654,6 +1655,10 @@
"src/globe/navara/imagery/pass.js",
"src/layers/recentImagery/render/display.js",
"src/layers/recentImagery/render/display.test.mjs",
"src/layers/weather/render/display.test.mjs",
"src/layers/wind/render/display.test.mjs",
"src/layers/bhoteKoshi/render/display.test.mjs",
"src/layers/shared/render/navaraGroundOrder.test.mjs",
"src/globe/navara/ground/corridors.js",
"scripts/qa-ground-looks.mjs"
]
+10 -11
View File
@@ -3465,10 +3465,6 @@
"src/scenes/dataPacks/navara.js",
"src/scenes/dataPacks/navaraGeometry.js",
"src/layers/shared/render/navaraStory.js",
"src/layers/bhoteKoshi/render/navara.js",
"src/layers/weather/render/navara.js",
"src/layers/weather/render/navaraSurface.js",
"src/layers/wind/render/navara.js",
"src/layers/wind/render/flowMesh.js",
"src/layers/shared/render/navaraGroundPicks.js",
"src/layers/shared/render/groundRing.js",
@@ -3477,7 +3473,6 @@
"src/layers/firms/rectangle.js",
"src/layers/shared/render/navaraMoverBank.js",
"src/layers/shared/render/moverBank.js",
"src/layers/recentImagery/render/navaraGround.js",
"src/layers/cctv/render/navaraMeshes.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/presentation.js",
@@ -3576,10 +3571,15 @@
"src/layers/streetLevel/render/display.js",
"src/layers/traffic/render/display.js",
"src/layers/recentImagery/render/display.js",
"src/layers/weather/render/display.js",
"src/layers/wind/render/display.js",
"src/layers/wind/render/navaraFlow.js",
"src/layers/bhoteKoshi/render/display.js",
"src/globe/navara/imagery/pass.js",
"src/globe/navara/imagery/index.js",
"src/globe/imagery.js",
"src/globe/cesium/imagery.js",
"src/globe/cesium/imageryShell.js",
"src/globe/navara/ground/corridors.js"
],
"external": [
@@ -4321,17 +4321,16 @@
"src/layers/streetLevel/render/display.js",
"src/layers/traffic/render/display.js",
"src/layers/recentImagery/render/display.js",
"src/layers/weather/render/display.js",
"src/layers/wind/render/display.js",
"src/layers/wind/render/navaraFlow.js",
"src/layers/bhoteKoshi/render/display.js",
"src/layers/shared/render/navaraStory.js",
"src/layers/cctv/render/navaraMeshes.js",
"src/layers/recentImagery/render/navaraGround.js",
"src/layers/shared/render/navaraGroundSurface.js",
"src/layers/wind/render/flowMesh.js",
"src/layers/wind/render/navara.js",
"src/layers/shared/render/navaraGroundEffect.js",
"src/layers/awareness/render/navaraPulses.js",
"src/layers/weather/render/navaraSurface.js",
"src/layers/weather/render/navara.js",
"src/layers/bhoteKoshi/render/navara.js"
"src/layers/awareness/render/navaraPulses.js"
],
"external": [
"hls.js",
+638
View File
@@ -0,0 +1,638 @@
#!/usr/bin/env node
/**
* Draw the rasters that moved to engine.imagery's tile schemes, staged
* sources and raised layers — the rain radar, lightning density, regional
* and global satellite clouds, the wind field and the Bhote Koshi
* before/after split — on both real engines, read the canvas back, and
* compare two runs pixel by pixel (before and after a change), and the two
* engines against each other. Run it against a keyless dev server of each
* version, then compare:
*
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
* node scripts/qa-weather-compare.mjs shoot http://localhost:4291 --out=DIR
* node scripts/qa-weather-compare.mjs compare BEFORE_DIR AFTER_DIR
*
* The page must serve `/qa-cesium.js` (`export * from 'cesium';` at the
* root of the served copy): older versions build their Cesium renderers
* from the SDK. Every scene freezes the app clock at local noon and waits
* for the map tiles. The weather tiles and images are generated here
* (intercepted /api/weather/tile and /api/weather/image) from one field of
* longitude and latitude, so a tile and a whole-extent image of the same
* place agree; the wind is a fixed field, paused. Each scene is shot with
* and without the raster: `overlay` is where the raster changed the frame.
* Google and Cesium ion are blocked, so no engine draws photoreal tiles.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const BLOCKED_HOSTS =
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
const args = process.argv.slice(2);
const [command, first, second] = args.filter((arg) => !arg.startsWith('--'));
const option = (name) =>
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const SIZE = { width: 1280, height: 800 };
const TIME = '2026-11-07T12:00:00.000Z';
const US = { west: -130, south: 20, east: -60, north: 55 };
const GLOBE = { west: -180, south: -90, east: 180, north: 90 };
const NEPAL = { west: 85.295, south: 28.135, east: 85.379, north: 28.292 };
const SCENES = [
{
id: 'radar',
kind: 'weather',
product: 'radar',
bounds: US,
alpha: 0.8,
view: { lon: -95, lat: 37.5, height: 6_000_000 },
noonUtc: 18,
},
{
id: 'lightning',
kind: 'weather',
product: 'lightning',
bounds: US,
alpha: 0.8,
view: { lon: -95, lat: 37.5, height: 6_000_000 },
noonUtc: 18,
},
{
id: 'clouds-regional',
kind: 'weather',
product: 'clouds-regional',
bounds: US,
alpha: 0.7,
view: { lon: -95, lat: 37.5, height: 6_000_000 },
noonUtc: 18,
},
{
id: 'clouds-global',
kind: 'weather',
product: 'clouds',
bounds: GLOBE,
alpha: 0.7,
view: { lon: -40, lat: 15, height: 14_000_000 },
noonUtc: 14.5,
},
{
id: 'wind',
kind: 'wind',
view: { lon: -40, lat: 20, height: 12_000_000 },
noonUtc: 14.5,
},
{
id: 'nepal-split',
kind: 'nepal',
view: { lon: 85.337, lat: 28.2135, height: 22_000 },
noonUtc: 6.5,
},
];
// The weather field at a longitude and latitude: blobs with soft edges
// (radar, lightning) or a grey sky to mask (the infrared products).
function weatherPixel(product, lon, lat, out, at) {
const v =
0.5 + 0.25 * Math.sin(lon * 0.35) + 0.25 * Math.cos(lat * 0.5 + lon * 0.1);
if (product === 'clouds' || product === 'clouds-regional') {
const grey = Math.round(255 * Math.max(0, Math.min(1, v)));
out[at] = out[at + 1] = out[at + 2] = grey;
out[at + 3] = 255;
return;
}
out[at] = Math.round(255 * Math.max(0, Math.min(1, v)));
out[at + 1] = Math.round(255 * Math.max(0, Math.min(1, 1 - v)));
out[at + 2] = product === 'lightning' ? 220 : 90;
out[at + 3] = Math.round(255 * Math.max(0, Math.min(1, (v - 0.45) * 4)));
}
async function weatherPng(product, box, width, height) {
const data = Buffer.alloc(width * height * 4);
for (let row = 0; row < height; row++) {
const lat = box.north - ((row + 0.5) / height) * (box.north - box.south);
for (let column = 0; column < width; column++) {
const lon = box.west + ((column + 0.5) / width) * (box.east - box.west);
weatherPixel(product, lon, lat, data, (row * width + column) * 4);
}
}
return sharp(data, { raw: { width, height, channels: 4 } })
.png()
.toBuffer();
}
const LARGEST = {
radar: [4096, 2048],
lightning: [4096, 2048],
'clouds-regional': [4096, 2048],
clouds: [2048, 1024],
};
const pngCache = new Map();
// One generated /api/weather/tile or /api/weather/image response, or null.
async function weatherResponse(url) {
const key = url.pathname + url.search;
if (pngCache.has(key)) return pngCache.get(key);
const query = url.searchParams;
const product = query.get('product');
const scene = SCENES.find((entry) => entry.product === product);
if (!scene) return null;
let png;
if (url.pathname === '/api/weather/tile') {
const z = +query.get('z');
const x = +query.get('x');
const y = +query.get('y');
const span = 180 / 2 ** z;
const size = +(query.get('size') ?? 256);
png = await weatherPng(
product,
{
west: -180 + x * span,
east: -180 + (x + 1) * span,
north: 90 - y * span,
south: 90 - (y + 1) * span,
},
size,
size,
);
} else {
const bbox = query.get('bbox');
const [west, south, east, north] = bbox
? bbox.split(',').map(Number)
: [
scene.bounds.west,
scene.bounds.south,
scene.bounds.east,
scene.bounds.north,
];
const [width, height] = query.get('size')
? query.get('size').split('x').map(Number)
: bbox
? [4096, 2048]
: LARGEST[product];
png = await weatherPng(
product,
{ west, south, east, north },
width,
height,
);
}
pngCache.set(key, png);
return png;
}
// A fixed 5° wind field: westerlies, trades and a jet.
function windSnapshot() {
const nx = 72;
const ny = 37;
const u = new Float32Array(nx * ny);
const v = new Float32Array(nx * ny);
for (let j = 0; j < ny; j++) {
const lat = 90 - j * 5;
for (let i = 0; i < nx; i++) {
const lon = i * 5;
u[j * nx + i] =
14 * Math.cos((lat * Math.PI) / 60) +
6 * Math.sin((lon * Math.PI) / 45);
v[j * nx + i] = 5 * Math.sin(((lon + lat) * Math.PI) / 50);
}
}
return {
model: 'gfs',
level: '10m',
units: 'm/s',
cycle: { runIso: TIME, validIso: TIME, forecastHour: 0 },
grid: { nx, ny, lo1: 0, la1: 90, dx: 5, dy: 5 },
u: Array.from(u),
v: Array.from(v),
};
}
async function shootScene(browser, origin, kind, scene) {
const page = await browser.newPage();
await page.setViewport(SIZE);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.setRequestInterception(true);
let requests = 0;
page.on('request', async (request) => {
const url = new URL(request.url());
if (url.pathname.startsWith('/api/weather/')) {
requests++;
const png = await weatherResponse(url).catch(() => null);
if (png)
await request.respond({
status: 200,
contentType: 'image/png',
body: png,
});
else await request.respond({ status: 404, body: '' });
} else if (BLOCKED_HOSTS.test(url.hostname)) request.abort();
else request.continue();
});
const { lon, lat, height } = scene.view;
await page.goto(
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${lat}&lon=${lon}&alt=${height}&heading=0&pitch=-90&map=osm&l=`,
);
await page.waitForFunction(
(expected) =>
window.__godsEyeView?.engine?.kind === expected &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 300_000, polling: 250 },
kind,
);
await page.waitForFunction(
() => !window.__godsEyeView.engine.camera.flightActive(),
{ timeout: 60_000, polling: 250 },
);
const version = await page.evaluate(
async ({ scene, time, wind }) => {
const { engine } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
const { appClock } = await import('/src/time/clock.js');
appClock.freeze(Date.UTC(2026, 10, 7) + scene.noonUtc * 3_600_000);
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(
scene.view.lon,
scene.view.lat,
scene.view.height,
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const frames = (count) =>
new Promise((resolve) => {
let left = count;
const remove = engine.frames.postRender.addEventListener(() => {
if (--left > 0) {
setTimeout(() => engine.render.request('qa-weather'), 0);
return;
}
remove();
resolve();
});
engine.render.request('qa-weather');
});
const tryImport = async (url) => {
try {
return await import(url);
} catch {
return null;
}
};
const viewer = engine.extensions?.cesium?.viewer ?? null;
const native = engine.extensions?.navara ?? null;
const cesium = viewer ? await import('/qa-cesium.js') : null;
const qa = { frames };
window.__qaWeather = qa;
if (scene.kind === 'weather') {
const snapshot = {
schemaVersion: 1,
product: scene.product,
bounds: scene.bounds,
times: [time],
latest: time,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
};
const display = await tryImport(
'/src/layers/weather/render/display.js',
);
let renderer;
let used;
if (display) {
renderer = display.createWeatherDisplay(engine, {});
used = 'display';
} else if (viewer) {
const { createWeatherRendering } =
await import('/src/layers/weather/rendering.js');
renderer = createWeatherRendering({
viewer,
cesium,
getHost: () => ({
collection: viewer.imageryLayers,
kind: 'globe',
}),
});
used = 'cesium';
} else {
const { createWeatherNavaraRenderer } =
await import('/src/layers/weather/render/navara.js');
renderer = createWeatherNavaraRenderer(engine, native, {
getHost: () => ({ kind: 'globe', collection: engine }),
});
used = 'navara';
}
renderer.setAlpha(scene.alpha);
qa.show = () =>
renderer.setFrame(snapshot, time, {
infrared: scene.product.startsWith('clouds') ? 100 : 'filtered',
});
qa.state = () => renderer.getDiagnostics();
return used;
}
if (scene.kind === 'wind') {
const display = await tryImport('/src/layers/wind/render/display.js');
const { createWindRendering } =
await import('/src/layers/wind/rendering.js');
const cesiumFlow = (options = {}) =>
createWindRendering({
cesium,
container: viewer.container,
getViewer: () => viewer,
getHost: () => ({
collection: viewer.imageryLayers,
kind: 'globe',
}),
...options,
});
let renderer;
let used;
if (display) {
const navaraFlow = native
? (await import('/src/layers/wind/render/navaraFlow.js'))
.createWindNavaraFlow
: null;
renderer = display.createWindDisplay(engine, {
flow: viewer
? () => cesiumFlow({ field: false })
: (e) => navaraFlow(e, native),
});
used = 'display';
} else if (viewer) {
renderer = cesiumFlow();
used = 'cesium';
} else {
const { createWindNavaraRenderer } =
await import('/src/layers/wind/render/navara.js');
renderer = createWindNavaraRenderer(engine, native);
used = 'navara';
}
renderer.attach();
renderer.setOptions({ overlay: 'none', paused: true });
renderer.setField(wind);
renderer.start();
qa.show = () => renderer.setOptions({ overlay: 'speed', paused: true });
qa.state = () => renderer.getDiagnostics();
return used;
}
// The Bhote Koshi before and after images, split at the middle.
const box = {
west: 85.295,
south: 28.135,
east: 85.379,
north: 28.292,
};
const urls = [
'/events/bhote-koshi-2026/pre.webp',
'/events/bhote-koshi-2026/post.webp',
].map((url) => new URL(url, location.href).href);
const display = await tryImport(
'/src/layers/bhoteKoshi/render/display.js',
);
if (display) {
const renderer = display.createBhoteKoshiDisplay(engine);
qa.show = async () => {
await renderer.loadImages(box, urls);
renderer.setSplit(0.5);
};
return 'display';
}
const Cesium = cesium ?? (await import('/qa-cesium.js'));
const rectangle = Cesium.Rectangle.fromDegrees(
box.west,
box.south,
box.east,
box.north,
);
if (viewer) {
qa.show = async () => {
const providers = await Promise.all(
urls.map((url) =>
Cesium.SingleTileImageryProvider.fromUrl(url, { rectangle }),
),
);
providers.forEach((provider, index) => {
const layer = viewer.imageryLayers.addImageryProvider(provider);
layer.splitDirection = index
? Cesium.SplitDirection.RIGHT
: Cesium.SplitDirection.LEFT;
layer.alpha = 0.96;
});
viewer.scene.splitPosition = 0.5;
};
return 'cesium';
}
const { createNepalEventDisplay } =
await import('/src/layers/bhoteKoshi/render/navara.js');
const renderer = createNepalEventDisplay(engine, native);
qa.show = async () => {
const providers = await Promise.all(
urls.map((url) =>
Cesium.SingleTileImageryProvider.fromUrl(url, { rectangle }),
),
);
await renderer.loadImages(box, providers);
renderer.setSplit(0.5);
};
return 'navara';
},
{ scene, time: TIME, wind: windSnapshot() },
);
// Settle: the map tiles loaded, then requests quiet for 2 s.
const settle = async () => {
for (let round = 0; round < 120; round++) {
const before = requests;
await page.evaluate(() => window.__qaWeather.frames(10));
await new Promise((resolve) => setTimeout(resolve, 500));
const loaded = await page.evaluate(() =>
window.__godsEyeView.engine.surface.tilesLoaded(),
);
if (loaded && requests === before && round >= 4) break;
}
await page.evaluate(() => window.__qaWeather.frames(20));
};
const read = () =>
page.evaluate(() => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const scratch = document.createElement('canvas');
scratch.width = canvas.clientWidth;
scratch.height = canvas.clientHeight;
const context = scratch.getContext('2d');
context.drawImage(canvas, 0, 0, scratch.width, scratch.height);
return scratch.toDataURL('image/png');
});
await settle();
const without = await read();
const shown = await page.evaluate(async () => {
const result = await window.__qaWeather.show();
return result === undefined ? null : result;
});
await settle();
const withRaster = await read();
const state = await page.evaluate(() => {
try {
return JSON.parse(JSON.stringify(window.__qaWeather.state?.() ?? null));
} catch {
return null;
}
});
await page.close();
return { without, with: withRaster, errors, version, shown, state };
}
async function shoot(origin, out) {
fs.mkdirSync(out, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
protocolTimeout: 600_000,
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
});
const engines = (option('engine') ?? 'cesium,navara').split(',');
const only = option('only')?.split(',') ?? null;
try {
for (const kind of engines)
for (const scene of SCENES) {
if (only && !only.includes(scene.id)) continue;
const shot = await shootScene(browser, origin, kind, scene);
for (const name of ['without', 'with'])
fs.writeFileSync(
path.join(out, `${kind}-${scene.id}-${name}.png`),
Buffer.from(shot[name].split(',')[1], 'base64'),
);
console.log(
`${kind} ${scene.id}: ${shot.version}, shown ${JSON.stringify(shot.shown)}, error ${shot.state?.error ?? shot.state?.imageryError ?? null}${shot.errors.length ? ` (page errors: ${shot.errors.join('; ')})` : ''}`,
);
}
} finally {
await browser.close();
}
}
const raw = async (file) =>
(await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }))
.data;
// Where the raster changed the frame (any channel by more than 6).
function overlayMask(without, withRaster) {
const mask = new Uint8Array(without.length / 4);
for (let p = 0; p < mask.length; p++)
for (let k = 0; k < 3; k++)
if (Math.abs(without[p * 4 + k] - withRaster[p * 4 + k]) > 6) {
mask[p] = 1;
break;
}
return mask;
}
// Per-pixel differences of two frames inside `mask` (or everywhere).
function difference(a, b, mask) {
let count = 0,
sum = 0,
over8 = 0,
over24 = 0,
max = 0;
for (let p = 0; p < a.length / 4; p++) {
if (mask && !mask[p]) continue;
let worst = 0;
for (let k = 0; k < 3; k++)
worst = Math.max(worst, Math.abs(a[p * 4 + k] - b[p * 4 + k]));
count++;
sum += worst;
if (worst > 8) over8++;
if (worst > 24) over24++;
max = Math.max(max, worst);
}
return {
pixels: count,
meanMaxChannel: count ? +(sum / count).toFixed(2) : 0,
over8: count ? +((100 * over8) / count).toFixed(2) : 0,
over24: count ? +((100 * over24) / count).toFixed(2) : 0,
max,
};
}
// The raster's own change of the frame: with minus without, per channel.
function contribution(without, withRaster) {
const out = new Int16Array(without.length);
for (let i = 0; i < without.length; i++) out[i] = withRaster[i] - without[i];
return out;
}
const shift = (values) =>
Uint8Array.from(values, (value) => Math.max(0, Math.min(255, value + 128)));
async function compare(beforeDir, afterDir) {
const report = {};
const engines = ['cesium', 'navara'];
for (const scene of SCENES) {
const row = (report[scene.id] = {});
const loaded = {};
for (const engine of engines)
for (const [version, dir] of [
['before', beforeDir],
['after', afterDir],
])
for (const name of ['without', 'with']) {
const file = path.join(dir, `${engine}-${scene.id}-${name}.png`);
if (fs.existsSync(file))
loaded[`${engine}-${version}-${name}`] = await raw(file);
}
for (const engine of engines) {
const before = loaded[`${engine}-before-with`];
const after = loaded[`${engine}-after-with`];
if (!before || !after) continue;
const mask = overlayMask(loaded[`${engine}-before-without`], before);
row[`${engine}: before vs after, where the raster draws`] = difference(
before,
after,
mask,
);
// The raster's own change, before and after: the base map drawn
// differently between runs (tiles, lighting) cancels out.
row[`${engine}: the raster's change, before vs after`] = difference(
shift(contribution(loaded[`${engine}-before-without`], before)),
shift(contribution(loaded[`${engine}-after-without`], after)),
mask,
);
row[`${engine}: base map before vs after`] = difference(
loaded[`${engine}-before-without`],
loaded[`${engine}-after-without`],
);
}
// Navara against Cesium, where Cesium's raster draws: each engine's
// change of its own frame (the base maps are drawn differently).
for (const version of ['before', 'after']) {
const reference = loaded[`cesium-${version}-with`];
const navara = loaded[`navara-${version}-with`];
if (!reference || !navara) continue;
const mask = overlayMask(loaded[`cesium-${version}-without`], reference);
row[`navara vs cesium ${version}: the raster's change`] = difference(
shift(contribution(loaded[`cesium-${version}-without`], reference)),
shift(contribution(loaded[`navara-${version}-without`], navara)),
mask,
);
}
}
return report;
}
if (command === 'shoot' && first) {
await shoot(first, option('out') ?? 'qa-weather');
} else if (command === 'compare' && first && second) {
const report = await compare(first, second);
const json = option('json');
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
for (const [scene, rows] of Object.entries(report)) {
console.log(scene);
for (const [label, value] of Object.entries(rows))
console.log(
` ${label}: ${value.pixels} px, mean ${value.meanMaxChannel}, >8 ${value.over8}%, >24 ${value.over24}%, max ${value.max}`,
);
}
} else {
console.error(
'Usage: node scripts/qa-weather-compare.mjs shoot <origin> [--out=DIR] [--engine=cesium,navara] [--only=scene,...]\n' +
' node scripts/qa-weather-compare.mjs compare <before> <after> [--json=FILE]',
);
process.exit(2);
}
+2 -1
View File
@@ -120,7 +120,8 @@ export const ENGINE_COUPLING_METRICS = Object.freeze([
measure: (files) =>
files.reduce(
(sum, { text }) =>
sum + countMatches(text, /\bdefineGroundEffect\(\s*\{/),
// Calls only: the helper's own declaration is not a pass.
sum + countMatches(text, /(?<!function )\bdefineGroundEffect\(\s*\{/),
0,
),
},
+2 -1
View File
@@ -386,7 +386,8 @@ export function createApplicationCatalog({
createWindLayer({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine) => rendererFor('wind', engine),
createNativeRendering: (engine, options) =>
rendererFor('wind', engine, options),
}),
),
],
+6
View File
@@ -31,3 +31,9 @@ export { createTrafficDisplay } from '../../layers/traffic/render/display.js';
export { createDirectionsDisplay } from '../../layers/directions/render/display.js';
export { createFirmsDisplay } from '../../layers/firms/render/display.js';
export { createRecentImageryDisplay } from '../../layers/recentImagery/render/display.js';
export { createWeatherDisplay } from '../../layers/weather/render/display.js';
export { createWindDisplay } from '../../layers/wind/render/display.js';
export {
createBhoteKoshiDisplay,
createBhoteKoshiLocatorDisplay,
} from '../../layers/bhoteKoshi/render/display.js';
+34 -33
View File
@@ -20,9 +20,8 @@
// contract primitives in `needs` (Wildlife Sightings).
// - `perEngine({ [kind]: loader | DRAWS_ITSELF })`: a layer with no display
// yet. On each engine kind listed it either draws itself (DRAWS_ITSELF,
// the Cesium weather and wind, the open map overlays) or has an
// engine-specific renderer, an import() loader of its factory (Navara's
// weather, wind and Bhote Koshi). It shows only on the kinds listed.
// the open map overlays) or has an engine-specific renderer, an import()
// loader of its factory. It shows only on the kinds listed.
//
// Every import() here is resolved by `loadLayerRenderers(kind)`, which the
// page awaits before it builds an engine's catalog (src/standalone/
@@ -119,34 +118,39 @@ const perEngine = (engines) =>
/** Navara's own view and classes, for the renderers that still use them. */
const navaraNative = (engine) => engine.extensions.navara;
// Navara's renderers of the layers without a display. Each loads only here,
// through import(), when a page loads Navara's renderers.
const navaraWeather = async () => {
const { createWeatherNavaraRenderer } =
await import('../../layers/weather/render/navara.js');
return (engine, options) =>
createWeatherNavaraRenderer(engine, navaraNative(engine), options);
// Navara's backends of the Bhote Koshi and wind displays, loaded only
// here, through import(), when a page loads Navara's renderers: the story
// entities sampled into Navara's view (Cesium draws them itself) and the
// wind's flow mesh.
const navaraStory = async () => {
const { drawStorySource } =
await import('../../layers/shared/render/navaraStory.js');
return {
drawStory: (engine, data) =>
drawStorySource(engine, navaraNative(engine), data),
};
};
const navaraWindFlow = async () => {
const { createWindNavaraFlow } =
await import('../../layers/wind/render/navaraFlow.js');
return {
flow: (engine) => createWindNavaraFlow(engine, navaraNative(engine)),
};
};
const navaraOnly = (navara, others = DRAWS_ITSELF) =>
perEngine({ cesium: others, navara });
// The layers' displays (./displays.js), one chunk.
const displays = () => import('./displays.js');
/** How every application layer draws, by id, in directory order. */
export const LAYER_MANIFEST = Object.freeze({
// The Bhote Koshi event and locator draw themselves on Cesium
// (src/data/bhoteKoshiEvent.js, bhoteKoshiLocator.js).
'bhote-koshi-2026': navaraOnly(async () => {
const { createNepalEventDisplay } =
await import('../../layers/bhoteKoshi/render/navara.js');
return (engine) => createNepalEventDisplay(engine, navaraNative(engine));
// The Bhote Koshi event's imagery on engine.imagery; the event's and the
// locator's story entities draw on Cesium itself, or through Navara's
// story backend.
'bhote-koshi-2026': display('createBhoteKoshiDisplay', ['imagery'], {
navara: navaraStory,
}),
'bhote-koshi-locator': navaraOnly(async () => {
const { drawStorySource } =
await import('../../layers/shared/render/navaraStory.js');
return (engine, { data }) =>
drawStorySource(engine, navaraNative(engine), data);
'bhote-koshi-locator': display('createBhoteKoshiLocatorDisplay', ['movers'], {
navara: navaraStory,
}),
// The aircraft family.
flights: display('createFlightsDisplay', ['movers', 'lines']),
@@ -196,16 +200,13 @@ export const LAYER_MANIFEST = Object.freeze({
).createAwarenessPulseDisc,
}),
}),
// Wind and the weather rasters draw themselves on Cesium
// (src/layers/wind, src/layers/weather); on Navara through its renderers.
wind: navaraOnly(async () => {
const { createWindNavaraRenderer } =
await import('../../layers/wind/render/navara.js');
return (engine) => createWindNavaraRenderer(engine, navaraNative(engine));
}),
'weather-radar': navaraOnly(navaraWeather),
'weather-satellite': navaraOnly(navaraWeather),
'weather-lightning': navaraOnly(navaraWeather),
// The wind field and the weather rasters on engine.imagery; the wind's
// streamlines are each engine's (Cesium's from the layer, Navara's flow
// mesh).
wind: display('createWindDisplay', ['imagery'], { navara: navaraWindFlow }),
'weather-radar': display('createWeatherDisplay', ['imagery']),
'weather-satellite': display('createWeatherDisplay', ['imagery']),
'weather-lightning': display('createWeatherDisplay', ['imagery']),
'weather-cyclones': display('createCyclonesDisplay', ['movers', 'ground']),
// The open map overlays drape themselves: Cesium imagery on the viewer,
// engine.maps.addOverlay on Navara (src/data/osmOverlays.js).
+20 -16
View File
@@ -51,6 +51,8 @@ test('the layers drawn through one display, and the ones that draw without one',
assert.deepEqual(
kinds((entry) => entry.display),
[
'bhote-koshi-2026',
'bhote-koshi-locator',
'flights',
'military',
'local-adsb',
@@ -75,6 +77,10 @@ test('the layers drawn through one display, and the ones that draw without one',
'ais-live-vessels',
'military-installations',
'military-awareness',
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'local-datacenters',
'local-dams',
@@ -85,7 +91,13 @@ test('the layers drawn through one display, and the ones that draw without one',
// Engine-specific options of a display: the Navara backends.
assert.deepEqual(
kinds((entry) => Object.keys(entry.backends ?? {}).length),
['cctv', 'military-awareness'],
[
'bhote-koshi-2026',
'bhote-koshi-locator',
'cctv',
'military-awareness',
'wind',
],
);
assert.deepEqual(Object.keys(LAYER_MANIFEST.cctv.backends), ['navara']);
// The layer that draws itself through the contract.
@@ -106,14 +118,7 @@ test('the layers drawn through one display, and the ones that draw without one',
),
]),
);
const navaraRenderer = { cesium: 'itself', navara: 'renderer' };
assert.deepEqual(engines, {
'bhote-koshi-2026': navaraRenderer,
'bhote-koshi-locator': navaraRenderer,
wind: navaraRenderer,
'weather-radar': navaraRenderer,
'weather-satellite': navaraRenderer,
'weather-lightning': navaraRenderer,
'sea-marks': { cesium: 'itself', navara: 'itself' },
'ski-pistes': { cesium: 'itself', navara: 'itself' },
});
@@ -138,6 +143,8 @@ test('a display or an engine-specific renderer answers only after its engine loa
test('the layers drawn through one display get the same factory on every engine', async () => {
const displays = {
'bhote-koshi-2026': ['bhoteKoshi', 'createBhoteKoshiDisplay'],
'bhote-koshi-locator': ['bhoteKoshi', 'createBhoteKoshiLocatorDisplay'],
flights: ['flights', 'createFlightsDisplay'],
military: ['military', 'createMilitaryDisplay'],
'local-adsb': ['localAdsb', 'createLocalAdsbDisplay'],
@@ -166,6 +173,10 @@ test('the layers drawn through one display get the same factory on every engine'
directions: ['directions', 'createDirectionsDisplay'],
'local-firms': ['firms', 'createFirmsDisplay'],
'recent-imagery': ['recentImagery', 'createRecentImageryDisplay'],
wind: ['wind', 'createWindDisplay'],
'weather-radar': ['weather', 'createWeatherDisplay'],
'weather-satellite': ['weather', 'createWeatherDisplay'],
'weather-lightning': ['weather', 'createWeatherDisplay'],
'telegeography-submarine-cables': [
'submarineCables',
'createCablesDisplay',
@@ -199,14 +210,7 @@ test('the layers drawn through one display get the same factory on every engine'
test('a layer that draws itself has no renderer; an unknown engine none either', () => {
const engine = createFakeEngine();
for (const id of [
'wildlife-sightings',
'sea-marks',
'ski-pistes',
'wind',
'weather-radar',
'bhote-koshi-2026',
])
for (const id of ['wildlife-sightings', 'sea-marks', 'ski-pistes'])
assert.equal(rendererFor(id, engine), null, id);
assert.equal(rendererFor('flights', null), null);
assert.equal(rendererFor('flights', { kind: 'vulkan' }), null);
+2 -2
View File
@@ -33,7 +33,7 @@ test('Navara loads its renderers once and shows every layer', async () => {
for (const id of ['wildlife-sightings', 'sea-marks', 'ski-pistes'])
assert.equal(rendererFor(id, engine), null, id);
assert.throws(() => loadLayerRenderers('vulkan'), TypeError);
// Cesium has nothing to load.
// Cesium loads the same displays: wind draws through one there too.
await loadLayerRenderers('cesium');
assert.equal(rendererFor('wind', createFakeEngine()), null);
assert.equal(typeof rendererFor('wind', createFakeEngine()), 'object');
});
+20 -13
View File
@@ -1181,6 +1181,19 @@ export function createBhoteKoshiEventLayer({
}
async function createImagery(event, current = () => true) {
if (_native) {
// The engine's display draws the images through engine.imagery.
await _native.loadImages(
event.imagery.rectangle,
[
eventAsset(event.imagery.before.path),
eventAsset(event.imagery.after.path),
],
'Vantor WorldView Open Data · CC BY-NC 4.0',
);
if (_enabled && current()) setSplit(_split);
return;
}
const rectangle = Cesium.Rectangle.fromDegrees(
event.imagery.rectangle.west,
event.imagery.rectangle.south,
@@ -1198,14 +1211,6 @@ export function createBhoteKoshiEventLayer({
}),
]));
if (!_enabled || !current()) return;
if (_native) {
await _native.loadImages(event.imagery.rectangle, [
beforeProvider,
afterProvider,
]);
if (current()) setSplit(_split);
return;
}
_imageryLayerCollection = _viewer.imageryLayers;
_beforeLayer = _imageryLayerCollection.addImageryProvider(beforeProvider);
_afterLayer = _imageryLayerCollection.addImageryProvider(afterProvider);
@@ -2628,8 +2633,9 @@ export function createBhoteKoshiEventLayer({
disableDepthTestDistance: tileSafe ? Number.POSITIVE_INFINITY : 0,
},
});
if (_native) _nativeFlood = _native.draw(_floodDataSource);
else _viewer.dataSources.add(_floodDataSource);
// The engine's story backend, or Cesium's data sources.
_nativeFlood = _native?.draw(_floodDataSource) ?? null;
if (!_nativeFlood) _viewer.dataSources.add(_floodDataSource);
}
function syncFloodSurfaceMode() {
@@ -3539,7 +3545,8 @@ export function createBhoteKoshiEventLayer({
const stack = _mapStackController;
_viewer = viewer;
_engine = engine;
if (!_viewer && _engine) _native = createNativeRenderer(engine);
// The engine's display (src/layers/bhoteKoshi/render/display.js).
if (_engine) _native = createNativeRenderer?.(engine) ?? null;
_embeddedMedia?.attachScene?.(viewer, engine);
if (!_enabled) return;
// Its enable has loaded the SDK; a restored attachment waits for it too.
@@ -3565,8 +3572,8 @@ export function createBhoteKoshiEventLayer({
await untilSceneReleased(createImagery(_event, current), signal);
if (!current()) return;
if (_floodDataSource) {
if (_native) _nativeFlood = _native.draw(_floodDataSource);
else await _viewer.dataSources.add(_floodDataSource);
_nativeFlood = _native?.draw(_floodDataSource) ?? null;
if (!_nativeFlood) await _viewer.dataSources.add(_floodDataSource);
if (!current()) return;
}
registerDynamicCredit(engine, BHOTE_KOSHI_CREDIT);
+488 -46
View File
@@ -4,19 +4,33 @@
// src/maps/imageryHost.js resolves it for the shell. Layers sit above the
// base imagery, in their order among the engine's layers (higher on top,
// then creation); imagery added by others stays where it was put. A tiles
// source is a UrlTemplateImageryProvider in Web Mercator, an image source a
// one-tile provider over its rectangle (as SingleTileImageryProvider, but
// from the canvas or image itself, without a PNG round trip). The engine
// follows the surface on every frame while it holds a layer: on a switch
// each layer is built again on the new stack, never re-parented.
// source is a UrlTemplateImageryProvider in Web Mercator or geographic
// tiling (its transform applied to each tile as it arrives), an image
// source a one-tile provider over its rectangle (as
// SingleTileImageryProvider, but from the canvas or image itself, without a
// PNG round trip). The engine follows the surface on every frame while it
// holds a layer: on a switch each layer is built again on the new stack,
// never re-parented. A staged source is a second layer at alpha 0 right
// over the drawn one, whose tiles Cesium loads while the drawn one shows;
// it takes the drawn one's place once its own requests are in and quiet.
// A raised layer is a shell primitive instead (imageryShell.js), on every
// surface.
import * as Cesium from 'cesium';
import { createImageryRegistry, isPixelImage } from '../imagery.js';
import { createImageryShell } from './imageryShell.js';
const SPLIT = { none: 'NONE', left: 'LEFT', right: 'RIGHT' };
const NO_HOST = Object.freeze({ collection: null, surface: 'none' });
/** Delay before the one frame that retries tiles a provider deferred. */
export const TILE_RETRY_DELAY_MS = 250;
/** How often a staged source waiting out its quiet interval rechecks readiness. */
export const STAGE_WAKE_MS = 150;
// A staged source's own tiles are in once no request was made or settled
// for this long; two frames in a row must find it so.
const STAGE_QUIET_MS = 200;
// Retries of a staged tile the server throttled (429, 503).
const STAGE_RETRIES = 3;
const rectangleOf = ({ west, south, east, north }) =>
Cesium.Rectangle.fromDegrees(west, south, east, north);
@@ -35,6 +49,20 @@ function drawable(image, createCanvas) {
return canvas;
}
// A decoded tile with row 0 on the north edge, as a transform takes it:
// Cesium decodes tiles as ImageBitmaps already flipped (WebGL ignores
// UNPACK_FLIP_Y for them), so those are drawn upright on a canvas, which
// Cesium flips again as it uploads.
function upright(image, createCanvas) {
if (typeof ImageBitmap === 'undefined' || !(image instanceof ImageBitmap))
return image;
const canvas = createCanvas(image.width, image.height);
const context = canvas.getContext('2d');
context.setTransform(1, 0, 0, -1, 0, image.height);
context.drawImage(image, 0, 0);
return canvas;
}
// One image over `rectangle`: SingleTileImageryProvider's tiling (one
// geographic tile at level 0), handing Cesium the image itself.
function singleImageProvider(image, rectangle, credit) {
@@ -51,7 +79,7 @@ function singleImageProvider(image, rectangle, credit) {
minimumLevel: 0,
maximumLevel: 0,
tileDiscardPolicy: undefined,
credit: credit ? new Cesium.Credit(credit) : undefined,
credit,
errorEvent: new Cesium.Event(),
hasAlphaChannel: true,
proxy: undefined,
@@ -104,6 +132,7 @@ const defaultFactories = Object.freeze({
tiles: (options) => new Cesium.UrlTemplateImageryProvider(options),
layer: (provider, options) => new Cesium.ImageryLayer(provider, options),
loadImage: (url) => Cesium.Resource.fetchImage({ url }),
shell: createImageryShell,
});
/**
@@ -114,7 +143,7 @@ const defaultFactories = Object.freeze({
* `tilesets` are the 3D tilesets the map may show (maps.js); a shown one
* with imagery layers hosts the imagery while the globe is hidden.
* `createCanvas`, `factories` (the tile provider, the layer, the image
* load) and the timers are test seams.
* load, a raised layer's shell) and the timers are test seams.
* @returns {{ imagery: import('../contract.js').GlobeImagery, destroy: () => void }}
*/
export function createCesiumImagery(
@@ -132,6 +161,7 @@ export function createCesiumImagery(
const { scene } = viewer;
const entries = new Set();
let removeWatch = null;
const now = () => globalThis.performance?.now() ?? Date.now();
function host() {
if (scene.globe?.show === true && viewer.imageryLayers)
@@ -174,39 +204,73 @@ export function createCesiumImagery(
collection.add(layer, index);
}
function providerFor(entry, source) {
const creditOf = (html) => (html ? new Cesium.Credit(html) : undefined);
// Each tile through the source's transform, upright.
function transformTiles(provider, transform) {
const original = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
const result = original(x, y, level, request);
return result
? Promise.resolve(result).then((image) =>
transform(upright(image, createCanvas)),
)
: result;
};
return provider;
}
// `drawing` is the entry or a staged job ({ credit, retry, image }).
function providerFor(drawing, source) {
let provider;
if (source.kind === 'tiles') {
const provider = make.tiles({
provider = make.tiles({
url: source.url,
subdomains: [...source.subdomains],
minimumLevel: source.minLevel,
maximumLevel: source.maxLevel,
tileWidth: source.tileSize,
tileHeight: source.tileSize,
tilingScheme: new Cesium.WebMercatorTilingScheme(),
tilingScheme:
source.scheme === 'geographic'
? new Cesium.GeographicTilingScheme()
: new Cesium.WebMercatorTilingScheme(),
rectangle: rectangleOf(source.rectangle),
hasAlphaChannel: !source.opaque,
enablePickFeatures: false,
...(source.credit ? { credit: source.credit } : {}),
});
return source.maxRequests
? admitTileRequests(
provider,
{ limit: source.maxRequests, inFlight: 0 },
requestRender,
entry.retry,
)
: provider;
}
return singleImageProvider(
drawable(entry.image, createCanvas),
rectangleOf(source.rectangle),
source.credit,
);
if (source.transform) transformTiles(provider, source.transform);
if (source.maxRequests)
admitTileRequests(
provider,
{ limit: source.maxRequests, inFlight: 0 },
requestRender,
drawing.retry,
);
} else
provider = singleImageProvider(
drawable(drawing.image, createCanvas),
rectangleOf(source.rectangle),
undefined,
);
// Cesium reads a provider's credit every frame: a new credit of the same
// source replaces it without a new layer.
Object.defineProperty(provider, 'credit', {
configurable: true,
enumerable: true,
get: () => drawing.credit,
});
return provider;
}
function applyLook(entry) {
const { layer, state } = entry;
const { layer, state, shell } = entry;
if (shell) {
shell.setAlpha(state.alpha);
shell.setShow(state.shown);
return;
}
if (!layer) return;
layer.alpha = state.alpha;
layer.show = state.shown;
@@ -214,11 +278,89 @@ export function createCesiumImagery(
Cesium.SplitDirection?.[SPLIT[state.split]] ?? layer.splitDirection;
}
// A raised layer's place among the shells: the engine's draw order.
const rankOf = (entry) => entry.state.options.order * 1e6 + entry.state.seq;
// A raised layer's detail rectangle in its image's texture coordinates.
function detailWindow(source, detail) {
const box = source.rectangle;
const east = box.east <= box.west ? box.east + 360 : box.east;
const x = east - box.west;
const y = box.north - box.south;
const r = detail.rectangle;
return {
west: (r.west - box.west) / x,
south: (r.south - box.south) / y,
east: (r.east - box.west) / x,
north: (r.north - box.south) / y,
};
}
// A raised layer on its shell: an image again on the same rectangle
// (Cesium draws the last texture until it is uploaded), else a new shell
// in place of the last. With `onDrawn` (a staged source) a shell on
// another rectangle waits unseen until it has drawn, then replaces the
// drawn one, and `onDrawn` runs once the image has drawn.
function mountShell(entry, source, decoded, onDrawn = null) {
const image = drawable(decoded, createCanvas);
const same = (box) =>
['west', 'south', 'east', 'north'].every(
(edge) => box[edge] === source.rectangle[edge],
);
let shell = entry.shell;
if (shell && same(shell.rectangle)) shell.setImage(image);
else {
shell = make.shell({
scene,
rectangle: source.rectangle,
height: entry.state.options.raise,
rank: rankOf(entry),
onSettled: () => {
const waiting = entry.shellWaiting;
if (waiting?.shell === shell && shell.drawn()) {
entry.shellWaiting = null;
waiting.done();
}
entry.detailWaiting?.check();
},
});
shell.setImage(image);
if (onDrawn && entry.shell) shell.setAlpha(0);
else {
entry.shell?.destroy();
entry.shell = shell;
}
}
const detail = entry.state.detail;
if (detail)
shell.setDetail(
drawable(detail.image, createCanvas),
detailWindow(source, detail),
);
applyLook(entry);
requestRender('imagery');
if (!onDrawn) return;
const done = () => {
if (entry.shell !== shell) {
entry.shell?.destroy();
entry.shell = shell;
applyLook(entry);
}
onDrawn();
};
if (shell.drawn()) done();
else entry.shellWaiting = { shell, done };
}
// The layer for the current source on the current host, in place of the
// one drawn (at its index, so a new source keeps its stack position).
function mount(entry) {
const { collection } = host();
const source = entry.state.source;
if (entry.state.options.raise !== null) {
if (entry.image) mountShell(entry, source, entry.image);
return;
}
const { collection } = host();
if (!usable(collection) || (source.kind === 'image' && !entry.image)) {
detach(entry);
return;
@@ -241,10 +383,25 @@ export function createCesiumImagery(
requestRender('imagery');
}
// An image source's image: now, or once its URL has loaded (null when it
// fails). `owner()` says whether the load is still wanted.
function imageOf(source, owner) {
if (source.kind !== 'image') return Promise.resolve(null);
if (source.image) return Promise.resolve(source.image);
return Promise.resolve(make.loadImage(source.url)).then(
(image) => (owner() ? image : null),
(error) => {
if (owner()) console.warn('[Imagery] image failed to load:', error);
return null;
},
);
}
// An image source: its image now, or once its URL has loaded.
function load(entry) {
const source = entry.state.source;
entry.loading = null;
entry.credit = creditOf(source.credit);
if (source.kind !== 'image' || source.image) {
entry.image = source.image;
mount(entry);
@@ -268,14 +425,190 @@ export function createCesiumImagery(
);
}
// A staged source on a draped layer: a second layer at alpha 0 right over
// the drawn one. Its requests are counted; once they are in (the globe's
// tiles all loaded, or its own quiet for a while) for two frames in a
// row, it takes the drawn one's place, which goes after the next frame.
function stageDraped(entry, job) {
const { collection } = host();
if (!usable(collection) || !entry.state.shown) {
// Nothing draws it now: it replaces the drawn one at once.
swapIn(entry, job);
return;
}
job.collection = collection;
const provider = providerFor(job, job.source);
const requestImage = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (job.closed) return undefined;
const result = requestImage(x, y, level, request);
const key = `${level}/${x}/${y}`;
job.lastActivity = now();
if (!result) {
// Scheduler admission is part of readiness, not a loaded tile.
job.deferred.add(key);
return result;
}
job.deferred.delete(key);
job.pending++;
return Promise.resolve(result)
.then((image) => {
job.retries.delete(key);
job.loaded++;
return image;
})
.finally(() => {
job.pending--;
job.lastActivity = now();
if (!job.closed) requestRender('imagery-stage');
});
};
job.offError = provider.errorEvent?.addEventListener((error) => {
if (job.closed) return;
const status = error?.error?.statusCode;
const key = `${error.level}/${error.x}/${error.y}`;
const retries = job.retries.get(key) ?? 0;
error.retry = false;
if ((status === 429 || status === 503) && retries < STAGE_RETRIES) {
job.retries.set(key, retries + 1);
error.retry = true;
return;
}
job.failed = true;
requestRender('imagery-stage');
});
job.layer = make.layer(provider, { alpha: 0, show: true });
job.layer.splitDirection =
Cesium.SplitDirection?.[SPLIT[entry.state.split]] ??
job.layer.splitDirection;
if (entry.layer && entry.collection === collection)
collection.add(job.layer, collection.indexOf(entry.layer) + 1);
else place(entry, job.layer, collection);
let settled = 0;
job.offCamera = scene.camera?.moveEnd?.addEventListener(() => {
// Tiles abandoned by the last view are no longer the source's work.
job.deferred.clear();
job.lastActivity = now();
settled = 0;
requestRender('imagery-stage');
});
job.offRender = scene.postRender?.addEventListener(() => {
if (job.closed) return;
if (job.failed) {
closeJob(job);
entry.staging = null;
job.finish(false);
return;
}
const own =
job.loaded > 0 &&
job.deferred.size === 0 &&
now() - job.lastActivity >= STAGE_QUIET_MS;
const globeIn =
host().surface === 'globe' && scene.globe?.tilesLoaded === true;
if ((globeIn || own) && job.pending === 0) {
if (++settled >= 2) swapIn(entry, job);
else requestRender('imagery-stage');
return;
}
settled = 0;
// Waiting out the quiet interval needs another check later, not a
// frame every frame.
if (job.pending === 0 && job.loaded > 0 && job.wake === null)
job.wake = after(() => {
job.wake = null;
if (!job.closed) requestRender('imagery-stage');
}, STAGE_WAKE_MS);
});
requestRender('imagery-stage');
}
function closeJob(job) {
job.closed = true;
job.offRender?.();
job.offCamera?.();
job.offError?.();
job.retry.cancel();
if (job.wake !== null) cancel(job.wake);
job.wake = null;
const { layer, collection } = job;
job.layer = null;
if (!layer) return;
if (usable(collection) && collection.contains(layer))
collection.remove(layer, true);
else if (!layer.isDestroyed?.()) layer.destroy?.();
}
// The staged source takes the drawn one's place.
function swapIn(entry, job) {
entry.staging = null;
const layer = job.layer;
job.layer = null;
closeJob(job);
entry.loading = null;
entry.image = job.image;
entry.credit = job.credit;
entry.retry.cancel();
entry.retry = job.retry;
job.finish(true);
if (!layer) {
mount(entry);
return;
}
const previous = entry.layer;
const previousCollection = entry.collection;
entry.layer = layer;
entry.collection = job.collection;
applyLook(entry);
if (previous) {
// The drawn one goes after the next frame, which draws the new one.
const off = scene.postRender?.addEventListener(() => {
off();
if (usable(previousCollection) && previousCollection.contains(previous))
previousCollection.remove(previous, true);
else if (!previous.isDestroyed?.()) previous.destroy?.();
requestRender('imagery-stage');
});
if (!off) {
if (previousCollection?.contains(previous))
previousCollection.remove(previous, true);
}
}
requestRender('imagery-stage');
}
// Follow the surface: a layer whose stack is not the current one is
// built again there (none: it waits without a layer).
// built again there (none: it waits without a layer); a staged source
// on the last stack starts again on the new one.
const follow = () => {
const { collection } = host();
for (const entry of entries)
for (const entry of entries) {
if (entry.state.options.raise !== null) continue;
if (entry.collection !== collection && (entry.layer || collection))
mount(entry);
const job = entry.staging;
if (job?.layer && job.collection !== collection) {
closeJob(job);
Object.assign(job, freshJob(job));
stageDraped(entry, job);
}
}
};
// A staged job's counters, new.
const freshJob = (job) => ({
closed: false,
layer: null,
collection: null,
pending: 0,
loaded: 0,
failed: false,
deferred: new Set(),
retries: new Map(),
lastActivity: now(),
wake: null,
retry: makeRetry(() => job.state),
});
const watch = () => {
if (!removeWatch && scene.preRender)
removeWatch = scene.preRender.addEventListener(follow);
@@ -286,45 +619,154 @@ export function createCesiumImagery(
removeWatch = null;
};
// One timer per layer: any number of provider deferrals while it is
// armed share the same single retry frame.
function makeRetry(stateOf) {
let retryTimer = null;
return {
schedule() {
if (retryTimer !== null) return;
retryTimer = after(() => {
retryTimer = null;
if (!stateOf()?.destroyed) requestRender('imagery-retry');
}, TILE_RETRY_DELAY_MS);
},
cancel() {
if (retryTimer !== null) cancel(retryTimer);
retryTimer = null;
},
};
}
function unstage(entry) {
const job = entry.staging;
entry.staging = null;
if (!job) return;
const waiting = entry.shellWaiting;
entry.shellWaiting = null;
if (waiting && waiting.shell !== entry.shell) waiting.shell.destroy();
closeJob(job);
}
const registry = createImageryRegistry(
(state) => {
let retryTimer = null;
const entry = {
state,
layer: null,
collection: null,
image: null,
loading: null,
// One timer per layer: any number of provider deferrals while it is
// armed share the same single retry frame.
retry: {
schedule() {
if (retryTimer !== null) return;
retryTimer = after(() => {
retryTimer = null;
if (!state.destroyed) requestRender('imagery-retry');
}, TILE_RETRY_DELAY_MS);
},
cancel() {
if (retryTimer !== null) cancel(retryTimer);
retryTimer = null;
},
},
credit: undefined,
shell: null,
shellWaiting: null,
detailWaiting: null,
staging: null,
retry: makeRetry(() => state),
};
entries.add(entry);
watch();
load(entry);
return {
source: () => load(entry),
source() {
unstage(entry);
load(entry);
},
credit() {
entry.credit = creditOf(state.source.credit);
requestRender('imagery-credit');
},
stage(source, finish) {
unstage(entry);
const job = {
state,
source,
finish,
image: null,
credit: creditOf(source.credit),
};
Object.assign(job, freshJob(job));
entry.staging = job;
imageOf(source, () => entry.staging === job && !state.destroyed).then(
(image) => {
if (entry.staging !== job || state.destroyed) return;
if (source.kind === 'image' && !image) {
entry.staging = null;
closeJob(job);
finish(false);
return;
}
job.image = image;
if (state.options.raise === null) {
stageDraped(entry, job);
return;
}
// A raised layer: the image on its shell, in place once it
// has drawn (a hidden shell draws nothing: at once).
const swap = () => {
if (entry.staging !== job) return;
entry.staging = null;
closeJob(job);
entry.image = image;
entry.credit = job.credit;
finish(true);
};
if (state.shown) mountShell(entry, source, image, swap);
else {
mountShell(entry, source, image);
swap();
}
},
);
},
unstage: () => unstage(entry),
detail(detail, finish) {
entry.detailWaiting = null;
const shell = entry.shell;
if (!shell) {
finish(detail !== null);
return;
}
if (!detail) {
shell.setDetail(null);
return;
}
const image = drawable(detail.image, createCanvas);
shell.setDetail(image, detailWindow(state.source, detail));
const waiting = {
check() {
if (entry.detailWaiting !== waiting) return;
if (shell.detail() !== image || !shell.drawn()) return;
entry.detailWaiting = null;
finish(true);
},
};
entry.detailWaiting = waiting;
waiting.check();
},
look() {
applyLook(entry);
if (entry.staging?.layer)
entry.staging.layer.splitDirection =
Cesium.SplitDirection?.[SPLIT[state.split]] ??
entry.staging.layer.splitDirection;
// A hidden layer stages nothing: the staged source replaces the
// drawn one at once.
if (!state.shown && entry.staging?.layer)
swapIn(entry, entry.staging);
requestRender('imagery-look');
},
ready: () => Boolean(entry.layer?.ready),
ready: () =>
state.options.raise !== null
? Boolean(entry.shell?.drawn())
: Boolean(entry.layer?.ready),
destroy() {
entries.delete(entry);
entry.loading = null;
unstage(entry);
entry.retry.cancel();
entry.shell?.destroy();
entry.shell = null;
entry.detailWaiting = null;
detach(entry);
unwatch();
requestRender('imagery-destroy');
+185
View File
@@ -343,3 +343,188 @@ test('an image URL draws once it has loaded; a newer source wins', async () => {
assert.equal(image.name, 'after');
assert.equal(layer.ready(), true);
});
test('geographic tiles take Cesium’s 2×1 tiling; a transform draws each tile', async () => {
const f = fixture();
const drawn = [];
f.imagery.createLayer({
id: 'radar',
source: tiles({
scheme: 'geographic',
transform: (image) => {
drawn.push(image);
return { masked: image };
},
}),
});
const provider = f.globe.get(0).imageryProvider;
assert.ok(
provider.options.tilingScheme instanceof Cesium.GeographicTilingScheme,
);
assert.equal(provider.options.tilingScheme.getNumberOfXTilesAtLevel(0), 2);
const request = provider.requestImage(1, 0, 0);
f.providers[0].pending[0]({ name: 'tile' });
assert.deepEqual(await request, { masked: { name: 'tile' } });
assert.deepEqual(drawn, [{ name: 'tile' }]);
});
// A fixture whose scene draws frames: postRender, a globe whose tiles load.
function stagingFixture() {
const f = fixture();
f.viewer.scene.postRender = new Cesium.Event();
f.viewer.scene.globe.tilesLoaded = true;
f.render = () => f.viewer.scene.postRender.raiseEvent();
return f;
}
test('a staged source loads at alpha 0 over the drawn one and takes its place once its tiles are in', async () => {
const f = stagingFixture();
const layer = f.imagery.createLayer({ id: 'a', source: tiles(), alpha: 0.7 });
const drawn = f.globe.get(0);
const staged = layer.setSource(
tiles({ url: 'https://next.example/{z}/{x}/{y}.png' }),
{ staged: true },
);
let swapped;
staged.then((value) => (swapped = value));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.globe.length, 2);
const incoming = f.globe.get(1);
assert.equal(incoming.alpha, 0, 'the staged layer loads unseen');
assert.equal(drawn.alpha, 0.7, 'the drawn layer stays');
const request = incoming.imageryProvider.requestImage(0, 0, 1);
f.render();
f.render();
assert.equal(swapped, undefined, 'a request in flight holds it back');
f.providers[1].pending[0]('tile');
await request;
f.render();
f.render();
await Promise.resolve();
assert.equal(swapped, true);
assert.equal(incoming.alpha, 0.7);
assert.match(layer.source().url, /next/);
assert.equal(f.globe.length, 2, 'the drawn one goes after the next frame');
f.render();
assert.deepEqual(layersOf(f.globe), [incoming]);
assert.equal(drawn.isDestroyed(), true);
});
test('a staged source whose tile fails leaves the drawn one', async () => {
const f = stagingFixture();
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
const staged = layer.setSource(
tiles({ url: 'https://next.example/{z}/{x}/{y}.png' }),
{ staged: true },
);
await new Promise((resolve) => setImmediate(resolve));
const incoming = f.globe.get(1);
// Throttled tiles retry; a missing one fails the source.
const throttled = { x: 0, y: 0, level: 1, error: { statusCode: 429 } };
incoming.imageryProvider.errorEvent.raiseEvent(throttled);
assert.equal(throttled.retry, true);
incoming.imageryProvider.errorEvent.raiseEvent({
x: 1,
y: 0,
level: 1,
error: { statusCode: 404 },
});
f.render();
assert.equal(await staged, false);
assert.equal(f.globe.length, 1);
assert.match(layer.source().url, /gibs/);
});
test('a raised layer is a shell over its rectangle; a staged image swaps once it has drawn', async () => {
const shells = [];
const preRender = new Cesium.Event();
const viewer = {
imageryLayers: new Cesium.ImageryLayerCollection(),
scene: { globe: { show: true }, preRender },
};
const { imagery } = createCesiumImagery(viewer, {
requestRender() {},
createCanvas: (width, height) => ({
width,
height,
getContext: () => ({
createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
putImageData() {},
}),
}),
factories: {
shell(options) {
const shell = {
options,
rectangle: options.rectangle,
image: null,
alpha: 1,
shown: true,
painted: false,
destroyed: false,
setImage(image) {
shell.image = image;
shell.painted = false;
},
setAlpha: (alpha) => (shell.alpha = alpha),
setShow: (show) => (shell.shown = show),
setDetail() {},
detail: () => null,
drawn: () => shell.painted,
paint() {
shell.painted = true;
options.onSettled();
},
destroy: () => (shell.destroyed = true),
};
shells.push(shell);
return shell;
},
},
});
const image = pixels(8, 4);
const layer = imagery.createLayer({
id: 'shell',
source: { kind: 'image', image, rectangle: BOX },
raise: 6000,
alpha: 0.7,
});
assert.equal(viewer.imageryLayers.length, 0, 'no draped layer');
assert.equal(shells.length, 1);
assert.deepEqual(shells[0].options.rectangle, BOX);
assert.equal(shells[0].options.height, 6000);
assert.equal(shells[0].alpha, 0.7);
assert.equal(layer.ready(), false);
shells[0].paint();
assert.equal(layer.ready(), true);
// The same rectangle: the shell's image, swapped once it has drawn.
const next = pixels(8, 4);
const staged = layer.setSource(
{ kind: 'image', image: next, rectangle: BOX },
{ staged: true },
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(shells.length, 1);
let done;
staged.then((value) => (done = value));
await Promise.resolve();
assert.equal(done, undefined);
shells[0].paint();
assert.equal(await staged, true);
// Another rectangle: a new shell unseen until it has drawn.
const wider = { ...BOX, west: -98 };
const moved = layer.setSource(
{ kind: 'image', image: next, rectangle: wider },
{ staged: true },
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(shells.length, 2);
assert.equal(shells[1].alpha, 0);
assert.equal(shells[0].destroyed, false);
shells[1].paint();
assert.equal(await moved, true);
assert.equal(shells[0].destroyed, true);
assert.equal(shells[1].alpha, 0.7);
layer.destroy();
assert.equal(shells[1].destroyed, true);
});
+299
View File
@@ -0,0 +1,299 @@
// A raised imagery layer on Cesium (engine.imagery's `raise`): one
// rectangle primitive at a height above the ellipsoid drawing one image
// and, inside a window of it, an optional detail image. Cesium's imagery
// drapes on the globe and on 3D tiles only, so a raised layer is a
// primitive of its own: flat shaded, alpha blended in the opaque pass
// without depth writes, first in scene.primitives in the engine's draw
// order (later shells draw over earlier ones, every other primitive over
// them), never picked. It rises with the camera (imagery.js raisedLift)
// by a uniform scale about the Earth's centre, which keeps stacked shells
// in their order.
import * as Cesium from 'cesium';
import { raisedLift } from '../imagery.js';
const MATERIAL_TYPE = 'GevRaisedImagery';
// Renders after an image swap: one queues the upload, one uploads and draws, one spare.
const UPLOAD_FRAMES = 3;
// An empty detail window: the image shows everywhere.
const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
// draw nothing until the image has arrived, and sample the detail image only
// once it has arrived. The window is west, south, east, north in the image's
// texture coordinates.
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
vec4 coarse = texture(image, st);
vec2 dst = (st - window.xy) / max(window.zw - window.xy, vec2(1e-6));
float inside = float(all(greaterThanEqual(st, window.xy)) && all(lessThanEqual(st, window.zw)));
float useDetail = inside * step(1.5, float(detailDimensions.x));
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
material.diffuse = czm_gammaCorrect(c.rgb);
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
return material;
}
`;
const ranks = new WeakMap();
function registerMaterial() {
const cache = Cesium.Material._materialCache;
if (cache.getMaterial(MATERIAL_TYPE)) return;
// A plain template: building the first material from a fabric would make that
// instance Cesium's shared template and keep its last image alive.
cache.addMaterial(MATERIAL_TYPE, {
fabric: {
type: MATERIAL_TYPE,
uniforms: {
image: Cesium.Material.DefaultImageId,
detail: Cesium.Material.DefaultImageId,
alpha: 1,
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
},
// Diffuse and alpha share these samples, so one source, not components.
source: MATERIAL_SOURCE,
},
translucent: false,
});
}
const sameEdges = (a, b) =>
a.west === b.west &&
a.south === b.south &&
a.east === b.east &&
a.north === b.north;
/** Keep the shells first in the primitive collection, by rank: higher ranks
* draw over lower ones and other opaque content draws over both. */
export function orderImageryShells(primitives, primitive, rank) {
ranks.set(primitive, rank);
if (typeof primitives.lowerToBottom !== 'function') return;
const shells = [];
for (let i = 0; i < primitives.length; i++) {
const item = primitives.get(i);
if (ranks.has(item)) shells.push(item);
}
const ordered = [...shells].sort((a, b) => ranks.get(a) - ranks.get(b));
if (ordered.every((item, i) => primitives.get(i) === item)) return;
for (const item of ordered.reverse()) primitives.lowerToBottom(item);
}
/**
* One shell over `rectangle` (degrees) at `height` metres, in draw position
* `rank`. `onSettled()` runs each time the latest image (and detail) has
* drawn. The owner calls destroy().
*/
export function createImageryShell({
scene,
rectangle,
height,
rank = 0,
onSettled = () => {},
}) {
registerMaterial();
const primitives = scene.primitives;
const material = new Cesium.Material({
fabric: { type: MATERIAL_TYPE },
translucent: false,
});
// Flat shading keeps the image's colours independent of the viewing angle.
const appearance = new Cesium.EllipsoidSurfaceAppearance({
aboveGround: true,
flat: true,
translucent: false,
material,
});
// Cesium orders its translucent pass itself (weighted OIT or distance), which
// cannot honour stacked shells. Draw in the opaque pass in scene.primitives
// order, alpha-blended and without depth writes, so later shells draw over
// earlier ones and nothing is hidden or picked through a shell.
appearance.getRenderState = () => ({
depthTest: { enabled: true },
depthMask: false,
blending: Cesium.BlendingState.ALPHA_BLEND,
});
const { west, south, east, north } = rectangle;
const primitive = new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(west, south, east, north),
height,
granularity: Cesium.Math.toRadians(0.5),
vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
}),
}),
appearance,
asynchronous: true,
allowPicking: false,
});
primitives.add(primitive);
orderImageryShells(primitives, primitive, rank);
let lift = 0;
const offLift = scene.preRender?.addEventListener(() => {
const next = raisedLift(scene.camera?.positionCartographic?.height);
if (next === lift) return;
lift = next;
primitive.modelMatrix = Cesium.Matrix4.fromUniformScale(
1 + lift / Cesium.Ellipsoid.WGS84.maximumRadius,
);
});
let image = null;
let frames = 0;
// The detail image, its own upload count and a window waiting for it to draw.
let detail = null;
let detailFrames = 0;
let pendingWindow = null;
let offRender = null;
let destroyed = false;
const fits = (size, value) =>
!size || (size.x === value.width && size.y === value.height);
const uploaded = () =>
image !== null && fits(material.uniforms.imageDimensions, image);
const detailDrawn = () =>
detail !== null &&
detailFrames === 0 &&
fits(material.uniforms.detailDimensions, detail);
const drawn = () =>
primitive.ready &&
uploaded() &&
frames === 0 &&
(detail === null || (detailDrawn() && pendingWindow === null));
function appliedWindow() {
const { x, y, z, w } = material.uniforms.window;
return z < x ? null : { west: x, south: y, east: z, north: w };
}
function setWindow(rect) {
const next = rect ?? NO_WINDOW;
const { x, y, z, w } = material.uniforms.window;
if (
x === next.west &&
y === next.south &&
z === next.east &&
w === next.north
)
return;
material.uniforms.window = new Cesium.Cartesian4(
next.west,
next.south,
next.east,
next.north,
);
scene.requestRender();
}
// The render governor may idle the scene; request frames only until the
// asynchronous geometry is ready and the latest images have been drawn.
function tick() {
// Cesium updates a material only while its primitive is shown, so hidden
// frames upload nothing and count for nothing.
if (primitive.ready && primitive.show) {
if (frames > 0) frames--;
if (detailFrames > 0) detailFrames--;
// A texture is never shown at another window's place.
if (pendingWindow && detailDrawn()) {
setWindow(pendingWindow);
pendingWindow = null;
}
}
if (primitive.show && !drawn()) {
scene.requestRender();
return;
}
offRender?.();
offRender = null;
if (drawn()) onSettled();
}
function wake() {
if (destroyed || !primitive.show || image === null || drawn()) return;
offRender ??= scene.postRender.addEventListener(tick);
scene.requestRender();
}
return {
rectangle,
/** Draw `next`; Cesium keeps drawing the previous texture until it is uploaded. */
setImage(next) {
if (destroyed || next === image) return;
image = next;
material.uniforms.image = next;
frames = UPLOAD_FRAMES;
wake();
},
image: () => image,
setAlpha(value) {
if (destroyed || material.uniforms.alpha === value) return;
material.uniforms.alpha = value;
scene.requestRender();
},
/** Draw `next` inside `rect` (west, south, east, north in the 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
* this one is uploaded. A different window applies once its image has
* drawn; until then the image covers it. */
setDetail(next, rect) {
if (destroyed) return;
if (!next) {
if (detail === null) return;
detail = null;
detailFrames = 0;
pendingWindow = null;
material.uniforms.detail = Cesium.Material.DefaultImageId;
setWindow(null);
scene.requestRender();
return;
}
if (next !== detail) {
detail = next;
material.uniforms.detail = next;
detailFrames = UPLOAD_FRAMES;
}
const applied = appliedWindow();
if (applied && sameEdges(applied, rect)) {
pendingWindow = null;
} else {
setWindow(null);
pendingWindow = rect;
}
wake();
},
detail: () => detail,
setShow(value) {
const show = Boolean(value);
if (destroyed || primitive.show === show) return;
primitive.show = show;
wake();
scene.requestRender();
},
setRank(value) {
if (!destroyed && !primitives.isDestroyed?.())
orderImageryShells(primitives, primitive, value);
},
drawn,
destroy() {
if (destroyed) return;
destroyed = true;
offRender?.();
offRender = null;
offLift?.();
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
primitives.remove(primitive);
if (!primitive.isDestroyed()) primitive.destroy();
// Primitive.destroy leaves the material and its texture alive.
if (!material.isDestroyed()) material.destroy();
image = null;
detail = null;
pendingWindow = null;
scene.requestRender();
},
diagnostics: () => ({
height,
ready: Boolean(primitive.ready),
uploaded: uploaded(),
show: primitive.show,
alpha: material.uniforms.alpha,
rendering: offRender !== null,
detail: { uploaded: detailDrawn(), window: appliedWindow() },
}),
};
}
+35 -9
View File
@@ -533,12 +533,16 @@
* What an imagery layer draws, and where (src/globe/imagery.js checks and
* defaults these). Two kinds:
* - tiles: `{ kind: 'tiles', url, subdomains?, minLevel?, maxLevel?,
* tileSize?, rectangle?, credit?, opaque?, maxRequests? }`, XYZ tiles in
* Web Mercator; `url` holds {z}, {x} and {y}, and {s} for one of
* `subdomains` (['a', 'b', 'c']); levels 0..18 and 256 px tiles by
* default; `opaque` tiles have no alpha (Cesium uploads them as RGB);
* `maxRequests` caps the layer's tile requests in flight (Cesium:
* unbounded by default; Navara loads 6 at a time);
* tileSize?, rectangle?, credit?, opaque?, maxRequests?, scheme?,
* transform? }`, XYZ tiles; `url` holds {z}, {x} and {y}, and {s} for
* one of `subdomains` (['a', 'b', 'c']); levels 0..18 and 256 px tiles
* by default; `opaque` tiles have no alpha (Cesium uploads them as
* RGB); `maxRequests` caps the layer's tile requests in flight (Cesium:
* unbounded by default; Navara loads 6 at a time); `scheme` is
* 'webMercator' (the default) or 'geographic' (EPSG:4326, 2×1 tiles at
* level 0, y down from 90°N); `transform(image)` turns each decoded tile
* (row 0 on the north edge) into the canvas or image that draws (the
* weather's infrared mask);
* - image: `{ kind: 'image', image | url, rectangle?, credit? }`, one
* image stretched over the rectangle in longitude and latitude
* (equirectangular, row 0 on the north edge): a canvas, an ImageBitmap
@@ -564,13 +568,35 @@
* @property {number} [order] Draw order among imagery layers (higher draws
* later, on top); 0 by default, then creation order. Every imagery layer
* draws above the base imagery.
* @property {number} [raise] Metres above the ellipsoid: the layer draws on
* a shell that high instead of draping, over terrain, 3D tiles and the
* sky's edge, hidden only by what is nearer; the shell rises with the
* camera (imagery.js raisedLift) so coarse far tiles never hide it. Image
* sources only. Cesium draws it as a primitive (no split); raised layers
* draw in the imagery order among themselves.
*
* @typedef {object} ImageryLayer
* @property {string} id
* @property {(source: ImagerySource) => void} setSource Draws `source` in
* place of the last one, at the layer's place in the order; the same
* canvas again draws its new pixels.
* @property {(source: ImagerySource, options?: { staged?: boolean, signal?: AbortSignal }) => (Promise<boolean>|undefined)} setSource
* Draws `source` in place of the last one, at the layer's place in the
* order; the same canvas again draws its new pixels, and a source that
* differs only in its credit keeps its pixels. `staged`: the drawn
* source stays until the new one is complete (an image loaded and
* uploaded; the tiles in view loaded — Cesium: its requests in and quiet,
* at alpha 0 under the drawn one; Navara: its own atlas), then replaces
* it; resolves true then, false when it failed (an image or a tile that
* would not load), was aborted by `signal`, or was replaced by another
* setSource first. A hidden layer takes a staged source at once.
* @property {() => object} source The checked source drawn.
* @property {() => boolean} staging Whether a staged source is on its way.
* @property {(detail: { image: object, rectangle: DegreeRectangle } | null) => Promise<boolean>} setDetail
* A raised layer only: inside `rectangle` (not across 180°) it draws
* `image` in place of its own source (a sharper view of one area); the
* last detail draws until the new one is uploaded (Cesium: its own image
* covers a moved window until then); resolves true once it draws, false
* when replaced first. null clears it at once.
* @property {() => object | null} detail
* @property {() => number | null} raise
* @property {(alpha: number) => void} setAlpha A fade: at 0 the layer draws
* nothing but stays on its surface (Cesium keeps its tiles mapped), so it
* comes back without a gap.
+255 -37
View File
@@ -1,14 +1,21 @@
// engine.imagery, the engine-neutral part: raster layers draped on the
// ground (terrain and 3D tiles) — XYZ tiles from a URL template, or one
// image (a canvas, a decoded image, RGBA bytes or an image URL) stretched
// over a longitude/latitude rectangle. This checks layer options and
// sources, keeps each layer's look (alpha, shown, split, order) and the
// split position, and tiles a Web Mercator template for the engines that
// mosaic their own tiles (Navara). Rectangles are degrees; colours are the
// image's own. No engine imports.
// ground (terrain and 3D tiles) or raised on a shell above it — XYZ tiles
// from a URL template (Web Mercator or geographic), or one image (a canvas,
// a decoded image, RGBA bytes or an image URL) stretched over a
// longitude/latitude rectangle. This checks layer options and sources,
// keeps each layer's look (alpha, shown, split, order), its staged source
// and detail and the split position, and tiles a template for the engines
// that mosaic their own tiles (Navara). Rectangles are degrees; colours are
// the image's own. No engine imports.
export const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']);
export const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']);
/** How a tiles source cuts the globe: Web Mercator XYZ, or geographic
* (EPSG:4326, 2×1 tiles at level 0, Cesium's GeographicTilingScheme). */
export const IMAGERY_TILE_SCHEMES = Object.freeze([
'webMercator',
'geographic',
]);
/** Where imagery drapes now (GlobeImagery.surface). */
export const IMAGERY_SURFACES = Object.freeze(['globe', 'tiles', 'none']);
/** The latitude Web Mercator tiles stop at. */
@@ -79,11 +86,28 @@ function imageOf(image) {
/** Whether an image source holds RGBA bytes rather than a drawable image. */
export const isPixelImage = (image) => ArrayBuffer.isView(image?.data);
// A raised layer's shell rises with the camera: from far away coarse 3D
// tiles stand tens of kilometres above the ground and would hide it. 4 m
// per kilometre of camera height, at most 60 km, in 500 m steps; a uniform
// lift keeps stacked shells in their order.
const LIFT_PER_METRE = 0.004;
const MAX_LIFT = 60_000;
const LIFT_STEP = 500;
/** Metres every raised layer rises for a camera this high above the ellipsoid. */
export function raisedLift(cameraHeight) {
if (!(cameraHeight > 0)) return 0;
const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE);
return Math.round(lift / LIFT_STEP) * LIFT_STEP;
}
/**
* An imagery source, checked, as the engines draw it:
* - { kind: 'tiles', url, subdomains, minLevel, maxLevel, tileSize,
* rectangle, credit, opaque, maxRequests }: XYZ tiles in Web Mercator;
* `url` holds {z}, {x} and {y}, and {s} for one of `subdomains`;
* rectangle, credit, opaque, maxRequests, scheme, transform }: XYZ tiles
* in Web Mercator or geographic (`scheme`); `url` holds {z}, {x} and
* {y}, and {s} for one of `subdomains`; `transform(image)`, when given,
* turns each decoded tile (row 0 on the north edge) into what draws;
* - { kind: 'image', image | url, rectangle, credit }: one image over the
* rectangle, equirectangular (row 0 on the north edge).
* The rectangle is in degrees (the globe when omitted); `credit` is HTML.
@@ -138,6 +162,12 @@ export function normalizeImagerySource(source) {
fail(`maxRequests must be a positive whole number: ${maxRequests}`);
if (source.opaque !== undefined && typeof source.opaque !== 'boolean')
fail('opaque must be a boolean');
const scheme = source.scheme ?? 'webMercator';
if (!IMAGERY_TILE_SCHEMES.includes(scheme))
fail(`scheme must be one of ${IMAGERY_TILE_SCHEMES.join(', ')}: ${scheme}`);
const transform = source.transform ?? null;
if (transform !== null && typeof transform !== 'function')
fail('transform must be a function of a decoded tile');
return Object.freeze({
kind,
url,
@@ -149,9 +179,50 @@ export function normalizeImagerySource(source) {
credit,
opaque: source.opaque === true,
maxRequests,
scheme,
transform,
});
}
/**
* Whether two checked sources draw the same pixels and differ at most in
* their credit: the same template and tiling, or the same image URL. A
* source holding an image is never the same (setSource with the same
* canvas draws its new pixels).
*/
export function sameImageryPixels(a, b) {
if (a.kind !== b.kind) return false;
const edges = ['west', 'south', 'east', 'north'];
if (!edges.every((edge) => a.rectangle[edge] === b.rectangle[edge]))
return false;
if (a.kind === 'image') return !a.image && !b.image && a.url === b.url;
return (
a.url === b.url &&
a.subdomains.join() === b.subdomains.join() &&
a.minLevel === b.minLevel &&
a.maxLevel === b.maxLevel &&
a.tileSize === b.tileSize &&
a.opaque === b.opaque &&
a.maxRequests === b.maxRequests &&
a.scheme === b.scheme &&
a.transform === b.transform
);
}
/**
* A raised layer's detail, checked: { image, rectangle }, an image (as an
* image source holds one) drawn in place of the layer's own inside a
* rectangle that does not cross the antimeridian; null clears it.
*/
export function normalizeImageryDetail(detail) {
if (detail === null) return null;
if (!isObject(detail)) fail('a detail is { image, rectangle } or null');
const rectangle = rectangleOf(detail.rectangle);
if (detail.rectangle == null || rectangle.west > rectangle.east)
fail('a detail rectangle must not cross the antimeridian');
return Object.freeze({ image: imageOf(detail.image), rectangle });
}
/** createLayer options with defaults, or a TypeError. */
export function normalizeImageryLayerOptions(options) {
if (!isObject(options)) fail('options must be an object');
@@ -161,29 +232,50 @@ export function normalizeImageryLayerOptions(options) {
if (!isFiniteNumber(order)) fail(`order must be a number: ${order}`);
if (options.show !== undefined && typeof options.show !== 'boolean')
fail('show must be a boolean');
const raise = options.raise ?? null;
if (raise !== null && !(isFiniteNumber(raise) && raise > 0))
fail(`raise must be a positive number of metres: ${raise}`);
const source = normalizeImagerySource(options.source);
if (raise !== null) raisedSource(source);
return {
id,
order,
source: normalizeImagerySource(options.source),
source,
alpha: unit(options.alpha ?? 1, 'alpha'),
shown: options.show !== false,
split: splitOf(options.split),
raise,
};
}
// A raised layer draws one image on its shell, never tiles.
function raisedSource(source) {
if (source.kind !== 'image') fail('a raised layer draws an image source');
return source;
}
/** A whole-globe rectangle wraps across the antimeridian. */
export const wrapsGlobe = ({ west, east }) => west === -180 && east === 180;
/**
* engine.imagery over an engine's drawing: `build(state)` returns the
* engine part of one layer ({ source(), look(), destroy(), ready?() }),
* called with the layer's checked state ({ options, source, alpha, shown,
* split, seq }) after it is stored; `source()` follows a new source and
* `look()` a new alpha, show or split. `splitPosition(fraction)` draws the
* engine's split, `surface()` answers GlobeImagery.surface. Layer ids are
* unique while the layer lives; destroyAll() is for the engine's destroy.
* `initialSplit` is the split position before one is set; `readSplit`,
* when given, reads the one drawn (an engine whose split others set too).
* engine part of one layer ({ source(), look(), destroy(), ready?(),
* stage?(), unstage?(), credit?(), detail?() }), called with the layer's
* checked state ({ options, source, alpha, shown, split, seq, detail })
* after it is stored; `source()` follows a new source and `look()` a new
* alpha, show or split. `credit()` follows a source that changed only its
* credit (`source()` when the part has none). `stage(source, finish)`
* builds a source aside while the drawn one stays, and calls
* `finish(true)` as it draws it in its place (the state's source is the
* new one from then on) or `finish(false)` when it cannot; `unstage()`
* drops one in progress. `detail(detail, finish)` does the same for a
* raised layer's detail (null: cleared at once). Without `stage` a staged
* source replaces the drawn one at once. `splitPosition(fraction)` draws
* the engine's split, `surface()` answers GlobeImagery.surface. Layer ids
* are unique while the layer lives; destroyAll() is for the engine's
* destroy. `initialSplit` is the split position before one is set;
* `readSplit`, when given, reads the one drawn (an engine whose split
* others set too).
*/
export function createImageryRegistry(
build,
@@ -209,6 +301,7 @@ export function createImageryRegistry(
shown: options.shown,
split: options.split,
seq: seq++,
detail: null,
destroyed: false,
};
live.set(options.id, state);
@@ -219,6 +312,25 @@ export function createImageryRegistry(
live.delete(options.id);
throw error;
}
// The source and the detail being staged: { value, resolve }.
let staged = null;
let detailing = null;
const settle = (job, ok) => {
if (job) job.resolve(ok);
};
const dropStaged = () => {
const job = staged;
staged = null;
if (!job) return;
job.offAbort?.();
part.unstage?.();
settle(job, false);
};
const dropDetail = () => {
const job = detailing;
detailing = null;
settle(job, false);
};
const alive = () => {
if (state.destroyed) throw new Error('Imagery layer was destroyed');
};
@@ -228,14 +340,86 @@ export function createImageryRegistry(
state[key] = value;
part.look();
};
const checkedSource = (next) => {
const source = normalizeImagerySource(next);
return options.raise === null ? source : raisedSource(source);
};
// A source the drawn one differs from only in its credit.
const inPlace = (source) => {
if (!sameImageryPixels(state.source, source)) return false;
const changed = state.source.credit !== source.credit;
state.source = source;
if (changed) (part.credit ?? part.source)();
return true;
};
const layer = {
id: options.id,
setSource(next) {
setSource(next, { staged: stage = false, signal = null } = {}) {
alive();
state.source = normalizeImagerySource(next);
part.source();
const source = checkedSource(next);
if (!stage) {
dropStaged();
if (!inPlace(source)) {
state.source = source;
part.source();
}
return undefined;
}
dropStaged();
if (signal?.aborted) return Promise.resolve(false);
if (inPlace(source)) return Promise.resolve(true);
if (!part.stage) {
state.source = source;
part.source();
return Promise.resolve(true);
}
return new Promise((resolve) => {
const job = { value: source, resolve };
staged = job;
if (signal) {
const abort = () => {
if (staged === job) dropStaged();
};
signal.addEventListener('abort', abort, { once: true });
job.offAbort = () => signal.removeEventListener('abort', abort);
}
part.stage(source, (ok) => {
if (staged !== job || state.destroyed) return;
staged = null;
job.offAbort?.();
if (ok) state.source = source;
settle(job, ok);
});
});
},
source: () => state.source,
staging: () => staged !== null,
setDetail(next) {
alive();
if (options.raise === null)
fail('only a raised layer takes a detail');
const detail = normalizeImageryDetail(next ?? null);
dropDetail();
if (!detail) {
if (state.detail === null) return Promise.resolve(true);
state.detail = null;
part.detail?.(null, () => {});
return Promise.resolve(true);
}
return new Promise((resolve) => {
const job = { value: detail, resolve };
detailing = job;
const finish = (ok) => {
if (detailing !== job || state.destroyed) return;
detailing = null;
if (ok) state.detail = detail;
settle(job, ok);
};
if (part.detail) part.detail(detail, finish);
else finish(true);
});
},
detail: () => state.detail,
setAlpha: (alpha) => restyle('alpha', unit(alpha, 'alpha')),
alpha: () => state.alpha,
show(visible) {
@@ -245,12 +429,18 @@ export function createImageryRegistry(
shown: () => state.shown,
setSplit: (side) => restyle('split', splitOf(side)),
split: () => state.split,
raise: () => options.raise,
ready: () =>
!state.destroyed && (part.ready ? part.ready() === true : true),
destroy() {
if (state.destroyed) return;
state.destroyed = true;
live.delete(options.id);
const job = staged;
staged = null;
job?.offAbort?.();
settle(job, false);
dropDetail();
part.destroy();
},
isDestroyed: () => state.destroyed,
@@ -292,8 +482,8 @@ export function createImageryRegistry(
};
}
// Web Mercator tiles of a template, for the engines that mosaic them
// (Navara draws one atlas per tiles layer).
// The tiles of a template, Web Mercator or geographic, for the engines that
// mosaic them (Navara draws one atlas per tiles layer).
const MAX_TILE_SIDE = 8;
export const mercatorX = (lon) => (lon + 180) / 360;
@@ -305,14 +495,21 @@ export const mercatorY = (lat) => {
return (1 - Math.log(Math.tan(Math.PI / 4 + r / 2)) / Math.PI) / 2;
};
/** Geographic tiles: x across 2^(z+1) columns, y down 2^z rows from 90°. */
export const geographicX = (lon) => (lon + 180) / 360;
export const geographicY = (lat) =>
(90 - Math.max(-90, Math.min(90, lat))) / 180;
/**
* The tiles over `box` (degrees) that a view `rectangle` (radians, or null:
* all of the box) shows at `level`, or the level below until they are at
* most 8×8: { key, z, x, y, width, height, bounds: [x, y, w, h] in Web
* Mercator units }, or null when the view misses the box. Never a global
* mosaic.
* most 8×8: { key, z, x, y, width, height, bounds }, or null when the view
* misses the box. Never a global mosaic. `bounds` is [x, y, w, h] in Web
* Mercator units, or for `scheme` 'geographic' the atlas's [west, south,
* east, north] in degrees.
*/
export function imageryTilePlan(box, rectangle, level) {
export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') {
const geographic = scheme === 'geographic';
let area = { ...box };
if (rectangle && rectangle.east >= rectangle.west) {
const degrees = 180 / Math.PI;
@@ -324,23 +521,36 @@ export function imageryTilePlan(box, rectangle, level) {
};
}
if (area.west >= area.east || area.south >= area.north) return null;
const toX = geographic ? geographicX : mercatorX;
const toY = geographic ? geographicY : mercatorY;
for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) {
const n = 2 ** z;
const x = Math.max(0, Math.floor(mercatorX(area.west) * n));
const y = Math.max(0, Math.floor(mercatorY(area.north) * n));
const right = Math.min(n - 1, Math.ceil(mercatorX(area.east) * n) - 1);
const bottom = Math.min(n - 1, Math.ceil(mercatorY(area.south) * n) - 1);
const columns = geographic ? 2 ** (z + 1) : 2 ** z;
const rows = 2 ** z;
const x = Math.max(0, Math.floor(toX(area.west) * columns));
const y = Math.max(0, Math.floor(toY(area.north) * rows));
const right = Math.min(
columns - 1,
Math.ceil(toX(area.east) * columns) - 1,
);
const bottom = Math.min(rows - 1, Math.ceil(toY(area.south) * rows) - 1);
const width = right - x + 1,
height = bottom - y + 1;
if (width <= MAX_TILE_SIDE && height <= MAX_TILE_SIDE)
return {
key: [z, x, y, width, height].join('/'),
key: [scheme, z, x, y, width, height].join('/'),
z,
x,
y,
width,
height,
bounds: [x / n, y / n, width / n, height / n],
bounds: geographic
? [
(x / columns) * 360 - 180,
90 - ((y + height) / rows) * 180,
((x + width) / columns) * 360 - 180,
90 - (y / rows) * 180,
]
: [x / columns, y / rows, width / columns, height / rows],
};
}
return null;
@@ -364,14 +574,22 @@ export function imageryTileUrl(
/**
* The tile level whose texels match the ground under a camera `height` m
* up at `latitude` (radians), seen with a vertical field of view `fovy`
* over `heightPx` CSS pixels.
* over `heightPx` CSS pixels. A geographic level 0 tile spans half the
* globe's width, so its level is one below Web Mercator's.
*/
export function imageryTileLevel({ height, latitude, fovy, heightPx }) {
export function imageryTileLevel({
height,
latitude,
fovy,
heightPx,
scheme = 'webMercator',
}) {
const metresPerPixel =
(2 * Math.max(1, height) * Math.tan(fovy / 2)) / Math.max(1, heightPx);
return Math.ceil(
const level = Math.ceil(
Math.log2((156543.033928 * Math.cos(latitude)) / metresPerPixel),
);
return scheme === 'geographic' ? level - 1 : level;
}
/**
+194
View File
@@ -10,6 +10,8 @@ import {
mercatorY,
normalizeImageryLayerOptions,
normalizeImagerySource,
raisedLift,
sameImageryPixels,
} from './imagery.js';
const image = { width: 2, height: 1, data: new Uint8Array(8) };
@@ -32,6 +34,8 @@ test('sources are checked and defaulted; rectangles default to the globe', () =>
credit: null,
opaque: false,
maxRequests: null,
scheme: 'webMercator',
transform: null,
},
);
const across = normalizeImagerySource({
@@ -73,6 +77,8 @@ test('sources are checked and defaulted; rectangles default to the globe', () =>
rectangle: { west: 0, south: 0, east: 0, north: 1 },
},
{ kind: 'image', image, credit: 7 },
{ kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}', scheme: 'polar' },
{ kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}', transform: 'mask' },
])
assert.throws(
() => normalizeImagerySource(bad),
@@ -97,8 +103,161 @@ test('layer options are checked and defaulted', () => {
{ id: 'a', source: { kind: 'image', image }, show: 1 },
{ id: 'a', source: { kind: 'image', image }, split: 'top' },
{ id: 'a', source: { kind: 'image', image }, order: Infinity },
{ id: 'a', source: { kind: 'image', image }, raise: 0 },
{
id: 'a',
source: { kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}' },
raise: 6000,
},
])
assert.throws(() => normalizeImageryLayerOptions(bad), TypeError);
assert.equal(
normalizeImageryLayerOptions({
id: 'a',
source: { kind: 'image', image },
raise: 6000,
}).raise,
6000,
);
});
test('a source that changes only its credit keeps its pixels', () => {
const transform = (tile) => tile;
const tiles = (extra) =>
normalizeImagerySource({
kind: 'tiles',
url: 'https://t.example/{z}/{x}/{y}',
scheme: 'geographic',
transform,
...extra,
});
assert.ok(sameImageryPixels(tiles(), tiles({ credit: 'NOAA' })));
assert.ok(!sameImageryPixels(tiles(), tiles({ maxLevel: 6 })));
assert.ok(!sameImageryPixels(tiles(), tiles({ transform: (t) => t })));
const url = normalizeImagerySource({ kind: 'image', url: 'a.png' });
assert.ok(
sameImageryPixels(
url,
normalizeImagerySource({ kind: 'image', url: 'a.png', credit: 'x' }),
),
);
const held = normalizeImagerySource({ kind: 'image', image });
assert.ok(!sameImageryPixels(held, held), 'an image again is new pixels');
});
test('a staged source replaces the drawn one only when it is done; newer, aborted and failed ones do not', async () => {
const calls = [];
const finishes = [];
const { imagery } = createImageryRegistry((state) => ({
source: () => calls.push(['source', state.source.url]),
credit: () => calls.push(['credit', state.source.credit]),
stage(source, finish) {
calls.push(['stage', source.url]);
finishes.push(finish);
},
unstage: () => calls.push(['unstage']),
look() {},
destroy() {},
}));
const tiles = (url, extra) => ({
kind: 'tiles',
url: `https://${url}.example/{z}/{x}/{y}`,
...extra,
});
const layer = imagery.createLayer({ id: 'a', source: tiles('one') });
const first = layer.setSource(tiles('two'), { staged: true });
assert.equal(layer.staging(), true);
assert.match(layer.source().url, /one/, 'the drawn source stays');
const second = layer.setSource(tiles('three'), { staged: true });
assert.equal(await first, false, 'replaced by a newer one');
finishes[1](true);
assert.equal(await second, true);
assert.match(layer.source().url, /three/);
assert.equal(layer.staging(), false);
// Aborted: the drawn source stays.
const controller = new AbortController();
const aborted = layer.setSource(tiles('four'), {
staged: true,
signal: controller.signal,
});
controller.abort();
assert.equal(await aborted, false);
assert.match(layer.source().url, /three/);
// Failed.
const failed = layer.setSource(tiles('five'), { staged: true });
finishes[3](false);
assert.equal(await failed, false);
assert.match(layer.source().url, /three/);
// Only the credit: in place, at once.
assert.equal(
await layer.setSource(tiles('three', { credit: 'NOAA' }), { staged: true }),
true,
);
layer.setSource(tiles('three', { credit: 'NWS' }));
assert.deepEqual(calls.slice(-2), [
['credit', 'NOAA'],
['credit', 'NWS'],
]);
assert.deepEqual(
calls.filter(([name]) => name === 'unstage').length,
2,
'a replaced and an aborted stage were dropped',
);
// A destroy ends a stage in progress.
const pending = layer.setSource(tiles('six'), { staged: true });
layer.destroy();
assert.equal(await pending, false);
});
test('a raised layer takes a detail; a draped one does not', async () => {
const details = [];
const { imagery } = createImageryRegistry(() => ({
source() {},
detail: (detail, finish) => details.push([detail, finish]),
look() {},
destroy() {},
}));
const raised = imagery.createLayer({
id: 'shell',
source: { kind: 'image', image },
raise: 6000,
});
const draped = imagery.createLayer({
id: 'ground',
source: { kind: 'image', image },
});
assert.equal(raised.raise(), 6000);
assert.equal(draped.raise(), null);
const rectangle = { west: 1, south: 1, east: 2, north: 2 };
assert.throws(() => draped.setDetail({ image, rectangle }), TypeError);
assert.throws(
() =>
raised.setDetail({
image,
rectangle: { west: 170, south: 1, east: -170, north: 2 },
}),
TypeError,
);
assert.throws(
() => raised.setSource({ kind: 'tiles', url: 'https://t/{z}/{x}/{y}' }),
TypeError,
);
const first = raised.setDetail({ image, rectangle });
const second = raised.setDetail({ image, rectangle });
assert.equal(await first, false);
details[1][1](true);
assert.equal(await second, true);
assert.deepEqual(raised.detail().rectangle, rectangle);
assert.equal(await raised.setDetail(null), true);
assert.equal(raised.detail(), null);
assert.equal(details.at(-1)[0], null, 'cleared on the engine');
});
test('raised layers rise with the camera in 500 m steps, at most 60 km', () => {
assert.equal(raisedLift(0), 0);
assert.equal(raisedLift(100_000), 500);
assert.equal(raisedLift(1_000_000), 4000);
assert.equal(raisedLift(1e9), 60_000);
});
test('the registry keeps each layer’s look, draws in order and calls its part', () => {
@@ -201,6 +360,41 @@ test('visible imagery tiles stay bounded and contain the intersected geographic
};
const plan = imageryTilePlan(box, view, 12);
assert.equal(plan.z, 12);
// Geographic tiles: 2×1 at level 0, bounds in degrees.
const world = imageryTilePlan(
{ west: -180, east: 180, south: -90, north: 90 },
null,
0,
'geographic',
);
assert.deepEqual(
[world.width, world.height, ...world.bounds],
[2, 1, -180, -90, 180, 90],
);
const us = imageryTilePlan(
{ west: -130, east: -60, south: 20, north: 55 },
null,
3,
'geographic',
);
assert.ok(us.width <= 8 && us.height <= 8);
assert.ok(
us.bounds[0] <= -130 &&
us.bounds[2] >= -60 &&
us.bounds[1] <= 20 &&
us.bounds[3] >= 55,
);
assert.equal(
imageryTileLevel({
height: 18_000,
latitude: 0,
fovy: Math.PI / 3,
heightPx: 540,
scheme: 'geographic',
}),
11,
'a geographic level is one below Web Mercator',
);
assert.ok(plan.width * plan.height < 8);
assert.equal(
imageryTilePlan(box, { west: 0, east: 0.1, south: 0, north: 0.1 }, 12),
+410 -176
View File
@@ -1,13 +1,18 @@
// engine.imagery on Navara: every shown layer in one screen pass (pass.js),
// classified on the scene depth, so rasters drape on terrain and 3D tiles
// alike. An image source is one texture (mipmapped, wrapping across 180°
// when it spans the globe); setting the same canvas again uploads its new
// pixels. A tiles source is an atlas of at most 8×8 Web Mercator tiles over
// the part of its rectangle in view, at the level the camera's height asks
// for, rebuilt when the camera moves to another plan and filled as tiles
// arrive (6 loads at a time, or the source's maxRequests). A layer's credit
// shows while it does. Every change composes the drawn layers (bottom
// first) and asks for one frame; nothing animates on its own.
// alike, or, raised, on a shell above the ellipsoid. An image source is one
// texture (mipmapped, wrapping across 180° when it spans the globe);
// setting the same canvas again uploads its new pixels. A tiles source is
// an atlas of at most 8×8 Web Mercator or geographic tiles over the part of
// its rectangle in view, at the level the camera's height asks for, rebuilt
// when the camera moves to another plan and filled as tiles arrive (6
// loads at a time, or the source's maxRequests), each through the source's
// transform. A staged source builds its own texture or atlas while the
// drawn one shows and takes its place once it is complete. A raised
// layer's detail is a second slot right over its own, which the pass
// draws in place of the layer inside the detail's rectangle. A layer's
// credit shows while it does. Every change composes the drawn layers
// (bottom first) and asks for one frame; nothing animates on its own.
import {
CanvasTexture,
@@ -28,6 +33,7 @@ import {
imageryTilePlan,
imageryTileUrl,
isPixelImage,
raisedLift,
wrapsGlobe,
} from '../../imagery.js';
/** Layers one frame of the pass draws (pass.js samples one texture each). */
@@ -118,24 +124,36 @@ export function createNavaraImagery(
const pass = createPass(view);
const entries = new Map();
let warned = false;
// The metres every raised layer rises at the camera's height now.
let lift = 0;
const liftNow = () => {
try {
return raisedLift(camera.pose().cartographic.height);
} catch {
// Before the first frame the camera has no pose yet.
return lift;
}
};
const compose = () => {
lift = liftNow();
const drawn = registry
.ordered()
.map((state) => entries.get(state))
.filter(
(entry) => entry?.state.shown && entry.state.alpha > 0 && entry.slot(),
);
if (drawn.length > MAX_IMAGERY_SLOTS && !warned) {
const slots = drawn.flatMap((entry) => entry.slots());
if (slots.length > MAX_IMAGERY_SLOTS && !warned) {
warned = true;
console.warn(
`[Imagery] Navara draws the top ${MAX_IMAGERY_SLOTS} of ${drawn.length} layers`,
`[Imagery] Navara draws the top ${MAX_IMAGERY_SLOTS} of ${slots.length} slots`,
);
}
pass.set(
drawn.slice(-MAX_IMAGERY_SLOTS).map((entry) => entry.slot()),
registry.imagery.splitPosition(),
);
// A detail never draws without its layer under it.
let top = slots.slice(-MAX_IMAGERY_SLOTS);
if (top[0]?.detailOf) top = top.slice(1);
pass.set(top, registry.imagery.splitPosition());
view.forceUpdate();
};
@@ -148,84 +166,182 @@ export function createNavaraImagery(
entry.releaseCredit = credit ? credits.addStatic(credit) : null;
}
// A raised layer's height on the pass, lifted with the camera.
const raiseOf = (entry) =>
entry.state.options.raise === null ? 0 : entry.state.options.raise + lift;
// One image source's texture: { texture, image, source } built from a
// decoded image, or loaded from its URL (`then(texture | null)`).
function textureFor(next, source, previous = null) {
const wrap = wrapsGlobe(source.rectangle);
if (
previous &&
previous.image === next &&
previous.texture.wrapS === (wrap ? RepeatWrapping : ClampToEdgeWrapping)
) {
// The same canvas or bytes again: their new pixels.
previous.texture.needsUpdate = true;
return previous;
}
return { texture: imageTexture(next, wrap), image: next };
}
function loadImage(source, controller) {
return Promise.resolve()
.then(() => load(source.url, controller.signal))
.then(
(decoded) => {
if (controller.signal.aborted) {
decoded?.close?.();
return null;
}
return decoded;
},
(error) => {
if (!controller.signal.aborted)
console.warn('[Imagery] image failed to load:', error);
return null;
},
);
}
function imageEntry(entry) {
let texture = null,
image = null,
loading = null;
const drop = () => {
texture?.dispose();
texture = null;
let drawn = null,
loading = null,
staging = null,
detail = null;
const drop = (held) => {
if (held && held !== drawn) held.texture.dispose();
};
const show = (next, source) => {
const wrapS = wrapsGlobe(source.rectangle)
? RepeatWrapping
: ClampToEdgeWrapping;
if (texture && image === next && texture.wrapS === wrapS) {
// The same canvas or bytes again: their new pixels.
texture.needsUpdate = true;
} else {
drop();
texture = imageTexture(next, wrapS === RepeatWrapping);
}
image = next;
const dropDrawn = () => {
drawn?.texture.dispose();
drawn = null;
};
const cancelStage = () => {
staging?.controller.abort();
if (staging?.built) drop(staging.built);
staging = null;
};
const slotOf = (held, rectangle) => {
const rect = unwrapped(rectangle);
return {
texture: held.texture,
rect,
bounds: rect,
look: [entry.state.alpha, SIDE[entry.state.split], 0, 0],
raise: raiseOf(entry),
};
};
return {
source() {
const { source } = entry.state;
loading?.abort();
loading = null;
cancelStage();
if (source.image) {
show(source.image, source);
const next = textureFor(source.image, source, drawn);
if (next !== drawn) dropDrawn();
drawn = next;
return;
}
drop();
image = null;
dropDrawn();
const controller = new AbortController();
loading = controller;
Promise.resolve()
.then(() => load(source.url, controller.signal))
.then(
(decoded) => {
if (controller.signal.aborted || entry.state.destroyed) {
decoded?.close?.();
return;
}
loading = null;
show(decoded, source);
compose();
},
(error) => {
if (controller.signal.aborted) return;
loading = null;
console.warn('[Imagery] image failed to load:', error);
},
);
loadImage(source, controller).then((decoded) => {
if (!decoded || entry.state.destroyed || loading !== controller) {
decoded?.close?.();
return;
}
loading = null;
drawn = textureFor(decoded, source);
compose();
});
},
stage(source, finish) {
cancelStage();
const controller = new AbortController();
const job = { controller, built: null };
staging = job;
const swap = (decoded) => {
if (staging !== job || entry.state.destroyed) {
if (!source.image) decoded?.close?.();
return;
}
if (!decoded) {
staging = null;
finish(false);
return;
}
// The drawn texture goes as the staged one takes its place: three
// uploads it on the frame that draws it.
loading?.abort();
loading = null;
job.built = textureFor(decoded, source, drawn);
staging = null;
if (job.built !== drawn) dropDrawn();
drawn = job.built;
finish(true);
syncCredit(entry);
compose();
};
// Never in the call itself: a newer source can still replace it.
if (source.image) queueMicrotask(() => swap(source.image));
else loadImage(source, controller).then(swap);
},
unstage: cancelStage,
detail(next, finish) {
detail?.held.texture.dispose();
detail = null;
if (next) {
detail = {
held: { texture: imageTexture(next.image, false) },
rectangle: next.rectangle,
};
finish(true);
}
compose();
},
slot() {
if (!texture) return null;
const rect = unwrapped(entry.state.source.rectangle);
return {
texture,
rect,
bounds: rect,
look: [entry.state.alpha, SIDE[entry.state.split], 0, 0],
};
return drawn ? slotOf(drawn, entry.state.source.rectangle) : null;
},
ready: () => texture !== null,
slots() {
const main = this.slot();
if (!main) return [];
if (!detail) return [main];
main.look[3] = 1;
return [
main,
{ ...slotOf(detail.held, detail.rectangle), detailOf: main },
];
},
ready: () => drawn !== null,
cameraChanged: () => false,
destroy() {
loading?.abort();
loading = null;
drop();
image = null;
cancelStage();
dropDrawn();
detail?.held.texture.dispose();
detail = null;
},
diagnostics: () => ({ kind: 'image', loaded: texture !== null }),
diagnostics: () => ({
kind: 'image',
loaded: drawn !== null,
staging: staging !== null,
detail: detail !== null,
}),
};
}
function tilesEntry(entry) {
let atlas = null,
queue = null;
// The atlas of one tiles source over the view, its loads `limit` at a
// time; `onTile()` after each tile settles.
function createTiler(source, onTile) {
const scheme = source.scheme;
const queue = createImageryTileQueue({
load,
limit: source.maxRequests ?? 6,
});
let atlas = null;
let disposed = false;
const disposeAtlas = () => {
const current = atlas;
atlas = null;
@@ -233,127 +349,116 @@ export function createNavaraImagery(
for (const controller of current.controllers) controller.abort();
current.texture.dispose();
current.canvas.width = current.canvas.height = 0;
queue?.prune();
queue.prune();
};
// The source's rectangle within Web Mercator's latitudes; across the
// The source's rectangle within the tiling's latitudes; across the
// antimeridian the atlas covers every longitude.
const limit = scheme === 'geographic' ? 90 : WEB_MERCATOR_LIMIT;
const box = () => {
const { west, south, east, north } = entry.state.source.rectangle;
const { west, south, east, north } = source.rectangle;
return {
west: west < east ? west : -180,
east: west < east ? east : 180,
south: Math.max(south, -WEB_MERCATOR_LIMIT),
north: Math.min(north, WEB_MERCATOR_LIMIT),
south: Math.max(south, -limit),
north: Math.min(north, limit),
};
};
// The atlas for the view; true when it changed.
function update() {
if (!entry.state.shown) {
if (!atlas) return false;
disposeAtlas();
return true;
}
const source = entry.state.source;
let pose;
try {
pose = camera.pose();
} catch {
// Before the first frame the camera has no pose yet.
return false;
}
const level = Math.max(
source.minLevel,
Math.min(
source.maxLevel,
imageryTileLevel({
height: pose.cartographic.height,
latitude: pose.cartographic.latitude,
fovy: pose.fovy,
heightPx: size().height,
}),
),
);
const plan = imageryTilePlan(box(), camera.viewRectangle(), level);
if ((atlas?.plan.key ?? null) === (plan?.key ?? null)) return false;
disposeAtlas();
if (!plan) return true;
const tile = source.tileSize;
const canvas = createCanvas(plan.width * tile, plan.height * tile);
const texture = finish(new CanvasTexture(canvas), false);
const current = {
plan,
canvas,
texture,
controllers: new Set(),
loaded: 0,
failed: 0,
};
atlas = current;
const context = canvas.getContext('2d');
const tasks = [];
for (let y = 0; y < plan.height; y++)
for (let x = 0; x < plan.width; x++)
tasks.push({
url: imageryTileUrl(
source.url,
plan.x + x,
plan.y + y,
plan.z,
source.subdomains,
),
controllers: current.controllers,
live: () => !entry.state.destroyed && atlas === current,
draw(image) {
context.drawImage(image, x * tile, y * tile, tile, tile);
current.loaded++;
texture.needsUpdate = true;
view.forceUpdate();
},
fail() {
current.failed++;
},
});
queue.add(tasks);
return true;
}
return {
source() {
queue?.destroy();
disposeAtlas();
queue = createImageryTileQueue({
load,
limit: entry.state.source.maxRequests ?? 6,
});
update();
},
slot() {
if (!atlas) return null;
const [west, south, east, north] = unwrapped(
entry.state.source.rectangle,
source,
atlas: () => atlas,
/** The atlas for the view; true when it changed. */
update(shown) {
if (disposed) return false;
if (!shown) {
if (!atlas) return false;
disposeAtlas();
return true;
}
let pose;
try {
pose = camera.pose();
} catch {
// Before the first frame the camera has no pose yet.
return false;
}
const level = Math.max(
source.minLevel,
Math.min(
source.maxLevel,
imageryTileLevel({
height: pose.cartographic.height,
latitude: pose.cartographic.latitude,
fovy: pose.fovy,
heightPx: size().height,
scheme,
}),
),
);
return {
texture: atlas.texture,
rect: [
west,
Math.max(south, -WEB_MERCATOR_LIMIT),
east,
Math.min(north, WEB_MERCATOR_LIMIT),
],
bounds: atlas.plan.bounds,
look: [entry.state.alpha, SIDE[entry.state.split], 1, 0],
const plan = imageryTilePlan(
box(),
camera.viewRectangle(),
level,
scheme,
);
if ((atlas?.plan.key ?? null) === (plan?.key ?? null)) return false;
disposeAtlas();
if (!plan) return true;
const tile = source.tileSize;
const canvas = createCanvas(plan.width * tile, plan.height * tile);
const texture = finish(new CanvasTexture(canvas), false);
const current = {
plan,
canvas,
texture,
controllers: new Set(),
loaded: 0,
failed: 0,
};
atlas = current;
const context = canvas.getContext('2d');
const tasks = [];
for (let y = 0; y < plan.height; y++)
for (let x = 0; x < plan.width; x++)
tasks.push({
url: imageryTileUrl(
source.url,
plan.x + x,
plan.y + y,
plan.z,
source.subdomains,
),
controllers: current.controllers,
live: () => !disposed && atlas === current,
draw(image) {
const drawn = source.transform
? source.transform(image)
: image;
context.drawImage(drawn, x * tile, y * tile, tile, tile);
current.loaded++;
texture.needsUpdate = true;
view.forceUpdate();
onTile();
},
fail() {
current.failed++;
onTile();
},
});
queue.add(tasks);
return true;
},
ready: () => true,
cameraChanged: update,
look: update,
destroy() {
queue?.destroy();
queue = null;
/** Every tile of the view's atlas settled (none: nothing to load). */
complete: () =>
!atlas ||
atlas.loaded + atlas.failed === atlas.plan.width * atlas.plan.height,
failed: () => (atlas?.failed ?? 0) > 0,
dispose() {
disposed = true;
queue.destroy();
disposeAtlas();
},
diagnostics: () => ({
kind: 'tiles',
...(queue?.diagnostics() ?? {}),
...queue.diagnostics(),
atlas: atlas
? {
level: atlas.plan.z,
@@ -366,6 +471,94 @@ export function createNavaraImagery(
};
}
function tilesEntry(entry) {
let tiler = null;
// The staged source's tiler and its finish.
let staged = null;
const settleStaged = () => {
const job = staged;
if (!job) return;
const shown = entry.state.shown;
if (shown && !job.tiler.complete()) return;
staged = null;
if (shown && job.tiler.failed()) {
job.tiler.dispose();
job.finish(false);
return;
}
tiler?.dispose();
tiler = job.tiler;
job.finish(true);
syncCredit(entry);
compose();
};
const dropStaged = () => {
staged?.tiler.dispose();
staged = null;
};
const update = () => {
const changed = tiler?.update(entry.state.shown) ?? false;
staged?.tiler.update(entry.state.shown);
settleStaged();
return changed;
};
return {
source() {
dropStaged();
tiler?.dispose();
tiler = createTiler(entry.state.source, () => {});
update();
},
stage(source, finish) {
dropStaged();
const job = { finish, tiler: null };
job.tiler = createTiler(source, () => {
if (staged === job) queueMicrotask(settleStaged);
});
staged = job;
update();
},
unstage: dropStaged,
slot() {
const atlas = tiler?.atlas();
if (!atlas) return null;
const source = tiler.source;
const [west, south, east, north] = unwrapped(source.rectangle);
const mercator = source.scheme !== 'geographic';
const limit = mercator ? WEB_MERCATOR_LIMIT : 90;
return {
texture: atlas.texture,
rect: [west, Math.max(south, -limit), east, Math.min(north, limit)],
bounds: atlas.plan.bounds,
look: [
entry.state.alpha,
SIDE[entry.state.split],
mercator ? 1 : 0,
0,
],
raise: 0,
};
},
slots() {
const main = this.slot();
return main ? [main] : [];
},
ready: () => true,
cameraChanged: update,
look: update,
destroy() {
dropStaged();
tiler?.dispose();
tiler = null;
},
diagnostics: () => ({
kind: 'tiles',
...(tiler?.diagnostics() ?? {}),
staging: staged ? staged.tiler.diagnostics() : null,
}),
};
}
// One drawing per source kind; a new kind replaces it.
function drawingFor(entry) {
const kind = entry.state.source.kind;
@@ -384,6 +577,7 @@ export function createNavaraImagery(
credit: null,
releaseCredit: null,
slot: () => entry.drawing?.slot() ?? null,
slots: () => entry.drawing?.slots() ?? [],
};
entries.set(state, entry);
drawingFor(entry).source();
@@ -395,14 +589,51 @@ export function createNavaraImagery(
syncCredit(entry);
compose();
},
credit() {
syncCredit(entry);
compose();
},
// A staged source of another kind builds the other drawing aside.
stage(source, finish) {
if (source.kind === entry.drawing.kind) {
entry.drawing.stage(source, finish);
return;
}
const aside =
source.kind === 'image' ? imageEntry(entry) : tilesEntry(entry);
aside.kind = source.kind;
entry.aside?.destroy();
entry.aside = aside;
aside.stage(source, (ok) => {
if (entry.aside !== aside) return;
entry.aside = null;
if (ok) {
entry.drawing.destroy();
entry.drawing = aside;
} else aside.destroy();
finish(ok);
syncCredit(entry);
compose();
});
},
unstage() {
entry.drawing.unstage();
entry.aside?.destroy();
entry.aside = null;
},
detail(detail, finish) {
entry.drawing.detail?.(detail, finish);
},
look() {
entry.drawing.look?.();
entry.aside?.look?.();
syncCredit(entry);
compose();
},
ready: () => entry.drawing.ready(),
destroy() {
entries.delete(state);
entry.aside?.destroy();
entry.drawing.destroy();
entry.releaseCredit?.();
entry.releaseCredit = null;
@@ -418,9 +649,12 @@ export function createNavaraImagery(
const offCamera = camera.changed.addEventListener(() => {
let changed = false;
for (const entry of entries.values())
for (const entry of entries.values()) {
changed = entry.drawing.cameraChanged() || changed;
if (changed) compose();
entry.aside?.cameraChanged();
}
// Raised layers rise with the camera.
if (changed || liftNow() !== lift) compose();
});
return {
+106
View File
@@ -198,3 +198,109 @@ test('a hidden tiles layer drops its atlas; shown again it builds one at the vie
assert.ok(f.diagnostics().a.atlas);
assert.equal(f.last().slots.length, 1);
});
test('geographic tiles draw in degrees through the source’s transform', async () => {
const f = fixture();
const masked = [];
f.imagery.createLayer({
id: 'radar',
source: tiles({
url: 'https://weather.example/tile?z={z}&x={x}&y={y}',
scheme: 'geographic',
maxLevel: 6,
transform: (image) => {
masked.push(image);
return { masked: image };
},
}),
});
await tick();
const { atlas } = f.diagnostics().radar;
assert.equal(atlas.level, 6, 'the source’s top level');
const [slot] = f.last().slots;
assert.equal(slot.look[2], 0, 'sampled in degrees');
const [west, south, east, north] = slot.bounds;
assert.ok(west <= BOX.west && east >= BOX.east);
assert.ok(south <= BOX.south && north >= BOX.north);
assert.match(f.jobs[0].url, /z=6&x=\d+&y=\d+$/);
const tile = { close() {} };
f.jobs[0].resolve(tile);
await tick();
assert.deepEqual(masked, [tile]);
assert.deepEqual(f.draws, [{ masked: tile }]);
});
test('a staged tiles source builds its own atlas under the drawn one and takes its place when complete', async () => {
const f = fixture();
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
await tick();
const drawn = f.last().slots[0].texture;
const firstJobs = f.jobs.length;
const staged = layer.setSource(
tiles({ url: 'https://next.example/{z}/{x}/{y}.png', credit: 'Next' }),
{ staged: true },
);
await tick();
assert.equal(f.last().slots[0].texture, drawn, 'the drawn atlas stays');
assert.ok(f.diagnostics().a.staging, 'the staged atlas loads');
const next = f.jobs.slice(firstJobs);
assert.ok(next.length > 0 && next.every((job) => /next/.test(job.url)));
let done;
staged.then((value) => (done = value));
// Every tile of the staged atlas in: it takes the drawn one's place.
for (let round = 0; round < 20 && done === undefined; round++) {
for (const job of f.jobs.slice(firstJobs)) job.resolve({ close() {} });
await tick();
}
assert.equal(done, true);
assert.notEqual(f.last().slots[0].texture, drawn);
assert.equal(f.credits.get('Next'), 1);
assert.equal(f.credits.get('NASA GIBS'), 0);
// A failed tile leaves the drawn atlas.
const before = f.last().slots[0].texture;
const from = f.jobs.length;
const failed = layer.setSource(
tiles({ url: 'https://broken.example/{z}/{x}/{y}.png' }),
{ staged: true },
);
await tick();
f.jobs[from].resolve(Promise.reject(new Error('404')));
let result;
failed.then((value) => (result = value));
for (let round = 0; round < 20 && result === undefined; round++) {
for (const job of f.jobs.slice(from + 1)) job.resolve({ close() {} });
await tick();
}
assert.equal(result, false);
assert.equal(f.last().slots[0].texture, before);
});
test('a raised image is lifted with the camera; its detail is a slot over it', async () => {
const f = fixture();
const image = { width: 2, height: 2, data: new Uint8Array(16) };
const layer = f.imagery.createLayer({
id: 'shell',
source: { kind: 'image', image, rectangle: BOX },
raise: 6000,
});
const [slot] = f.last().slots;
// 12 km up: a 0 m lift (48 m rounds to 0).
assert.equal(slot.raise, 6000);
const detailRectangle = { ...BOX, east: -97.74 };
assert.equal(
await layer.setDetail({ image, rectangle: detailRectangle }),
true,
);
const [main, detail] = f.last().slots;
assert.equal(main.look[3], 1, 'the next slot is its detail');
assert.deepEqual(detail.rect, [
detailRectangle.west,
detailRectangle.south,
detailRectangle.east,
detailRectangle.north,
]);
assert.equal(detail.raise, 6000);
await layer.setDetail(null);
assert.equal(f.last().slots.length, 1);
assert.equal(f.last().slots[0].look[3], 0);
});
+114 -49
View File
@@ -5,11 +5,15 @@
// depth (terrain, 3D tiles); where that is the ground (not a mesh in front
// of it), up to MAX_IMAGERY_SLOTS layers are sampled in draw order at its
// longitude and latitude, each in its rectangle, on its side of the split:
// an image in longitude and latitude, a tile atlas in Web Mercator. Texels
// are blended in display space by their alpha times the layer's, as
// Cesium's globe blends imagery layers. Mip levels come from the
// derivatives of the ground position, taken before any branch. Costs
// nothing while no layer is drawn (the effect is hidden).
// an image or a geographic atlas in longitude and latitude, a Web Mercator
// atlas in Web Mercator. A raised slot is sampled where the pixel's ray
// meets the ellipsoid grown by its height, wherever nothing of the scene
// is nearer (over 3D tiles and the sky's edge too). A slot followed by its
// detail draws nothing inside the detail's rectangle. Texels are blended
// in display space by their alpha times the layer's, as Cesium's globe
// blends imagery layers. Mip levels come from the derivatives of each
// slot's position, taken outside any divergent branch. Costs nothing while
// no layer is drawn (the effect is hidden).
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing';
@@ -32,7 +36,8 @@ ${SLOTS.map(
(i) => `uniform sampler2D tImage${i};
uniform vec4 uRect${i};
uniform vec4 uBounds${i};
uniform vec4 uLook${i};`,
uniform vec4 uLook${i};
uniform float uRaise${i};`,
).join('\n')}
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
@@ -40,16 +45,52 @@ vec3 toDisplay(vec3 c) {
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
// One layer over the colour: rect is where it draws (degrees, east past
// Geodetic longitude and latitude (Bowring) of a world point, degrees.
vec2 geodetic(vec3 point) {
float p = length(point.xy);
float theta = atan(point.z * 6378137.0, p * 6356752.314245);
float st = sin(theta);
float ct = cos(theta);
float latitude = atan(point.z + 42841.311513 * st * st * st, p - 42697.672707 * ct * ct * ct);
return vec2(atan(point.y, point.x), latitude) * 57.29577951308232;
}
vec2 mercator(vec2 geo) {
float clamped = clamp(geo.y * 0.017453292519943295, -1.4844222297, 1.4844222297);
return vec2((geo.x + 180.0) / 360.0,
(1.0 - log(tan(0.7853981633974483 + clamped * 0.5)) / 3.141592653589793) / 2.0);
}
// Where the ray from the camera meets the ellipsoid grown by raise metres,
// in front of the scene (sceneRange along the ray); 0 when it does not.
float raised(float raise, vec3 origin, vec3 ray, float sceneRange, out vec2 geo) {
vec3 radii = vec3(6378137.0, 6378137.0, 6356752.314245) + raise;
vec3 o = origin / radii;
vec3 d = ray / radii;
float a = dot(d, d);
float b = dot(o, d);
float c = dot(o, o) - 1.0;
float disc = b * b - a * c;
float root = sqrt(max(disc, 0.0));
float range = (-b - root) / a;
if (range <= 0.0) range = (-b + root) / a;
geo = geodetic(origin + max(range, 0.0) * ray);
return step(0.0, disc) * step(1e-3, range) * step(range, sceneRange + 1.0);
}
bool inRect(vec2 geo, vec4 rect) {
float lon = geo.x < rect.x ? geo.x + 360.0 : geo.x;
return lon <= rect.z && geo.y >= rect.y && geo.y <= rect.w;
}
// One slot over the colour: rect is where it draws (degrees, east past
// west), bounds the image's extent (degrees) or the atlas's (Web Mercator
// x, y, width, height), look its alpha, side (-1 left, 1 right, 0 both)
// and projection (1 Web Mercator).
vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look,
vec2 geo, vec2 geoDx, vec2 geoDy, vec2 merc, vec2 mercDx, vec2 mercDy, float x) {
if (look.x <= 0.0) return color;
// x, y, width, height), look its alpha, side (-1 left, 1 right, 0 both),
// projection (1 Web Mercator) and whether the next slot is its detail
// (cut, that slot's rect: it draws nothing there).
vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look, vec4 cut,
vec2 geo, vec2 geoDx, vec2 geoDy, vec2 merc, vec2 mercDx, vec2 mercDy, float x, float drawn) {
if (look.x <= 0.0 || drawn < 0.5) return color;
if ((look.y < 0.0 && x > uSplit) || (look.y > 0.0 && x < uSplit)) return color;
if (!inRect(geo, rect)) return color;
if (look.w > 0.5 && inRect(geo, cut)) return color;
float lon = geo.x < rect.x ? geo.x + 360.0 : geo.x;
if (lon > rect.z || geo.y < rect.y || geo.y > rect.w) return color;
vec2 at;
vec2 dx;
vec2 dy;
@@ -68,47 +109,68 @@ vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look,
vec4 texel = textureGrad(image, at, dx, dy);
return mix(color, texel.rgb, texel.a * look.x);
}
// Derivatives of a position, the antimeridian's longitude step wrapped and
// none across the edge of what is drawn.
void derivatives(vec2 geo, vec2 merc, float drawn, out vec2 geoDx, out vec2 geoDy,
out vec2 mercDx, out vec2 mercDy) {
geoDx = dFdx(geo);
geoDy = dFdy(geo);
mercDx = dFdx(merc);
mercDy = dFdy(merc);
geoDx.x -= 360.0 * floor(geoDx.x / 360.0 + 0.5);
geoDy.x -= 360.0 * floor(geoDy.x / 360.0 + 0.5);
mercDx.x -= floor(mercDx.x + 0.5);
mercDy.x -= floor(mercDy.x + 0.5);
float edge = 1.0 - step(0.5, abs(dFdx(drawn)) + abs(dFdy(drawn)));
geoDx *= edge;
geoDy *= edge;
mercDx *= edge;
mercDy *= edge;
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
outputColor = inputColor;
float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv));
float distance = exp2(2.0 * depth / uLogDepthFC) - 1.0;
vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0);
vec3 point = (uWorld * vec4(ray.xyz * (distance / -ray.z), 1.0)).xyz;
// Geodetic longitude and latitude (Bowring), degrees.
float p = length(point.xy);
float theta = atan(point.z * 6378137.0, p * 6356752.314245);
float st = sin(theta);
float ct = cos(theta);
float latitude = atan(point.z + 42841.311513 * st * st * st, p - 42697.672707 * ct * ct * ct);
vec2 geo = vec2(atan(point.y, point.x), latitude) * 57.29577951308232;
float clamped = clamp(latitude, -1.4844222297, 1.4844222297);
vec2 merc = vec2((geo.x + 180.0) / 360.0,
(1.0 - log(tan(0.7853981633974483 + clamped * 0.5)) / 3.141592653589793) / 2.0);
// Derivatives before any divergent branch; across the antimeridian a
// longitude step wraps; across the sky's edge there is none.
vec2 geoDx = dFdx(geo);
vec2 geoDy = dFdy(geo);
vec2 mercDx = dFdx(merc);
vec2 mercDy = dFdy(merc);
geoDx.x -= 360.0 * floor(geoDx.x / 360.0 + 0.5);
geoDy.x -= 360.0 * floor(geoDy.x / 360.0 + 0.5);
mercDx.x -= floor(mercDx.x + 0.5);
mercDy.x -= floor(mercDy.x + 0.5);
float onSurface = 1.0 - step(1.0, depth);
float edge = 1.0 - step(0.5, abs(dFdx(onSurface)) + abs(dFdy(onSurface)));
geoDx *= edge;
geoDy *= edge;
mercDx *= edge;
mercDy *= edge;
if (depth >= 1.0) return;
// The packed depth also holds forward meshes, which are not ground.
if (depth < texture2D(tSurfaceDepth, uv).r - 2e-7) return;
// The ground: the scene depth where it is the surface (the packed depth
// also holds forward meshes, which are not ground).
float ground = (1.0 - step(1.0, depth)) *
step(texture2D(tSurfaceDepth, uv).r - 2e-7, depth);
vec2 geo = geodetic(point);
vec2 merc = mercator(geo);
// Derivatives before any divergent branch.
vec2 geoDx;
vec2 geoDy;
vec2 mercDx;
vec2 mercDy;
derivatives(geo, merc, ground, geoDx, geoDy, mercDx, mercDy);
// A raised slot's ray, and the scene's range along it (the sky: none).
vec3 origin = uWorld[3].xyz;
vec3 direction = normalize(mat3(uWorld) * ray.xyz);
float sceneRange = depth >= 1.0 ? 1.0e12 : distance * length(ray.xyz) / -ray.z;
vec3 color = toDisplay(inputColor.rgb);
${SLOTS.map(
(i) =>
`color = paint(color, tImage${i}, uRect${i}, uBounds${i}, uLook${i}, geo, geoDx, geoDy, merc, mercDx, mercDy, uv.x);`,
).join('\n ')}
outputColor = vec4(toLinear(color), inputColor.a);
vec3 start = color;
${SLOTS.map((i) => {
const cut = i + 1 < SLOTS.length ? `uRect${i + 1}` : 'vec4(0.0)';
return `{
vec2 g = geo;
vec2 m = merc;
vec2 gx = geoDx;
vec2 gy = geoDy;
vec2 mx = mercDx;
vec2 my = mercDy;
float drawn = ground;
// A uniform branch: the derivatives inside it are defined.
if (uRaise${i} > 0.0) {
drawn = raised(uRaise${i}, origin, direction, sceneRange, g);
m = mercator(g);
derivatives(g, m, drawn, gx, gy, mx, my);
}
color = paint(color, tImage${i}, uRect${i}, uBounds${i}, uLook${i}, ${cut}, g, gx, gy, m, mx, my, uv.x, drawn);
}`;
}).join('\n ')}
if (color != start) outputColor = vec4(toLinear(color), inputColor.a);
}
`;
@@ -153,6 +215,7 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
[`uRect${i}`, new Uniform([0, 0, 0, 0])],
[`uBounds${i}`, new Uniform([0, 0, 1, 1])],
[`uLook${i}`, new Uniform([0, 0, 0, 0])],
[`uRaise${i}`, new Uniform(0)],
]),
]),
});
@@ -179,6 +242,7 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
u.get(`uRect${i}`).value = slot?.rect ?? [0, 0, 0, 0];
u.get(`uBounds${i}`).value = slot?.bounds ?? [0, 0, 1, 1];
u.get(`uLook${i}`).value = slot?.look ?? [0, 0, 0, 0];
u.get(`uRaise${i}`).value = slot?.raise ?? 0;
}
}
}
@@ -187,8 +251,9 @@ const registered = new WeakSet();
/**
* The pass of one view: set(slots, split) draws up to MAX_IMAGERY_SLOTS
* layers ({ texture, rect, bounds, look }, bottom first; none: the effect
* hidden) with the split at `split` (a fraction of the width from the left).
* slots ({ texture, rect, bounds, look, raise }, bottom first; none: the
* effect hidden) with the split at `split` (a fraction of the width from
* the left). `raise` is the slot's shell height in metres (0: draped).
*/
export function createImageryPass(view) {
if (!registered.has(view)) {
+309
View File
@@ -3786,6 +3786,315 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
);
},
],
[
'imagery: tile schemes, transforms, staged sources and raised layers are checked and kept',
async () => {
const magenta = { kind: 'image', image: flat([255, 0, 255, 255]) };
const green = { kind: 'image', image: flat([0, 255, 0, 255]) };
const template = {
kind: 'tiles',
url: 'https://tiles.invalid/{z}/{x}/{y}.png',
maxLevel: 3,
rectangle: box,
};
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: { ...template, scheme: 'polar' },
}),
'an unknown tile scheme',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: { ...template, transform: 'mask' },
}),
'a transform that is not a function',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: magenta,
raise: -1,
}),
'a raise below the ellipsoid',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: template,
raise: 6000,
}),
'a raised tiles layer',
);
ensure(!listed('contract-bad'), 'a refused layer is listed');
const geographic = imagery.createLayer({
id: 'contract-imagery-geographic',
source: { ...template, scheme: 'geographic', transform: (i) => i },
});
const staged = imagery.createLayer({
id: 'contract-imagery-staged',
source: { ...magenta, rectangle: box },
});
const raisedLayer = imagery.createLayer({
id: 'contract-imagery-raised',
source: { ...magenta, rectangle: box },
raise: 6000,
});
// A promise's outcome, read frame by frame.
const outcome = (promise) => {
const result = { value: undefined };
promise.then((value) => (result.value = value));
return result;
};
try {
ensure(
geographic.source().scheme === 'geographic' &&
typeof geographic.source().transform === 'function',
'the tile scheme or transform is not kept',
);
ensure(
listed('contract-imagery-raised') &&
raisedLayer.raise() === 6000 &&
staged.raise() === null,
`raise(): ${raisedLayer.raise()}, ${staged.raise()}`,
);
throwsType(
() => staged.setDetail({ image: flat([0, 0, 0, 255]) }),
'a detail on a draped layer',
);
throwsType(
() => raisedLayer.setSource(template),
'tiles on a raised layer',
);
throwsType(
() =>
raisedLayer.setDetail({
image: flat([0, 0, 0, 255]),
rectangle: { west: 170, south: 0, east: -170, north: 1 },
}),
'a detail across the antimeridian',
);
// Staged: the drawn source stays until the new one is drawn.
await until(() => staged.ready(), 'the staged layer ready');
const first = outcome(
staged.setSource({ ...green, rectangle: box }, { staged: true }),
);
ensure(
staged.source().image === magenta.image ||
first.value === true ||
staged.source().image === green.image,
'a staged source replaced nothing',
);
await until(() => first.value !== undefined, 'the staged source');
ensure(first.value === true, 'the staged source failed');
ensure(
staged.source().image === green.image && !staged.staging(),
'the staged source is not the layer source',
);
// A newer source wins; an aborted one leaves the drawn source.
const superseded = outcome(
staged.setSource({ ...magenta, rectangle: box }, { staged: true }),
);
const winner = outcome(
staged.setSource(
{ kind: 'image', image: flat([0, 0, 255, 255]), rectangle: box },
{ staged: true },
),
);
await until(
() => superseded.value !== undefined && winner.value !== undefined,
'the superseded and the newer staged sources',
);
ensure(
superseded.value === false && winner.value === true,
`superseded ${superseded.value}, newer ${winner.value}`,
);
const drawnImage = staged.source().image;
const controller = new AbortController();
const aborted = outcome(
staged.setSource(
{ ...magenta, rectangle: box },
{ staged: true, signal: controller.signal },
),
);
controller.abort();
await until(() => aborted.value !== undefined, 'the aborted source');
ensure(
aborted.value === false && staged.source().image === drawnImage,
'an aborted staged source replaced the drawn one',
);
// A new credit alone keeps the source's pixels.
geographic.setSource({
...template,
scheme: 'geographic',
transform: geographic.source().transform,
credit: 'Contract credit',
});
ensure(
geographic.source().credit === 'Contract credit',
'the credit did not change',
);
// A hidden layer takes a staged source at once.
staged.show(false);
const hidden = outcome(
staged.setSource({ ...green, rectangle: box }, { staged: true }),
);
await until(() => hidden.value !== undefined, 'the hidden staged');
ensure(hidden.value === true, 'a hidden layer refused a stage');
// A raised layer's detail.
const west = { ...box, east: 19.5 };
await until(() => raisedLayer.ready(), 'the raised layer drawn');
const detail = outcome(
raisedLayer.setDetail({
image: flat([0, 255, 0, 255]),
rectangle: west,
}),
);
await until(() => detail.value !== undefined, 'the detail drawn');
ensure(
detail.value === true &&
raisedLayer.detail()?.rectangle.east === 19.5,
'the detail is not kept',
);
await raisedLayer.setDetail(null);
ensure(raisedLayer.detail() === null, 'the detail is not cleared');
} finally {
geographic.destroy();
staged.destroy();
raisedLayer.destroy();
}
ensure(!listed('contract-imagery-raised'), 'a raised layer is listed');
},
],
[
'imagery: geographic tiles through a transform, staged and raised layers draw (pixels)',
async () => {
if (!readRegion) return;
await nadirView(1e6);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const screenOf = (lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const meanAt = (lon, lat, half = 5) => {
const { x, y } = screenOf(lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const near = (actual, expected, tolerance = 40) =>
actual.every(
(value, k) => Math.abs(value - expected[k]) <= tolerance,
);
const show = (colour) =>
colour.map((value) => value.toFixed(0)).join(',');
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4);
// Every tile, whatever the app serves, painted magenta by the
// transform: geographic tiles over the box only.
const painted = (image) => {
const size = image.width;
const data = new Uint8Array(size * size * 4);
for (let i = 0; i < data.length; i += 4)
data.set([255, 0, 255, 255], i);
if (typeof document === 'undefined') return image;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const context = canvas.getContext('2d');
const pixels = context.createImageData(size, size);
pixels.data.set(data);
context.putImageData(pixels, 0, 0);
return canvas;
};
const layers = [];
try {
const tiles = imagery.createLayer({
id: 'contract-paint-geographic',
source: {
kind: 'tiles',
url: '/icons/icon-32.png?z={z}&x={x}&y={y}',
tileSize: 32,
maxLevel: 4,
scheme: 'geographic',
transform: painted,
rectangle: box,
},
});
layers.push(tiles);
await until(
() => near(meanAt(19.5, 10), MAGENTA),
`the transformed tiles drawn (${show(meanAt(19.5, 10))})`,
);
ensure(
!near(meanAt(21, 10), MAGENTA),
'the tiles spill outside their rectangle',
);
// Staged: green replaces the magenta tiles once it is drawn.
const staged = tiles.setSource(
{ kind: 'image', image: flat([...GREEN, 255]), rectangle: box },
{ staged: true },
);
let swapped;
staged.then((value) => (swapped = value));
await until(() => swapped !== undefined, 'the staged image');
ensure(swapped === true, 'the staged image failed');
await until(
() => near(meanAt(19.5, 10), GREEN),
`the staged image drawn (${show(meanAt(19.5, 10))})`,
);
tiles.destroy();
// Raised 6 km: drawn over the box (a little parallax at 1000 km),
// a detail in place of its west half.
const raisedLayer = imagery.createLayer({
id: 'contract-paint-raised',
source: {
kind: 'image',
image: flat([...MAGENTA, 255]),
rectangle: box,
},
raise: 6000,
});
layers.push(raisedLayer);
await until(
() => near(meanAt(19.6, 10), MAGENTA),
`the raised layer drawn (${show(meanAt(19.6, 10))})`,
);
raisedLayer.setDetail({
image: flat([...GREEN, 255]),
rectangle: { ...box, east: 19.5 },
});
await until(
() =>
near(meanAt(19.25, 10), GREEN) &&
near(meanAt(19.75, 10), MAGENTA),
`the detail drawn (west ${show(meanAt(19.25, 10))}, east ${show(meanAt(19.75, 10))})`,
);
raisedLayer.setDetail(null);
await until(
() => near(meanAt(19.25, 10), MAGENTA),
'the detail cleared',
);
} finally {
for (const layer of layers) layer.destroy();
}
await until(
() =>
!near(meanAt(19.5, 10), MAGENTA) && !near(meanAt(19.5, 10), GREEN),
'every layer gone',
);
},
],
];
}
+20 -2
View File
@@ -271,12 +271,27 @@ export function createFakeEngine(options = {}) {
destroy() {},
}));
// Imagery layers keep their state for tests; every change is a render
// request. They drape on the globe, or on tiles while photoreal shows.
// request. They drape on the globe, or on tiles while photoreal shows. A
// staged source or a detail is drawn on the next microtask, unless a
// newer one or a destroy came first.
const imageryRegistry = createImageryRegistry(
() => ({
source() {
fake.renderRequests++;
},
credit() {
fake.renderRequests++;
},
stage(_source, finish) {
queueMicrotask(() => {
fake.renderRequests++;
finish(true);
});
},
detail(detail, finish) {
fake.renderRequests++;
if (detail) queueMicrotask(() => finish(true));
},
look() {
fake.renderRequests++;
},
@@ -916,7 +931,7 @@ export function createFakeEngine(options = {}) {
},
/**
* A live imagery layer's state, by id: { options, source, alpha, shown,
* split }; `layers()` all of them in draw order.
* split, raise, detail, staging }; `layers()` all of them in draw order.
*/
imageryLayer(id) {
const state = imageryRegistry.live.get(id);
@@ -927,6 +942,9 @@ export function createFakeEngine(options = {}) {
alpha: state.alpha,
shown: state.shown,
split: state.split,
raise: state.options.raise,
detail: state.detail,
staging: state.layer.staging(),
}
: undefined;
},
+120
View File
@@ -0,0 +1,120 @@
// The Bhote Koshi flood event and its locator on the globe contract, the
// same on every engine. The event's before and after imagery are two
// engine.imagery layers of the event's image URLs over one rectangle, at
// alpha 0.96, the before image left of the engine's split position and the
// after image right of it. The story graphics (the flood corridor, the
// surge front, the locator's border, path and rings) are Cesium entities
// the layers sample (src/data/bhoteKoshiEvent.js, bhoteKoshiLocator.js):
// Cesium draws them itself; another engine draws them through its
// story backend (`drawStory`, src/layers/shared/render/navaraStory.js on
// Navara), which the manifest passes in.
const IMAGERY_ALPHA = 0.96;
// Over Recent Imagery (10), under the weather (20 and up).
const IMAGERY_ORDER = 15;
const SIDES = ['left', 'right'];
// What a layer draws until its image is in.
const EMPTY_IMAGE = Object.freeze({
width: 1,
height: 1,
data: new Uint8Array(4),
});
let events = 0;
/**
* The event layer's renderer (bhoteKoshiEvent.js `createNativeRenderer`).
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ drawStory?: (engine: object, dataSource: object) => { destroy: () => void } }} [options]
*/
export function createBhoteKoshiDisplay(engine, { drawStory = null } = {}) {
if (!engine?.imagery)
throw new TypeError('The Bhote Koshi display needs a globe engine');
const id = `bhote-koshi-imagery-${++events}`;
let layers = [];
let visible = true;
let loads = 0;
let destroyed = false;
function clear() {
for (const layer of layers) layer.destroy();
layers = [];
loads++;
engine.render.request('nepal-imagery');
}
return {
/** The story's entities through the engine's backend, or null: the
* layer adds them to Cesium. */
draw: (dataSource) => drawStory?.(engine, dataSource) ?? null,
/**
* The before and after images over `box` (degrees); resolves once both
* draw, rejects when one would not load (unless replaced or cleared
* first).
*/
async loadImages(box, urls, credit = null) {
if (destroyed) return;
clear();
const load = loads;
const rectangle = {
west: box.west,
south: box.south,
east: box.east,
north: box.north,
};
// Each layer stages its image over an empty one, which tells whether
// the image loaded.
layers = urls.map((_url, index) =>
engine.imagery.createLayer({
id: `${id}-${SIDES[index]}`,
order: IMAGERY_ORDER,
alpha: IMAGERY_ALPHA,
show: visible,
split: SIDES[index],
source: { kind: 'image', image: EMPTY_IMAGE, rectangle },
}),
);
const loaded = await Promise.all(
layers.map((layer, index) =>
layer.setSource(
{
kind: 'image',
url: urls[index],
rectangle,
...(credit ? { credit } : {}),
},
{ staged: true },
),
),
);
engine.render.request('nepal-imagery');
if (load === loads && !loaded.every(Boolean))
throw new Error('Bhote Koshi imagery unavailable');
},
setVisible(show) {
visible = Boolean(show);
for (const layer of layers) layer.show(visible);
engine.render.request('nepal-imagery');
},
/** Where the swipe runs, a fraction of the canvas width from the left. */
setSplit(fraction) {
if (Number.isFinite(fraction))
engine.imagery.setSplitPosition(Math.max(0, Math.min(1, fraction)));
engine.render.request('nepal-split');
},
clear,
destroy() {
destroyed = true;
clear();
},
};
}
/**
* The locator layer's renderer (bhoteKoshiLocator.js `createNativeRenderer`):
* its story source through the engine's backend; null where Cesium
* draws it.
*/
export function createBhoteKoshiLocatorDisplay(
engine,
{ data, drawStory = null } = {},
) {
return drawStory ? drawStory(engine, data) : null;
}
@@ -0,0 +1,71 @@
// The Bhote Koshi displays on the fake engine: the event's before and after
// images split at the engine's split position; the story through the
// engine's backend, or none.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import {
createBhoteKoshiDisplay,
createBhoteKoshiLocatorDisplay,
} from './display.js';
const BOX = { west: 85.295, south: 28.135, east: 85.379, north: 28.292 };
test('the before and after images split left and right at alpha 0.96', async () => {
const engine = createFakeEngine();
const display = createBhoteKoshiDisplay(engine);
await display.loadImages(BOX, ['pre.webp', 'post.webp'], 'Vantor');
const layers = engine.fake.imageryLayers().map(engine.fake.imageryLayer);
assert.deepEqual(
layers.map(({ source, split, alpha }) => [source.url, split, alpha]),
[
['pre.webp', 'left', 0.96],
['post.webp', 'right', 0.96],
],
);
assert.deepEqual({ ...layers[0].source.rectangle }, BOX);
assert.equal(layers[0].source.credit, 'Vantor');
display.setSplit(0.3);
assert.equal(engine.imagery.splitPosition(), 0.3);
display.setVisible(false);
assert.ok(
engine.fake
.imageryLayers()
.every((id) => engine.fake.imageryLayer(id).shown === false),
);
assert.equal(display.draw({ entities: { values: [] } }), null, 'no backend');
display.destroy();
assert.deepEqual(engine.fake.imageryLayers(), []);
});
test('an image that will not load rejects; the story draws through the backend', async () => {
const engine = createFakeEngine();
const stage = engine.imagery.createLayer;
// A layer whose staged source fails, as an engine's whose image 404s.
engine.imagery.createLayer = (options) => {
const layer = stage(options);
layer.setSource = async () => false;
return layer;
};
const drawn = [];
const display = createBhoteKoshiDisplay(engine, {
drawStory: (target, data) => {
drawn.push([target, data]);
return { destroy() {} };
},
});
await assert.rejects(
display.loadImages(BOX, ['pre.webp', 'post.webp']),
/imagery unavailable/,
);
const data = { name: 'flood' };
assert.ok(display.draw(data));
assert.deepEqual(drawn, [[engine, data]]);
assert.equal(createBhoteKoshiLocatorDisplay(engine, { data }), null);
assert.ok(
createBhoteKoshiLocatorDisplay(engine, {
data,
drawStory: () => ({ destroy() {} }),
}),
);
});
-67
View File
@@ -1,67 +0,0 @@
import { Texture, LinearFilter } from 'three';
import { createImageryGround } from '../../recentImagery/render/navaraGround.js';
import { drawStorySource } from '../../shared/render/navaraStory.js';
export function createNepalEventDisplay(engine, native) {
const owner = { slots: { a: null, b: null }, preview: null, split: 0.5 };
const ground = createImageryGround(engine, native, owner, 'gevNepalEvent');
let disposed = false;
function clear() {
for (const id of ['a', 'b']) {
owner.slots[id]?.atlas.texture.dispose();
owner.slots[id] = null;
}
if (!disposed) ground.sync();
}
return {
draw: (data) => drawStorySource(engine, native, data),
async loadImages(box, providers) {
const images = await Promise.all(
providers.map((p) => p.requestImage(0, 0, 0)),
);
if (disposed) return;
clear();
for (const [i, id] of ['a', 'b'].entries()) {
const texture = new Texture(images[i]);
texture.flipY = false;
texture.minFilter = texture.magFilter = LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
owner.slots[id] = {
atlas: {
texture,
plan: {
geographic: true,
bounds: [
box.west,
box.north,
box.east - box.west,
box.south - box.north,
],
},
},
box,
alpha: 0.96,
split: i === 0 ? 'left' : 'right',
};
}
ground.sync();
engine.render.request('nepal-imagery');
},
setVisible(show) {
for (const slot of Object.values(owner.slots))
if (slot) slot.alpha = show ? 0.96 : 0;
engine.render.request('nepal-imagery');
},
setSplit(x) {
owner.split = x;
engine.render.request('nepal-split');
},
clear,
destroy() {
disposed = true;
clear();
ground.destroy();
},
};
}
@@ -1,147 +0,0 @@
import { Uniform, Matrix4, DataTexture } from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js';
// Two images under a swipe on a Navara surface pass. Recent Imagery draws
// through engine.imagery now; the Nepal event imagery (bhoteKoshi) still
// draws here until it moves there too.
const effects = new Map();
const shader = /* glsl */ `
uniform sampler2D tAllDepth,tSurfaceDepth,tA,tB;
uniform mat4 uInverseProjection,uWorld;
uniform float uLogDepthFC,uSplit,uPixelRatio;
uniform vec4 uBoundsA,uBoundsB,uBoxA,uBoxB,uPreview;
uniform vec2 uLookA,uLookB;
uniform bool uPreviewVisible,uGeographicA,uGeographicB;
vec3 displayColor(vec3 c) {
return mix(c*12.92,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 linearColor(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
bool inBox(vec2 geo,vec4 box) { return geo.x>=box.x && geo.y>=box.y && geo.x<=box.z && geo.y<=box.w; }
vec3 imageColor(vec3 color,sampler2D image,vec4 bounds,vec4 box,vec2 look,vec2 geo,vec2 mercator,float x,bool geographic) {
if(look.x<=0.0 || !inBox(geo,box)) return color;
if((look.y<0.0 && x>uSplit)||(look.y>0.0 && x<uSplit)) return color;
vec2 at=((geographic?geo:mercator)-bounds.xy)/bounds.zw;
if(any(lessThan(at,vec2(0.0)))||any(greaterThan(at,vec2(1.0)))) return color;
vec4 pixel=texture2D(image,at);
return mix(color,pixel.rgb,pixel.a*look.x);
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
if(depth>=1.0 || depth<texture2D(tSurfaceDepth,uv).r-2e-7) return;
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=(uWorld*vec4(ray.xyz*(distance/-ray.z),1.0)).xyz;
float p=length(point.xy),theta=atan(point.z*6378137.0,p*6356752.314245);
float st=sin(theta),ct=cos(theta);
float latitude=atan(point.z+42841.311513*st*st*st,p-42697.672707*ct*ct*ct);
vec2 geo=vec2(atan(point.y,point.x),latitude)*57.29577951308232;
vec2 mercator=vec2((geo.x+180.0)/360.0,(1.0-log(tan(0.7853981633974483+latitude*0.5))/3.141592653589793)/2.0);
vec3 color=displayColor(inputColor.rgb);
color=imageColor(color,tA,uBoundsA,uBoxA,uLookA,geo,mercator,uv.x,uGeographicA);
color=imageColor(color,tB,uBoundsB,uBoxB,uLookB,geo,mercator,uv.x,uGeographicB);
if(uPreviewVisible && inBox(geo,uPreview)) {
vec2 pixelWidth=max(fwidth(geo),vec2(1e-7));
vec2 edges=min(geo-uPreview.xy,uPreview.zw-geo)/pixelWidth;
float edge=1.0-step(2.0*uPixelRatio,min(edges.x,edges.y));
float dash=step(0.5,fract((gl_FragCoord.x+gl_FragCoord.y)/(12.0*uPixelRatio)));
color=mix(color,vec3(0.545,0.914,1.0),mix(0.12,0.95,edge*dash));
}
outputColor=vec4(linearColor(color),inputColor.a);
}
`;
const boxVector = (b) =>
b ? [b.west, b.south, b.east, b.north] : [0, 0, 0, 0];
// Over engine.imagery's layers (src/globe/navara/imagery/pass.js).
const imageryEffect = (key) =>
defineGroundEffect({
key,
name: 'GevEventImagery',
fragmentShader: shader,
insertAfter: ['gevImagery', 'toneMapping'],
insertBefore: [
'gevObservedWeather',
'gevEarthquakeGround',
'gevGround',
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
],
uniforms: () => [
...groundSurfaceUniforms(),
['uGeographicA', new Uniform(false)],
['uGeographicB', new Uniform(false)],
['uWorld', new Uniform(new Matrix4())],
['uSplit', new Uniform(0.5)],
['uPixelRatio', new Uniform(1)],
['uPreview', new Uniform([0, 0, 0, 0])],
['uPreviewVisible', new Uniform(false)],
...['A', 'B'].flatMap((id) => [
[`t${id}`, new Uniform(null)],
[`uBounds${id}`, new Uniform([0, 0, 1, 1])],
[`uBox${id}`, new Uniform([0, 0, 0, 0])],
[`uLook${id}`, new Uniform([0, 0])],
]),
],
update() {
if (!this.effect) return;
const camera = this.view.camera.raw,
u = this.effect.uniforms,
o = this.owner;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
u.get('uWorld').value.copy(camera.matrixWorld);
u.get('uSplit').value = o.split;
u.get('uPixelRatio').value = o.pixelRatio();
u.get('uPreview').value = boxVector(o.preview);
u.get('uPreviewVisible').value = Boolean(o.preview);
for (const id of ['A', 'B']) {
const r = o.slots[id.toLowerCase()];
u.get(`uGeographic${id}`).value = r?.atlas?.plan.geographic === true;
u.get(`t${id}`).value = r?.atlas?.texture || o.empty;
u.get(`uBounds${id}`).value = r?.atlas?.plan.bounds || [0, 0, 1, 1];
u.get(`uBox${id}`).value = boxVector(r?.box);
u.get(`uLook${id}`).value = [
r?.atlas ? r.alpha : 0,
r?.split === 'left' ? -1 : r?.split === 'right' ? 1 : 0,
];
}
},
});
export function createImageryGround(
engine,
{ view, EffectDesc, NavaraEffect },
owner,
key,
) {
if (!effects.has(key)) effects.set(key, imageryEffect(key));
effects.get(key).register(view, { EffectDesc, NavaraEffect });
owner.empty = new DataTexture(new Uint8Array(4), 1, 1);
owner.empty.needsUpdate = true;
owner.pixelRatio = () =>
engine.host.canvas.width / Math.max(1, engine.host.size().width);
let handle = null;
return {
sync() {
const visible = Boolean(owner.slots.a || owner.slots.b || owner.preview);
if (visible && !handle)
handle = view.addEffect({ [key]: { owner }, visible: true });
else handle?.update({ visible });
},
destroy() {
handle?.delete();
handle = null;
owner.empty.dispose();
},
};
}
@@ -1,36 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import { createImageryGround } from '../../recentImagery/render/navaraGround.js';
// EffectDesc gives insertAfter priority, then tries insertBefore. Check the
// actual renderer declarations under every activation order, including the
// minimal look, whose pipeline has no toneMapping anchor.
const declarations = [
'gevImagery',
'gevNepalEvent',
'gevObservedWeather',
'gevGround',
'gevPreSharpenOverlay',
];
// The descriptor a renderer registers; its setup stops there.
const stop = new Error('registered');
function registered(create) {
let spec;
const view = {
camera: {},
registerEffect(key, Desc) {
spec = { key, after: Desc.insertAfter, before: Desc.insertBefore };
throw stop;
},
};
try {
create({ view, EffectDesc: class {}, NavaraEffect: class {} });
} catch (error) {
if (error !== stop) throw error;
}
return spec;
}
const declarations = ['gevImagery', 'gevGround', 'gevPreSharpenOverlay'];
async function declared(key, path, className) {
let source = await fs.readFile(new URL(path, import.meta.url), 'utf8');
if (className) source = source.split(`class ${className}`)[1];
@@ -52,8 +27,6 @@ const specs = [
'../../../globe/navara/imagery/pass.js',
'ImageryEffectDesc',
),
registered((sdk) => createImageryGround({}, sdk, {}, 'gevNepalEvent')),
await declared('gevObservedWeather', '../../weather/render/navaraSurface.js'),
await declared(
'gevGround',
'../../../globe/navara/ground/pass.js',
+10
View File
@@ -9,6 +9,16 @@ export function resolveImageryHost({ viewer, tileset }) {
return { collection: null, kind: 'none' };
}
// What the layer calls a surface: engine.imagery's surfaces as host kinds.
const HOST_KIND = { globe: 'globe', tiles: 'tileset', none: 'none' };
/** Where an engine drapes imagery (engine.imagery.surface()), as a host;
* the surface itself identifies it. */
export function engineImageryHost(engine) {
const surface = engine.imagery.surface();
return { kind: HOST_KIND[surface] ?? 'none', collection: surface };
}
export const NO_IMAGERY_HOST = msg(
'Hidden by this map source · choose a globe map',
);
+15 -11
View File
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import { engineImageryHost, imageryHostStatus } from './imageryHost.js';
import { weatherDisplay, insideExtent } from './display.js';
import {
RADAR_MAX_GAP_MS,
@@ -107,12 +107,12 @@ export function createWeatherLayer({
: REGIONAL_INFRARED_MAX_GAP_MS;
const isLatest = () =>
clock ? clock.getState().mode === 'latest' : followLatest;
// A display draws through engine.imagery, which follows the surface the
// map shows; the Cesium renderer of old resolves Cesium's imagery stack.
let drawsOnEngine = false;
const getHost = () =>
sceneEngine?.kind === 'navara'
? {
kind: sceneEngine.maps.photorealActive() ? 'tileset' : 'globe',
collection: sceneEngine,
}
drawsOnEngine
? engineImageryHost(sceneEngine)
: (imageryHost?.() ?? {
collection: viewer?.imageryLayers,
kind: 'globe',
@@ -313,12 +313,15 @@ export function createWeatherLayer({
viewer = nextViewer;
sceneEngine = engine;
const options = { viewer, getHost, onChange: notify };
// The SDK is read here, on Cesium only: the production build's Cesium
// global loads with the first Cesium globe.
// The engine's display (render/display.js) when the layer has one;
// else the Cesium renderer, whose SDK is read here: the production
// build's Cesium global loads with the first Cesium globe.
const display = engine
? (createNativeRendering?.(engine, options) ?? null)
: null;
drawsOnEngine = display !== null;
rendering =
engine?.kind === 'navara'
? createNativeRendering(engine, options)
: createRendering({ ...options, cesium: cesium ?? Cesium });
display ?? createRendering({ ...options, cesium: cesium ?? Cesium });
checkHost(false);
eventTarget?.addEventListener?.(
'gev:map-stack-changed',
@@ -351,6 +354,7 @@ export function createWeatherLayer({
);
rendering?.clear();
rendering = null;
drawsOnEngine = false;
hostCollection = null;
imageryHost = null;
runNavigation = null;
+711
View File
@@ -0,0 +1,711 @@
// The observed weather rasters (rain radar, satellite clouds, lightning
// density) on the globe contract, the same on every engine. Where imagery
// drapes on the globe, each frame is one engine.imagery layer: the
// product's geographic tiles (the regional infrared through the infrared
// mask, a tile transform) or, for the global infrared, its decoded mosaic
// as one image. A new frame is a staged source: the shown frame stays
// until the next one's tiles are in, then the layer swaps them. On 3D
// tiles the frame is a raised layer at the product's shell height
// (shellRendering.js decides the frames, the detail window around the view
// and the prefetch; here a shell surface is one raised layer, its detail
// the layer's detail). A host switch tears one down and restages the shown
// frame on the other. Lower products draw first: clouds, radar, lightning.
import { weatherTileUrl } from '../source.js';
import { engineImageryHost, imageryHostStatus } from '../imageryHost.js';
import {
createWeatherShell,
WEATHER_SHELL_HEIGHTS,
} from '../shellRendering.js';
import { weatherDisplay, escapeCreditText } from '../display.js';
import { msg } from '../../../i18n/index.js';
import { readResponseBytesCapped } from '../../../sources/httpBody.js';
import {
acquireInfraredMosaic,
processInfraredImage,
} from '../infraredImage.js';
import * as geo from '../../../geo/index.js';
/** Imagery order of each product (and the wind field under them), over
* Recent Imagery and the event imagery. */
export const WEATHER_IMAGERY_ORDER = Object.freeze({
wind: 20,
clouds: 21,
'clouds-regional': 21,
radar: 22,
lightning: 23,
});
// Bounded display detail for the hourly, approximately 3 km global product.
const GLOBAL_TILE_MAXIMUM_LEVEL = 3;
// Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB.
const MAX_MOSAICS = 6;
const MAX_PREFETCH_TILES = 8;
const TILE_SIZE = 256;
// A source that draws nothing, until the first frame is in.
const EMPTY_IMAGE = Object.freeze({
width: 1,
height: 1,
data: new Uint8Array(4),
});
let layers = 0;
function creditText(snapshot) {
const sourceCredit = weatherDisplay(snapshot).credit;
return sourceCredit !== undefined
? escapeCreditText(sourceCredit)
: snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners';
}
// Radians back to degrees, without the round trip's last bits.
const degree = (radians) => Math.round(geo.math.toDegrees(radians) * 1e9) / 1e9;
const degreesOf = (rectangle) => ({
west: degree(rectangle.west),
south: degree(rectangle.south),
east: degree(rectangle.east),
north: degree(rectangle.north),
});
const boundsOf = ({ west, south, east, north }) => ({
west,
south,
east,
north,
});
/** Draped frames: one imagery layer, each new frame staged over the shown one. */
function createDrapedWeather(
engine,
{ onChange, timeoutMs, now, fetchImpl, decodeImage, createCanvas },
) {
const id = `weather-${++layers}`;
let layer = null;
let current = null;
let incoming = null;
let alpha = 0.7;
let hidden = false;
let lastError = null;
const mosaics = new Map();
let prefetchJob = null;
let prefetchedKey = null;
const suspended = () => engine.imagery.surface() === 'none';
function cancelPrefetch() {
clearTimeout(prefetchJob?.timeout);
prefetchJob?.controller.abort();
prefetchJob = null;
prefetchedKey = null;
}
async function mosaic(time, mode, signal, onFetched) {
signal.throwIfAborted();
const key = `${time}|${mode}`;
const texture = mosaics.get(key);
if (texture) {
mosaics.delete(key);
mosaics.set(key, texture);
return { texture, decodeMs: 0, cached: true };
}
const result = await acquireInfraredMosaic(time, {
signal,
mode,
createCanvas,
fetchImpl,
decodeImage,
now,
onFetched,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
mosaics.delete(key);
mosaics.set(key, result.texture);
while (mosaics.size > MAX_MOSAICS)
mosaics.delete(mosaics.keys().next().value);
return { ...result, cached: false };
}
// Up to eight geographic tiles of levels 0 and 1 over the view.
function prefetchTiles(snapshot, time) {
const box = boundsOf(snapshot.bounds);
const view = engine.camera.viewRectangle();
const area = view ? degreesOf(view) : box;
const cover =
view && area.east >= area.west
? {
west: Math.max(box.west, area.west),
east: Math.min(box.east, area.east),
south: Math.max(box.south, area.south),
north: Math.min(box.north, area.north),
}
: box;
if (cover.west >= cover.east || cover.south >= cover.north) return [];
const template = weatherTileUrl(snapshot.product, time, {
size: TILE_SIZE,
});
const urls = [];
for (let z = 0; z <= 1; z++) {
const span = 180 / 2 ** z;
for (let y = 0; y < 2 ** z; y++)
for (let x = 0; x < 2 ** (z + 1); x++) {
const west = -180 + x * span;
const north = 90 - y * span;
if (
west >= cover.east ||
west + span <= cover.west ||
north <= cover.south ||
north - span >= cover.north
)
continue;
urls.push(
template.replace('{z}', z).replace('{x}', x).replace('{y}', y),
);
if (urls.length === MAX_PREFETCH_TILES) return urls;
}
}
return urls;
}
function ensureLayer(snapshot) {
if (layer) return layer;
layer = engine.imagery.createLayer({
id,
order: WEATHER_IMAGERY_ORDER[snapshot.product] ?? 21,
alpha,
show: !hidden,
source: {
kind: 'image',
image: EMPTY_IMAGE,
rectangle: boundsOf(snapshot.bounds),
},
});
return layer;
}
// A frame's source: its tiles (through the infrared mask) or its mosaic.
function sourceOf(frame, texture) {
const credit = creditText(frame.creditSnapshot ?? frame.snapshot);
const rectangle = boundsOf(frame.snapshot.bounds);
if (texture) return { kind: 'image', image: texture, rectangle, credit };
return {
kind: 'tiles',
url: weatherTileUrl(frame.product, frame.time, { size: TILE_SIZE }),
scheme: 'geographic',
tileSize: TILE_SIZE,
maxLevel: frame.product === 'clouds' ? GLOBAL_TILE_MAXIMUM_LEVEL : 6,
rectangle,
credit,
transform: frame.transform,
};
}
/**
* Take the credit of a refreshed manifest of the same product: the shown
* frame keeps its pixels and changes its credit; a frame still loading
* takes it once it is shown.
*/
function updateSnapshot(snapshot) {
if (incoming?.product === snapshot.product)
incoming.creditSnapshot = snapshot;
if (!current || current.product !== snapshot.product) return;
current.creditSnapshot = snapshot;
const source = sourceOf(current, current.texture);
if (source.credit !== layer?.source().credit) layer?.setSource(source);
}
function cancelIncoming() {
if (!incoming) return;
const frame = incoming;
incoming = null;
clearTimeout(frame.timeout);
frame.offAbort?.();
frame.controller.abort();
frame.resolve(false);
}
function finish(frame, ok) {
if (incoming !== frame) return;
incoming = null;
clearTimeout(frame.timeout);
frame.offAbort?.();
if (ok) {
lastError = null;
frame.loadMs = now() - frame.startedAt;
current = frame;
layer.setAlpha(alpha);
layer.show(!hidden);
// A refresh that arrived while the frame loaded.
updateSnapshot(frame.creditSnapshot ?? frame.snapshot);
} else {
lastError = msg('Weather tiles unavailable · previous frame retained');
frame.controller.abort();
}
frame.resolve(ok);
engine.render.request('weather');
onChange();
}
return {
rehome() {
if (suspended()) {
cancelPrefetch();
cancelIncoming();
}
return false;
},
cancelPrefetch,
updateSnapshot,
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
let job;
try {
if (hidden || incoming || suspended() || !snapshot.times.includes(time))
return false;
const global = snapshot.product === 'clouds';
const urls = global ? [] : prefetchTiles(snapshot, time);
const key = global ? `${time}|${infrared}` : urls.join('|');
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
prefetchJob = job;
const { signal } = job.controller;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
if (global) await mosaic(time, infrared, signal);
else
await Promise.all(
urls.map(async (url) => {
const response = await fetchImpl(url, { signal });
if (!response.ok)
throw new Error(msg('Weather prefetch unavailable'));
await readResponseBytesCapped(response, 4 * 1024 * 1024 + 65_536);
}),
);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
return true;
} catch {
job?.controller.abort();
return false;
} finally {
clearTimeout(job?.timeout);
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(
snapshot,
time,
{ signal, infrared = 'filtered', creditSnapshot } = {},
) {
signal?.throwIfAborted();
cancelPrefetch();
cancelIncoming();
if (suspended()) return false;
if (
current?.time === time &&
current.product === snapshot.product &&
current.infrared === infrared
) {
updateSnapshot(creditSnapshot ?? snapshot);
return true;
}
lastError = null;
const global = snapshot.product === 'clouds';
const frame = {
snapshot,
creditSnapshot,
time,
infrared,
product: snapshot.product,
texture: null,
// The regional infrared mask, the same function for every tile.
transform:
snapshot.product === 'clouds-regional'
? (image) => processInfraredImage(image, infrared, createCanvas)
: null,
mosaic: global
? { fetched: false, decodeMs: null, cached: false }
: undefined,
controller: new AbortController(),
startedAt: now(),
resolve: null,
};
incoming = frame;
const result = new Promise((resolve) => {
frame.resolve = resolve;
});
const abort = () => {
if (incoming === frame) cancelIncoming();
engine.render.request('weather');
};
signal?.addEventListener('abort', abort, { once: true });
frame.offAbort = () => signal?.removeEventListener('abort', abort);
frame.timeout = setTimeout(() => finish(frame, false), timeoutMs);
const stage = (texture = null) => {
if (incoming !== frame) return;
frame.texture = texture;
ensureLayer(snapshot)
.setSource(sourceOf(frame, texture), {
staged: true,
signal: frame.controller.signal,
})
.then((ok) => finish(frame, ok));
engine.render.request('weather');
onChange();
};
if (global)
void mosaic(time, infrared, frame.controller.signal, () => {
frame.mosaic.fetched = true;
})
.then(({ texture, decodeMs, cached }) => {
frame.mosaic.decodeMs = decodeMs;
frame.mosaic.cached = cached;
stage(texture);
})
.catch(() => finish(frame, false));
else stage();
onChange();
return result;
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) {
cancelPrefetch();
cancelIncoming();
}
layer?.show(!hidden && current !== null);
engine.render.request('weather');
},
setAlpha(value) {
alpha = value;
if (current) layer?.setAlpha(alpha);
engine.render.request('weather');
},
clear() {
cancelPrefetch();
cancelIncoming();
layer?.destroy();
layer = null;
current = null;
hidden = false;
lastError = null;
mosaics.clear();
engine.render.request('weather');
},
getDiagnostics() {
return {
host: 'globe',
cache: { mosaics: mosaics.size, prefetching: !!prefetchJob },
imageryCount: Number(!!current) + Number(!!incoming),
mosaic: (incoming || current)?.mosaic,
infrared: (incoming || current)?.infrared ?? 'filtered',
loading: !!incoming,
time: hidden ? null : (current?.time ?? null),
hidden,
product: current?.product ?? null,
frameLoadMs: current?.loadMs ?? null,
error: imageryHostStatus(engineImageryHost(engine)) || lastError,
};
},
};
}
/**
* One shell surface of shellRendering.js as a raised imagery layer at the
* product's height: its image a staged source, its detail the layer's.
* `onSettled` runs once the latest image (and detail) draws.
*/
export function createRaisedWeatherSurface(
engine,
product,
{ rectangle, height, onSettled = () => {} },
) {
const box = degreesOf(rectangle);
const layer = engine.imagery.createLayer({
id: `weather-shell-${++layers}`,
order: WEATHER_IMAGERY_ORDER[product] ?? 21,
raise: height,
alpha: 0,
source: { kind: 'image', image: EMPTY_IMAGE, rectangle: box },
});
let image = null;
let drawnImage = null;
let detail = null;
let detailWindow = null;
let drawnDetail = null;
let destroyed = false;
const settle = () => {
if (
!destroyed &&
image !== null &&
drawnImage === image &&
drawnDetail === detail
)
onSettled();
};
return {
setImage(next) {
if (destroyed || next === image) return;
image = next;
layer
.setSource(
{ kind: 'image', image: next, rectangle: box },
{ staged: true },
)
.then((ok) => {
if (!ok || image !== next) return;
drawnImage = next;
settle();
});
},
setAlpha(value) {
if (!destroyed) layer.setAlpha(value);
},
/** `rect` is west, south, east, north in the image's texture coordinates. */
setDetail(next, rect) {
if (destroyed) return;
if (!next) {
if (detail === null) return;
detail = drawnDetail = detailWindow = null;
layer.setDetail(null);
return;
}
if (next === detail && detailWindow && rect) {
const same = ['west', 'south', 'east', 'north'].every(
(edge) => detailWindow[edge] === rect[edge],
);
if (same) return;
}
detail = next;
detailWindow = rect;
const x = box.east - box.west;
const y = box.north - box.south;
layer
.setDetail({
image: next,
rectangle: {
west: box.west + rect.west * x,
south: box.south + rect.south * y,
east: box.west + rect.east * x,
north: box.south + rect.north * y,
},
})
.then((ok) => {
if (!ok || detail !== next) return;
drawnDetail = next;
settle();
});
},
/** Returns whether visibility changed. */
setShow(value) {
const show = Boolean(value);
if (destroyed || layer.shown() === show) return false;
layer.show(show);
return true;
},
destroy() {
if (destroyed) return;
destroyed = true;
layer.destroy();
image = drawnImage = detail = drawnDetail = null;
},
getDiagnostics: () => ({
height,
ready: drawnImage !== null,
uploaded: image !== null && drawnImage === image,
show: !destroyed && layer.shown(),
alpha: destroyed ? 0 : layer.alpha(),
rendering: !destroyed && layer.staging(),
detail: {
uploaded: detail !== null && drawnDetail === detail,
window: detail !== null && drawnDetail === detail ? detailWindow : null,
},
}),
};
}
/**
* The weather layer's renderer on any engine (index.js): draped frames
* where imagery drapes on the globe, raised shells on 3D tiles.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ onChange?: () => void, timeoutMs?: number, now?: () => number,
* fetchImpl?: typeof fetch, decodeImage?: Function,
* createCanvas?: () => HTMLCanvasElement }} [options]
*/
export function createWeatherDisplay(
engine,
{
onChange = () => {},
timeoutMs = 25_000,
now = () => performance.now(),
fetchImpl = (...args) => globalThis.fetch(...args),
decodeImage,
createCanvas = () => document.createElement('canvas'),
} = {},
) {
if (!engine?.imagery)
throw new TypeError('The weather display needs a globe engine');
const shared = {
onChange,
timeoutMs,
now,
fetchImpl,
decodeImage,
createCanvas,
};
const getHost = () => engineImageryHost(engine);
const globe = createDrapedWeather(engine, shared);
let shell = null;
let active = null;
let alpha = 0.7;
let hidden = false;
let shown = null;
let restaging = null;
// The last refreshed manifest, so a frame shown after it keeps its credit.
let refreshed = null;
// A host without imagery keeps the active renderer, which retains its frame.
const wanted = () => {
const { kind } = getHost();
return kind === 'tileset'
? 'shell'
: kind === 'globe'
? 'globe'
: (active ?? 'globe');
};
const renderer = () => (active === 'shell' ? shell : globe);
function shellFor(product) {
if (shell?.product === product) return shell;
shell?.clear();
shell = createWeatherShell({
...shared,
cesium: null,
product,
height: WEATHER_SHELL_HEIGHTS[product],
getHost,
requestRender: () => engine.render.request('weather'),
viewRectangle: () => engine.camera.viewRectangle(),
moveEnd: engine.camera.moveEnd,
createSurface: (frame) =>
createRaisedWeatherSurface(engine, product, frame),
});
shell.setAlpha(alpha);
shell.setHidden(hidden);
return shell;
}
function restage(frame) {
const target =
active === 'shell' ? shellFor(frame.snapshot.product) : globe;
restaging = frame;
const settle = (ok) => {
if (restaging !== frame) return;
restaging = null;
if (!ok) onChange();
};
// The restaged frame takes the newest refreshed credit.
target
.setFrame(frame.snapshot, frame.time, {
infrared: frame.infrared,
...(target === globe ? { creditSnapshot: shown?.latest } : {}),
})
.then(settle, () => settle(false));
}
function switchHost() {
const next = wanted();
if (next === active) return false;
const previous = active;
active = next;
if (previous === null) return false;
restaging = null;
if (previous === 'shell') {
shell?.clear();
shell = null;
} else globe.clear();
if (active === 'globe') {
globe.setAlpha(alpha);
if (hidden) globe.setHidden(true);
}
if (shown && !hidden) restage(shown);
engine.render.request('weather');
return true;
}
return {
rehome() {
const switched = switchHost();
if (active === 'shell' && !shell) return switched;
return renderer().rehome() || switched;
},
cancelPrefetch() {
renderer()?.cancelPrefetch();
},
prefetch(snapshot, time, options) {
switchHost();
if (active === 'shell' && shell?.product !== snapshot.product)
return Promise.resolve(false);
return renderer().prefetch(snapshot, time, options);
},
async setFrame(snapshot, time, options = {}) {
options.signal?.throwIfAborted();
switchHost();
restaging = null;
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
const before = refreshed;
const ok = await target.setFrame(snapshot, time, options);
if (ok) {
// Keep a refresh that arrived while this frame loaded.
const latest =
refreshed !== before && refreshed?.product === snapshot.product
? refreshed
: undefined;
shown = {
snapshot,
time,
infrared: options.infrared ?? 'filtered',
...(latest ? { latest } : {}),
};
}
return ok;
},
// The shown frame keeps its staged snapshot for a restage on another
// host; only the refreshed credit is remembered and applied.
updateSnapshot(snapshot) {
refreshed = snapshot;
if (shown?.snapshot.product === snapshot.product)
shown = { ...shown, latest: snapshot };
globe.updateSnapshot(snapshot);
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) restaging = null;
renderer()?.setHidden(hidden);
},
setAlpha(value) {
alpha = value;
renderer()?.setAlpha(value);
},
clear() {
restaging = null;
shown = null;
refreshed = null;
hidden = false;
shell?.clear();
shell = null;
globe.clear();
},
getDiagnostics() {
const diagnostics =
active === 'shell' && shell
? shell.getDiagnostics()
: active === 'shell'
? {
host: 'shell',
cache: { mosaics: 0, bytes: 0, prefetching: false },
imageryCount: 0,
infrared: 'filtered',
loading: false,
time: null,
hidden,
product: null,
frameLoadMs: null,
error: imageryHostStatus(getHost()),
}
: globe.getDiagnostics();
// A host switch restages the retained frame; keep reporting its time.
return restaging && !hidden && diagnostics.time === null
? { ...diagnostics, time: restaging.time }
: diagnostics;
},
};
}
+115
View File
@@ -0,0 +1,115 @@
// The weather display on the fake engine: draped frames as staged
// geographic tiles, a refreshed credit in place, raised shells on 3D tiles.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { WEATHER_SHELL_HEIGHTS } from '../shellRendering.js';
import { WEATHER_IMAGERY_ORDER, createWeatherDisplay } from './display.js';
const T1 = '2026-11-07T12:00:00.000Z';
const T2 = '2026-11-07T12:10:00.000Z';
const BOUNDS = { west: -130, south: 20, east: -60, north: 55 };
const snapshot = (product, extra = {}) => ({
schemaVersion: 1,
product,
bounds: BOUNDS,
times: [T1, T2],
latest: T2,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
...extra,
});
const layerOf = (engine) => {
const [id] = engine.fake.imageryLayers();
return id ? engine.fake.imageryLayer(id) : undefined;
};
test('a draped frame is the product’s geographic tiles, staged over the shown one', async () => {
const engine = createFakeEngine();
const display = createWeatherDisplay(engine, {});
display.setAlpha(0.8);
assert.equal(await display.setFrame(snapshot('radar'), T1), true);
const first = layerOf(engine);
assert.equal(first.source.kind, 'tiles');
assert.equal(first.source.scheme, 'geographic');
assert.equal(first.source.maxLevel, 6);
assert.equal(first.source.transform, null);
assert.match(first.source.url, /\/api\/weather\/tile\?product=radar&time=/);
assert.match(first.source.url, /&z=\{z\}&x=\{x\}&y=\{y\}&size=256$/);
assert.deepEqual({ ...first.source.rectangle }, BOUNDS);
assert.equal(first.source.credit, 'NOAA nowCOAST · NWS/OAR MRMS');
assert.equal(first.alpha, 0.8);
assert.equal(first.options.order, WEATHER_IMAGERY_ORDER.radar);
assert.equal(display.getDiagnostics().time, T1);
// The next frame replaces it in the same layer.
assert.equal(await display.setFrame(snapshot('radar'), T2), true);
assert.equal(engine.fake.imageryLayers().length, 1);
assert.match(layerOf(engine).source.url, /12%3A10/);
// A refreshed manifest's credit, in place.
display.updateSnapshot(
snapshot('radar', { display: { credit: 'NOAA MRMS · refreshed' } }),
);
assert.equal(layerOf(engine).source.credit, 'NOAA MRMS · refreshed');
assert.match(layerOf(engine).source.url, /12%3A10/);
display.setHidden(true);
assert.equal(layerOf(engine).shown, false);
display.clear();
assert.deepEqual(engine.fake.imageryLayers(), []);
});
test('the regional infrared masks each tile; a newer frame wins over one loading', async () => {
const engine = createFakeEngine();
const display = createWeatherDisplay(engine, {});
const older = display.setFrame(snapshot('clouds-regional'), T1, {
infrared: 100,
});
const newer = display.setFrame(snapshot('clouds-regional'), T2, {
infrared: 100,
});
assert.equal(await older, false);
assert.equal(await newer, true);
const { source } = layerOf(engine);
assert.equal(typeof source.transform, 'function');
assert.match(source.url, /product=clouds-regional/);
});
test('with no surface the frame waits; on 3D tiles it is a raised shell at the product’s height', async () => {
const engine = createFakeEngine();
let surface = 'none';
engine.imagery.surface = () => surface;
const canvases = [];
const display = createWeatherDisplay(engine, {
fetchImpl: async () => new Response(new Uint8Array([1, 2, 3])),
decodeImage: async () => ({ width: 4096, height: 2048, close() {} }),
createCanvas: () => {
const canvas = {
width: 0,
height: 0,
getContext: () => ({ drawImage() {} }),
};
canvases.push(canvas);
return canvas;
},
});
assert.equal(await display.setFrame(snapshot('radar'), T1), false);
assert.deepEqual(engine.fake.imageryLayers(), []);
surface = 'tiles';
display.rehome();
assert.equal(await display.setFrame(snapshot('radar'), T1), true);
const shell = layerOf(engine);
assert.equal(shell.raise, WEATHER_SHELL_HEIGHTS.radar);
assert.equal(shell.source.kind, 'image');
assert.equal(shell.source.image, canvases[0]);
assert.deepEqual({ ...shell.source.rectangle }, BOUNDS);
assert.equal(display.getDiagnostics().host, 'shell');
// Back on the globe: the shown frame is staged again as tiles.
surface = 'globe';
display.rehome();
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const draped = layerOf(engine);
assert.equal(draped.raise, null);
assert.equal(draped.source.kind, 'tiles');
display.clear();
});
-63
View File
@@ -1,63 +0,0 @@
import { createWeatherShell } from '../shellRendering.js';
import { escapeCreditText, weatherDisplay } from '../display.js';
import { createWeatherSurface } from './navaraSurface.js';
/** Use the same decoded frames, detail windows, infrared mask and prefetch policy as the weather shell. */
export function createWeatherNavaraRenderer(engine, native, options) {
let shell = null,
alpha = 0.7,
hidden = false,
removeCredit = null,
credit = null;
const updateSnapshot = (snapshot) => {
const next = weatherDisplay(snapshot).credit ?? null;
if (next === credit) return;
removeCredit?.();
credit = next;
removeCredit = next
? engine.credits.addStatic(escapeCreditText(next))
: null;
};
const select = (product) => {
if (shell?.product === product) return shell;
shell?.clear();
shell = createWeatherShell({
...options,
product,
requestRender: () => engine.render.request('weather'),
viewRectangle: () => engine.camera.viewRectangle(),
moveEnd: engine.camera.moveEnd,
createSurface: (frame) =>
createWeatherSurface(engine, native, product, frame),
});
shell.setAlpha(alpha);
shell.setHidden(hidden);
return shell;
};
return {
setFrame(snapshot, time, params) {
updateSnapshot(snapshot);
return select(snapshot.product).setFrame(snapshot, time, params);
},
updateSnapshot,
prefetch: (...args) => shell?.prefetch(...args),
cancelPrefetch: () => shell?.cancelPrefetch(),
rehome: () => shell?.rehome(),
setHidden(value) {
hidden = value;
shell?.setHidden(value);
},
setAlpha(value) {
alpha = value;
shell?.setAlpha(value);
},
getDiagnostics: () =>
shell?.getDiagnostics() ?? { time: null, loading: false },
clear() {
shell?.clear();
shell = null;
removeCredit?.();
removeCredit = credit = null;
},
};
}
-265
View File
@@ -1,265 +0,0 @@
import { Effect, EffectAttribute } from 'postprocessing';
import {
CanvasTexture,
DataTexture,
LinearFilter,
Matrix4,
Uniform,
} from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
import { shellLift } from '../shellRendering.js';
const KEY = 'gevObservedWeather';
const owners = new WeakMap();
const products = ['Wind', 'Satellite', 'Radar', 'Lightning'];
const slotFor = (p) =>
p === 'wind'
? 'Wind'
: p === 'radar'
? 'Radar'
: p === 'lightning'
? 'Lightning'
: 'Satellite';
const emptyRect = [0, 0, -1, -1];
const rectArray = (r) => (r ? [r.west, r.south, r.east, r.north] : emptyRect);
const shader = /* glsl */ `
uniform sampler2D tAllDepth,tSurfaceDepth;
uniform mat4 uInverseProjection,uWorld;
uniform float uLogDepthFC;
uniform bool uRaised;
${products
.map(
(p) => `uniform sampler2D t${p},tDetail${p};
uniform vec4 uBounds${p},uWindow${p};
uniform vec2 uLook${p};`,
)
.join('\n')}
vec3 toDisplay(vec3 x) {
return mix(12.92*x,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x));
}
vec3 toLinear(vec3 x) {
return mix(x/12.92,pow((x+0.055)/1.055,vec3(2.4)),step(vec3(0.04045),x));
}
vec2 geographic(vec3 point) {
float r=length(point.xy),theta=atan(point.z*6378137.0,r*6356752.314245);
float s=sin(theta),c=cos(theta);
return vec2(atan(point.y,point.x),atan(point.z+42841.311513*s*s*s,r-42697.672707*c*c*c));
}
vec3 paint(vec3 color,sampler2D image,sampler2D detail,vec4 bounds,vec4 window,vec2 look,
vec3 ray,float sceneRange,bool onSurface) {
if(look.x<=0.0) return color;
vec3 origin=uWorld[3].xyz;
float range=sceneRange;
if(uRaised) {
vec3 radii=vec3(6378137.0,6378137.0,6356752.314245)+look.y;
vec3 o=origin/radii,d=ray/radii;
float a=dot(d,d),b=dot(o,d),c=dot(o,o)-1.0;
float disc=b*b-a*c;
if(disc<0.0) return color;
float root=sqrt(disc);
range=(-b-root)/a;
if(range<=0.0) range=(-b+root)/a;
if(range<=0.0 || range>sceneRange+1.0) return color;
} else if(!onSurface) return color;
vec2 uv=(geographic(origin+range*ray)-bounds.xy)/(bounds.zw-bounds.xy);
if(any(lessThan(uv,vec2(0.0)))||any(greaterThan(uv,vec2(1.0)))) return color;
vec2 delta=window.zw-window.xy;
vec2 duv=(uv-window.xy)/max(delta,vec2(1e-6));
bool useDetail=all(greaterThan(delta,vec2(0.0)))&&all(greaterThanEqual(duv,vec2(0.0)))&&all(lessThanEqual(duv,vec2(1.0)));
vec4 sampleColor=useDetail ? texture2D(detail,duv) : texture2D(image,uv);
return mix(color,sampleColor.rgb,look.x*sampleColor.a);
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
bool onSurface=depth<1.0 && depth>=texture2D(tSurfaceDepth,uv).r-2e-7;
if(!uRaised && !onSurface) return;
vec3 v=(uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0)).xyz;
vec3 ray=normalize(mat3(uWorld)*v);
float range=depth>=1.0 ? 1.0e12 : (exp2(2.0*depth/uLogDepthFC)-1.0)*length(v)/(-v.z);
vec3 rgb=toDisplay(inputColor.rgb);
${products.map((p) => `rgb=paint(rgb,t${p},tDetail${p},uBounds${p},uWindow${p},uLook${p},ray,range,onSurface);`).join('\n')}
outputColor=vec4(toLinear(rgb),inputColor.a);
}
`;
function weatherPass(engine, { view, EffectDesc, NavaraEffect }) {
let shared = owners.get(view);
if (shared) return shared;
const surfaces = new Set();
let handle = null,
empty = null;
class WeatherEffect extends EffectDesc {
static key = KEY;
static insertAfter = ['gevNepalEvent', 'gevImagery', 'toneMapping'];
static insertBefore = [
'gevEarthquakeGround',
'gevGround',
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
];
createPass() {
this.effect = new Effect('GevObservedWeather', shader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['uWorld', new Uniform(new Matrix4())],
['uRaised', new Uniform(false)],
...products.flatMap((p) => [
['t' + p, new Uniform(empty)],
['tDetail' + p, new Uniform(empty)],
['uBounds' + p, new Uniform(emptyRect)],
['uWindow' + p, new Uniform(emptyRect)],
['uLook' + p, new Uniform([0, 0])],
]),
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
updateFrame() {
if (!this.effect) return;
const camera = this.view.camera.raw,
u = this.effect.uniforms;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
u.get('uWorld').value.copy(camera.matrixWorld);
u.get('uRaised').value = engine.maps.photorealActive();
const lift = shellLift(engine.camera.pose().cartographic.height);
for (const frame of surfaces) frame.upload(this.ctx.getRenderer());
for (const p of products) {
let active = null;
for (const f of surfaces)
if (f.slot === p && f.visible && f.alpha > 0 && f.image) active = f;
u.get('t' + p).value = active?.image ?? empty;
u.get('tDetail' + p).value = active?.detail ?? empty;
u.get('uBounds' + p).value = active?.bounds ?? emptyRect;
u.get('uWindow' + p).value = active?.window ?? emptyRect;
u.get('uLook' + p).value = active
? [active.alpha, active.height + lift]
: [0, 0];
}
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
}
view.registerEffect(KEY, WeatherEffect);
shared = {
surfaces,
sync() {
if (surfaces.size && !handle) {
empty = new DataTexture(new Uint8Array(4), 1, 1);
empty.needsUpdate = true;
handle = view.addEffect({ [KEY]: {}, visible: true });
} else if (!surfaces.size && handle) {
handle.delete();
handle = null;
empty.dispose();
empty = null;
}
engine.render.request(KEY);
},
};
owners.set(view, shared);
return shared;
}
function imageTexture(canvas) {
const texture = new CanvasTexture(canvas);
texture.minFilter = texture.magFilter = LinearFilter;
texture.generateMipmaps = false;
return texture;
}
/** Own one weather frame. Upload staging images before replacing the last drawn frame. */
export function createWeatherSurface(
engine,
options,
product,
{ rectangle, height, onSettled },
) {
const shared = weatherPass(engine, options);
let pending = false,
ready = false;
const frame = {
slot: slotFor(product),
bounds: rectArray(rectangle),
height,
alpha: 0,
visible: true,
image: null,
detail: null,
window: null,
upload(renderer) {
if (!pending) return;
if (frame.image) renderer.initTexture(frame.image);
if (frame.detail) renderer.initTexture(frame.detail);
pending = false;
ready = true;
// Completing a frame can dispose its predecessor. Finish this pass first.
queueMicrotask(() => {
if (shared.surfaces.has(frame)) onSettled();
});
},
};
shared.surfaces.add(frame);
shared.sync();
return {
setImage(canvas) {
if (frame.image?.image === canvas) return;
frame.image?.dispose();
frame.image = imageTexture(canvas);
ready = false;
pending = true;
shared.sync();
},
setDetail(canvas, rect) {
if (frame.detail?.image !== canvas) {
frame.detail?.dispose();
frame.detail = canvas ? imageTexture(canvas) : null;
pending = true;
}
frame.window = rect ? rectArray(rect) : null;
shared.sync();
},
setAlpha(value) {
frame.alpha = value;
shared.sync();
},
setShow(value) {
if (frame.visible === value) return false;
frame.visible = value;
shared.sync();
return true;
},
getDiagnostics() {
const [west, south, east, north] = frame.window ?? [];
return {
height,
ready,
uploaded: ready,
show: frame.visible,
alpha: frame.alpha,
rendering: pending,
detail: {
uploaded: Boolean(frame.detail && !pending),
window: frame.window && { west, south, east, north },
},
};
},
destroy() {
if (!shared.surfaces.delete(frame)) return;
frame.image?.dispose();
frame.detail?.dispose();
shared.sync();
},
};
}
+23 -15
View File
@@ -125,23 +125,31 @@ export function createWindLayer({
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
viewer = nextViewer;
engine = nextEngine;
const cesiumRendering = (options) =>
createRendering({
// Read on Cesium only: the production build's Cesium global
// loads with the first Cesium globe.
cesium: cesium ?? Cesium,
container: container ?? nextViewer.container,
getViewer: () => viewer,
getHost: () =>
imageryHost?.() ?? {
collection: viewer.imageryLayers ?? viewer.scene?.imageryLayers,
kind: 'globe',
},
onStatusChange: notify,
...options,
});
// The engine's display (render/display.js) when the layer has one,
// with Cesium's flow (no field) on Cesium; an engine's own flow
// backend takes its place. Else the Cesium renderer draws it all.
rendering =
engine?.kind === 'navara'
? createNativeRendering(engine)
: createRendering({
// Read on Cesium only: the production build's Cesium global
// loads with the first Cesium globe.
cesium: cesium ?? Cesium,
container: container ?? nextViewer.container,
getViewer: () => viewer,
getHost: () =>
imageryHost?.() ?? {
collection:
viewer.imageryLayers ?? viewer.scene?.imageryLayers,
kind: 'globe',
},
(engine
? createNativeRendering?.(engine, {
flow: nextViewer ? () => cesiumRendering({ field: false }) : null,
onStatusChange: notify,
});
})
: null) ?? cesiumRendering();
rendering.attach();
rendering.setOptions?.({ overlay, paused });
const target =
+216
View File
@@ -0,0 +1,216 @@
// The wind on the globe contract: its colour field (speed, temperature or
// pressure) is one engine.imagery image over the globe, draped where
// imagery drapes on the globe and raised at the wind's shell height on 3D
// tiles, faded out as the camera comes down while streamlines draw (they
// carry the wind near the ground). The streamlines are the engine's own,
// behind one seam: the `flow` backend each engine passes in (Cesium's
// wind renderer without its field, rendering.js; Navara's flow mesh,
// render/navaraFlow.js). Without one the field draws alone.
import { createFieldRaster } from '../fields.js';
import { fieldUnavailableText } from '../rendering.js';
import { NO_IMAGERY_HOST } from '../../weather/imageryHost.js';
import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js';
import { WEATHER_IMAGERY_ORDER } from '../../weather/render/display.js';
const FADE_LOW_METERS = 200_000;
const FADE_HIGH_METERS = 1_200_000;
const FADE_LOG_RANGE = Math.log(FADE_HIGH_METERS / FADE_LOW_METERS);
const OVERLAYS = ['none', 'speed', 'temperature', 'pressure'];
let fields = 0;
/** The field's alpha for a camera `height` m up: none below 200 km, full
* above 1200 km, logarithmic between, while streamlines draw. */
export function windFieldAlpha(overlay, height, flowing) {
const base = overlay === 'temperature' ? 1 : 0.85;
if (!flowing) return base;
const fade = Math.max(
0,
Math.min(
1,
Math.log(Math.max(1, height) / FADE_LOW_METERS) / FADE_LOG_RANGE,
),
);
return base * fade;
}
function rasterCanvas(raster) {
const canvas = document.createElement('canvas');
canvas.width = raster.width;
canvas.height = raster.height;
const context = canvas.getContext('2d');
const pixels = context.createImageData(raster.width, raster.height);
pixels.data.set(raster.rgba);
context.putImageData(pixels, 0, 0);
return canvas;
}
/**
* The wind layer's renderer on any engine (index.js).
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ flow?: (engine: object) => object, onStatusChange?: () => void,
* createCanvas?: (raster: object) => object }} [options]
* `flow` makes the engine's streamlines (rendering.js's interface:
* attach, setField, setOptions, start, stop, clear, destroy,
* getParticleCount, getDiagnostics).
*/
export function createWindDisplay(
engine,
{
flow: makeFlow = null,
onStatusChange = () => {},
createCanvas = rasterCanvas,
} = {},
) {
if (!engine?.imagery)
throw new TypeError('The wind display needs a globe engine');
const flow = makeFlow?.(engine) ?? null;
const id = `wind-field-${++fields}`;
let snapshot = null;
let overlay = 'none';
let paused = false;
let layer = null;
let layerSurface = null;
let imageryError = null;
let lastAlpha = null;
let removeFrame = null;
const flowing = () =>
flow?.getDiagnostics?.().renderMode === 'gpu-streamlines';
function removeField() {
layer?.destroy();
layer = null;
layerSurface = null;
lastAlpha = null;
}
function fade() {
if (!layer) return;
let height;
try {
height = engine.camera.pose().cartographic.height;
} catch {
return;
}
let alpha = windFieldAlpha(overlay, height, flowing());
// On 3D tiles the shell keeps a coarse fade: fewer restyles.
if (layer.raise() !== null) alpha = Math.round(alpha * 10) / 10;
if (
lastAlpha !== null &&
Math.abs(alpha - lastAlpha) <= 0.005 &&
!(alpha === 0 && lastAlpha !== 0)
)
return;
lastAlpha = alpha;
layer.setAlpha(alpha);
layer.show(alpha > 0);
}
function installField() {
removeField();
const previous = imageryError;
imageryError = null;
if (snapshot && overlay !== 'none') {
const surface = engine.imagery.surface();
layerSurface = surface;
if (surface === 'none') imageryError = NO_IMAGERY_HOST;
else {
const raster = createFieldRaster(snapshot, overlay);
if (!raster) imageryError = fieldUnavailableText(overlay);
else {
layer = engine.imagery.createLayer({
id,
order: WEATHER_IMAGERY_ORDER.wind,
alpha: 0,
show: false,
source: { kind: 'image', image: createCanvas(raster) },
...(surface === 'tiles'
? { raise: WEATHER_SHELL_HEIGHTS.wind }
: {}),
});
layerSurface = surface;
fade();
}
}
}
if (previous !== imageryError) onStatusChange();
}
// Every frame: the fade, and the field again on another surface.
function frame() {
if (
snapshot &&
overlay !== 'none' &&
engine.imagery.surface() !== layerSurface
)
installField();
else fade();
}
return {
rehome() {
flow?.rehome?.();
if (snapshot && engine.imagery.surface() !== layerSurface) installField();
},
attach() {
flow?.attach();
removeFrame ??= engine.frames.preRender.addEventListener(frame);
},
setField(next) {
snapshot = next;
flow?.setField(next);
installField();
engine.render.request('wind');
},
setOptions(next = {}) {
paused = typeof next.paused === 'boolean' ? next.paused : paused;
const nextOverlay = OVERLAYS.includes(next.overlay)
? next.overlay
: overlay;
flow?.setOptions({ overlay: nextOverlay, paused });
if (nextOverlay !== overlay) {
overlay = nextOverlay;
installField();
}
engine.render.request('wind');
},
start() {
flow?.start();
engine.render.request('wind');
},
stop() {
flow?.stop();
},
clear() {
flow?.clear();
snapshot = null;
removeField();
imageryError = null;
},
destroy() {
flow?.stop();
flow?.destroy();
removeFrame?.();
removeFrame = null;
snapshot = null;
removeField();
},
getParticleCount: () => flow?.getParticleCount() ?? 0,
getDiagnostics() {
const drawn = flow?.getDiagnostics?.() ?? {};
return {
...drawn,
overlay,
paused,
imageryActive: layer !== null,
host: layer ? (layer.raise() !== null ? 'shell' : 'globe') : null,
field: layer
? {
alpha: layer.alpha(),
shown: layer.shown(),
raise: layer.raise(),
ready: layer.ready(),
}
: null,
imageryError: imageryError ?? drawn.imageryError ?? null,
};
},
};
}
+121
View File
@@ -0,0 +1,121 @@
// The wind display on the fake engine: its field as one imagery image,
// faded with the camera while streamlines draw, raised on 3D tiles; the
// flow is the backend's.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js';
import { createWindDisplay, windFieldAlpha } from './display.js';
const snapshot = () => {
const nx = 8;
const ny = 5;
return {
model: 'gfs',
grid: { nx, ny, lo1: 0, la1: 90, dx: 45, dy: 45 },
u: Array.from({ length: nx * ny }, (_, i) => 5 + (i % 7)),
v: Array.from({ length: nx * ny }, (_, i) => (i % 3) - 1),
};
};
const canvasOf = (raster) => ({ width: raster.width, height: raster.height });
function flowStub() {
const calls = [];
const flow = {
calls,
attach: () => calls.push('attach'),
setField: () => calls.push('setField'),
setOptions: (options) => calls.push(['setOptions', options.overlay]),
start: () => calls.push('start'),
stop: () => calls.push('stop'),
clear: () => calls.push('clear'),
destroy: () => calls.push('destroy'),
getParticleCount: () => 12,
getDiagnostics: () => ({
renderMode: 'gpu-streamlines',
imageryError: null,
}),
};
return flow;
}
const at = (engine, height) => {
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
engine.fake.emit('frames.preRender');
};
test('the field fades below 1200 km while streamlines draw', () => {
assert.equal(windFieldAlpha('speed', 2_000_000, true), 0.85);
assert.equal(windFieldAlpha('temperature', 100_000, true), 0);
assert.equal(windFieldAlpha('temperature', 100_000, false), 1);
const middle = windFieldAlpha('speed', 489_898, true);
assert.ok(Math.abs(middle - 0.85 / 2) < 0.01);
});
test('the field is one image layer over the globe, faded by height; the flow draws the paths', () => {
const engine = createFakeEngine();
const flow = flowStub();
const display = createWindDisplay(engine, {
flow: () => flow,
createCanvas: canvasOf,
});
display.attach();
display.setField(snapshot());
display.start();
assert.deepEqual(engine.fake.imageryLayers(), [], 'no overlay: no field');
display.setOptions({ overlay: 'speed', paused: false });
const [id] = engine.fake.imageryLayers();
at(engine, 12_000_000);
let field = engine.fake.imageryLayer(id);
assert.equal(field.source.kind, 'image');
assert.deepEqual(
{ ...field.source.rectangle },
{
west: -180,
south: -90,
east: 180,
north: 90,
},
);
assert.equal(field.raise, null);
assert.equal(field.alpha, 0.85);
assert.equal(field.shown, true);
at(engine, 50_000);
field = engine.fake.imageryLayer(id);
assert.equal(field.alpha, 0);
assert.equal(field.shown, false, 'low down the paths carry the wind');
assert.equal(display.getParticleCount(), 12);
assert.deepEqual(flow.calls.slice(0, 3), ['attach', 'setField', 'start']);
display.destroy();
assert.deepEqual(engine.fake.imageryLayers(), []);
assert.equal(flow.calls.at(-1), 'destroy');
});
test('on 3D tiles the field is raised at the wind’s shell height; none: a status', () => {
const engine = createFakeEngine();
let surface = 'tiles';
engine.imagery.surface = () => surface;
let changes = 0;
const display = createWindDisplay(engine, {
createCanvas: canvasOf,
onStatusChange: () => changes++,
});
display.attach();
display.setOptions({ overlay: 'speed' });
display.setField(snapshot());
const [id] = engine.fake.imageryLayers();
assert.equal(engine.fake.imageryLayer(id).raise, WEATHER_SHELL_HEIGHTS.wind);
assert.equal(display.getDiagnostics().host, 'shell');
surface = 'none';
engine.fake.emit('frames.preRender');
assert.deepEqual(engine.fake.imageryLayers(), []);
assert.match(display.getDiagnostics().imageryError, /Hidden by this map/);
assert.equal(changes, 1);
surface = 'globe';
engine.fake.emit('frames.preRender');
assert.equal(engine.fake.imageryLayers().length, 1);
assert.equal(display.getDiagnostics().host, 'globe');
assert.equal(display.getDiagnostics().imageryError, null);
});
@@ -3,88 +3,49 @@ import {
WIND_NARROW_PATH_LIMIT,
WIND_PATH_LIMIT,
} from '../streamlines.js';
import { createFieldRaster } from '../fields.js';
import { fieldUnavailableText, sameWind } from '../rendering.js';
import { createWeatherSurface } from '../../weather/render/navaraSurface.js';
import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js';
import { sameWind } from '../rendering.js';
import { createWindFlowMesh } from './flowMesh.js';
const world = {
west: -Math.PI,
south: -Math.PI / 2,
east: Math.PI,
north: Math.PI / 2,
};
const clamp = (n) => Math.max(0, Math.min(1, n));
export function createWindNavaraRenderer(engine, native) {
/**
* The wind's streamlines on Navara (the display's flow backend,
* render/display.js): baked paths as one flow mesh in Navara's view, faded
* in with the camera's height and moved by the page clock while the scene
* is shown and motion is allowed. The colour field is the display's.
*/
export function createWindNavaraFlow(engine, native) {
const canvas = engine.host.canvas,
media = matchMedia('(prefers-reduced-motion: reduce)');
let snapshot,
field,
mesh,
surface,
removeFrame,
lastAlpha,
lastTime,
let snapshot = null,
field = null,
mesh = null,
removeFrame = null,
lastTime = null,
seconds = 0,
paths = 0,
fade = 0;
let running = false,
paused = false,
overlay = 'none',
error = null,
fade = 0,
budget = 0;
let running = false,
paused = false;
const wake = () => engine.render.request('wind');
const limit = () =>
canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
const build = () => {
mesh?.destroy();
mesh = null;
budget =
canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
budget = limit();
const baked = bakeWindStreamlines(field, { count: budget });
paths = baked.length;
if (paths) mesh = createWindFlowMesh(native, baked);
};
const paint = () => {
surface?.destroy();
surface = null;
lastAlpha = null;
error = null;
if (!snapshot || overlay === 'none') return;
const raster = createFieldRaster(snapshot, overlay);
if (!raster) {
error = fieldUnavailableText(overlay);
return;
}
const texture = document.createElement('canvas');
texture.width = raster.width;
texture.height = raster.height;
texture
.getContext('2d')
.putImageData(
new ImageData(raster.rgba, raster.width, raster.height),
0,
0,
);
surface = createWeatherSurface(engine, native, 'wind', {
rectangle: world,
height: WEATHER_SHELL_HEIGHTS.wind,
onSettled: wake,
});
surface.setImage(texture);
wake();
};
const frame = () => {
if (!running || document.hidden) {
lastTime = null;
return;
}
const h = engine.camera.pose().cartographic.height;
if (
field &&
budget !==
(canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT)
)
build();
if (field && budget !== limit()) build();
fade = clamp((h - 15000) / 45000);
const still = paused || media.matches;
const now = performance.now();
@@ -98,32 +59,14 @@ export function createWindNavaraRenderer(engine, native) {
width: canvas.clientWidth,
height: canvas.clientHeight,
});
if (surface) {
let alpha =
(overlay === 'temperature' ? 1 : 0.85) *
clamp(Math.log(Math.max(1, h) / 200000) / Math.log(6));
if (engine.maps.photorealActive()) alpha = Math.round(alpha * 10) / 10;
if (
lastAlpha == null ||
Math.abs(alpha - lastAlpha) > 0.005 ||
(alpha === 0 && lastAlpha !== 0)
) {
lastAlpha = alpha;
surface.setAlpha(alpha);
surface.setShow(alpha > 0);
}
}
if (mesh && !still && fade) wake();
};
const reset = () => {
mesh?.destroy();
mesh = null;
surface?.destroy();
surface = null;
field = snapshot = null;
paths = 0;
error = null;
lastAlpha = lastTime = null;
lastTime = null;
};
const preferenceChanged = () => {
lastTime = null;
@@ -135,21 +78,17 @@ export function createWindNavaraRenderer(engine, native) {
media.addEventListener('change', preferenceChanged);
document.addEventListener('visibilitychange', preferenceChanged);
},
/** A wind snapshot; its paths are baked again when its vectors changed. */
setField(next) {
const nextField = { ...next.grid, u: next.u, v: next.v };
const changed = !sameWind(snapshot, next, field, nextField);
snapshot = next;
field = nextField;
if (changed) build();
paint();
if (changed || !mesh) build();
wake();
},
setOptions(next) {
paused = next.paused;
if (overlay !== next.overlay) {
overlay = next.overlay;
paint();
}
wake();
},
start() {
@@ -172,7 +111,6 @@ export function createWindNavaraRenderer(engine, native) {
getDiagnostics: () => ({
renderMode: 'gpu-streamlines',
reducedMotion: media.matches,
imageryError: error,
gpu: {
pathCount: paths,
phaseSeconds: seconds,
@@ -180,7 +118,6 @@ export function createWindNavaraRenderer(engine, native) {
heightFade: fade,
vertexCount: (mesh?.segments ?? 0) * 4,
},
surface: surface?.getDiagnostics() ?? null,
}),
};
}
+6 -3
View File
@@ -57,7 +57,9 @@ export function sameWind(previous, next, previousField, nextField) {
}
/** Globe-projected surface flow plus one owned, static colour field: globe
* imagery on the globe host, a raised shell on the 3D Tiles host. */
* imagery on the globe host, a raised shell on the 3D Tiles host. With
* `field: false` it draws the flow alone: the wind display draws the field
* through the globe contract (render/display.js). */
export function createWindRendering({
cesium,
container,
@@ -70,6 +72,7 @@ export function createWindRendering({
eventTarget = globalThis.window,
createGpuRendering = createWindGpuRendering,
onStatusChange,
field: drawsField = true,
} = {}) {
let canvas = null;
let context = null;
@@ -343,7 +346,7 @@ export function createWindRendering({
removeImagery();
imageryError = null;
const viewer = viewerReady();
if (!snapshot || overlay === 'none' || !viewer) return;
if (!drawsField || !snapshot || overlay === 'none' || !viewer) return;
const { collection, kind } = getHost();
if (kind === 'none') {
imageryError = NO_IMAGERY_HOST;
@@ -409,7 +412,7 @@ export function createWindRendering({
}
function rehome() {
if (!snapshot || overlay === 'none') return;
if (!drawsField || !snapshot || overlay === 'none') return;
const { collection, kind } = getHost();
const wasHidden = imageryError === NO_IMAGERY_HOST;
if ((imagery || shell) && kind !== 'none' && kind !== imageryKind) {