fix(weather): upright N. America cloud tiles; lift shells over coarse 3D tiles (#730)

This commit is contained in:
Bilawal Sidhu authored and GitHub committed 2026-09-23 14:49:39 -05:00
1 parent 082074a006
commit 005254fcd1
7 files changed
+130 -3

No files matched your search

+10 -2
View File
@@ -4,13 +4,21 @@ import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
/** Apply the display transfer once to a decoded image. */
export function processInfraredImage(image, mode, createCanvas) {
/** Apply the display transfer once to a decoded image; `flipY` draws a
* bottom-up image back upright. */
export function processInfraredImage(
image,
mode,
createCanvas,
{ flipY = false } = {},
) {
const canvas = createCanvas();
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height);
context.drawImage(image, 0, 0);
if (flipY) context.setTransform(1, 0, 0, 1, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
pixels.data.set(infraredAlpha(pixels.data, mode));
context.putImageData(pixels, 0, 0);
+12
View File
@@ -10,8 +10,12 @@ import {
function canvas() {
const output = {};
output.getContext = () => ({
setTransform: (...matrix) => {
output.transforms = [...(output.transforms ?? []), matrix];
},
drawImage: (image) => {
output.image = image;
output.drawnWith = output.transforms?.at(-1) ?? null;
},
getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }),
putImageData: (pixels) => {
@@ -34,6 +38,14 @@ test('decoded pixels are processed once on a same-sized canvas', () => {
);
});
test('flipY draws a bottom-up image upright, then restores the transform', () => {
const image = { width: 4, height: 8 };
assert.equal(processInfraredImage(image, 'full', canvas).drawnWith, null);
const output = processInfraredImage(image, 'full', canvas, { flipY: true });
assert.deepEqual(output.drawnWith, [1, 0, 0, -1, 0, 8]);
assert.deepEqual(output.transforms.at(-1), [1, 0, 0, 1, 0, 0]);
});
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
const controller = new AbortController();
let closes = 0,
+7 -1
View File
@@ -381,8 +381,14 @@ function createGlobeRendering({
if (request) frame.requests.add(request);
return Promise.resolve(result)
.then((image) => {
// Cesium decodes tiles as ImageBitmaps already flipped, since WebGL
// ignores UNPACK_FLIP_Y for them; a canvas upload flips again.
if (!frame.closed && snapshot.product === 'clouds-regional')
image = processInfraredImage(image, infrared, createCanvas);
image = processInfraredImage(image, infrared, createCanvas, {
flipY:
typeof ImageBitmap !== 'undefined' &&
image instanceof ImageBitmap,
});
frame.retries.delete(tileKey);
frame.loaded++;
return image;
+2
View File
@@ -71,6 +71,8 @@ export function createShellCesium({ maximumTextureSize = 0 } = {}) {
GeometryInstance,
BlendingState: Cesium.BlendingState,
Cartesian4: Cesium.Cartesian4,
Ellipsoid: Cesium.Ellipsoid,
Matrix4: Cesium.Matrix4,
Math: Cesium.Math,
Rectangle: Cesium.Rectangle,
ContextLimits: { maximumTextureSize },
+25
View File
@@ -25,6 +25,12 @@ const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
const DETAIL_MIN_WIDTH = 6;
const DETAIL_GRID = 0.5;
const shellHeights = new WeakMap();
// From far away Google's coarse 3D tiles rise tens of kilometres above the
// ground and hide the shells, so the shells rise with the camera: 4 m per
// kilometre of camera height, at most 60 km, in 500 m steps.
const LIFT_PER_METRE = 0.004;
const MAX_LIFT = 60_000;
const LIFT_STEP = 500;
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
// draw nothing until the full-extent image has arrived, and sample the detail
// image only once it has arrived. The window is west, south, east, north in the
@@ -71,6 +77,13 @@ const sameEdges = (a, b) =>
a.east === b.east &&
a.north === b.north;
/** Metres every shell rises for a camera this high above the ellipsoid. */
export function shellLift(cameraHeight) {
if (!(cameraHeight > 0)) return 0;
const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE);
return Math.round(lift / LIFT_STEP) * LIFT_STEP;
}
/** Keep weather shells first in the primitive collection, in ascending height:
* higher shells draw over lower ones and other opaque content draws over both. */
export function orderWeatherShells(primitives, primitive, height) {
@@ -135,6 +148,17 @@ export function createShellSurface({
});
primitives.add(primitive);
orderWeatherShells(primitives, primitive, height);
// A uniform scale about the Earth's centre lifts every shell alike and keeps
// their order.
let lift = 0;
const offLift = scene.preRender?.addEventListener(() => {
const next = shellLift(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.
@@ -265,6 +289,7 @@ export function createShellSurface({
destroyed = true;
offRender?.();
offRender = null;
offLift?.();
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
primitives.remove(primitive);
if (!primitive.isDestroyed()) primitive.destroy();
@@ -6,6 +6,7 @@ import {
createWeatherShell,
detailWindow,
orderWeatherShells,
shellLift,
WEATHER_SHELL_CACHE_BYTES,
WEATHER_SHELL_HEIGHTS,
} from './shellRendering.js';
@@ -297,6 +298,44 @@ test('a surface applies a new detail window only once its image has drawn; the s
assert.equal(scene.postRender.size, 0);
});
test('shells rise with the camera over coarse 3D tiles, in steps, up to 60 km', () => {
assert.equal(shellLift(undefined), 0);
assert.equal(shellLift(1_200), 0, 'city views keep the shell heights');
assert.equal(shellLift(3_000_000), 12_000);
assert.equal(shellLift(8_000_000), 32_000);
assert.equal(shellLift(18_500_000), 60_000);
const camera = { positionCartographic: { height: 1_200 } };
const scene = {
primitives: new Cesium.PrimitiveCollection(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
camera,
requestRender() {},
};
const surface = createShellSurface({
viewer: { scene },
cesium: Cesium,
rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55),
height: 5_800,
});
const primitive = scene.primitives.get(0);
const identity = primitive.modelMatrix;
scene.preRender.raiseEvent();
assert.equal(primitive.modelMatrix, identity, 'no lift, no new matrix');
camera.positionCartographic.height = 8_000_000;
scene.preRender.raiseEvent();
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
assert.ok(
Math.abs(primitive.modelMatrix[0] * radius - (radius + 32_000)) < 1e-3,
);
const lifted = primitive.modelMatrix;
camera.positionCartographic.height = 8_010_000;
scene.preRender.raiseEvent();
assert.equal(primitive.modelMatrix, lifted, 'the same step keeps the matrix');
surface.destroy();
assert.equal(scene.preRender.numberOfListeners, 0);
});
test('real Cesium builds the two-texture material, the opaque-pass appearance and the rectangle primitive', () => {
const scene = {
primitives: new Cesium.PrimitiveCollection(),
+35
View File
@@ -75,9 +75,13 @@ function target(extra = {}) {
function mockCanvas() {
const canvas = { width: 0, height: 0 };
canvas.getContext = () => ({
setTransform(...matrix) {
canvas.transforms = [...(canvas.transforms ?? []), matrix];
},
drawImage(source, ...crop) {
canvas.source = source;
canvas.crop = crop;
canvas.drawnWith = canvas.transforms?.at(-1) ?? null;
},
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
putImageData(pixels) {
@@ -1510,6 +1514,37 @@ test('regional transfer preserves deferrals, original Request, rejection and can
assert.equal(await stage, false);
});
test('regional tiles decoded as flipped ImageBitmaps are drawn back upright', async () => {
const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap');
const previous = globalThis.ImageBitmap;
globalThis.ImageBitmap = class ImageBitmap {
width = 256;
height = 256;
};
try {
const h = renderingHarness();
void h.rendering.setFrame(
{ ...snapshot, product: 'clouds-regional' },
times[0],
);
const provider = h.providers[0];
// Cesium decodes with imageOrientation 'flipY'; the canvas upload flips again.
provider.response = { promise: Promise.resolve(new ImageBitmap()) };
const bitmapTile = await provider.requestImage(0, 0, 0, {});
assert.deepEqual(bitmapTile.drawnWith, [1, 0, 0, -1, 0, 256]);
// An HTMLImageElement fallback is not pre-flipped.
provider.response = {
promise: Promise.resolve({ width: 256, height: 256 }),
};
const imageTile = await provider.requestImage(1, 0, 0, {});
assert.equal(imageTile.drawnWith, null);
h.rendering.clear();
} finally {
if (hadBitmap) globalThis.ImageBitmap = previous;
else delete globalThis.ImageBitmap;
}
});
for (const kind of ['globe']) {
test(`infrared mode restages the same time on ${kind}, retaining the old layer for one rendered frame`, async () => {
let host,