From 005254fcd15454cedac2bc5a52f6e4894b78b8c6 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:49:39 -0500 Subject: [PATCH] fix(weather): upright N. America cloud tiles; lift shells over coarse 3D tiles (#730) --- src/layers/weather/infraredImage.js | 12 +++++-- src/layers/weather/infraredImage.test.mjs | 12 +++++++ src/layers/weather/rendering.js | 8 ++++- src/layers/weather/shellFixture.mjs | 2 ++ src/layers/weather/shellRendering.js | 25 ++++++++++++++ src/layers/weather/shellRendering.test.mjs | 39 ++++++++++++++++++++++ src/layers/weather/weather.test.mjs | 35 +++++++++++++++++++ 7 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index 5b2db50b3..ccbb8e991 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -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); diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index f93a7816a..f3937abc3 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -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, diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 34224ecc8..8d195f302 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -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; diff --git a/src/layers/weather/shellFixture.mjs b/src/layers/weather/shellFixture.mjs index 685c72c56..18e5de788 100644 --- a/src/layers/weather/shellFixture.mjs +++ b/src/layers/weather/shellFixture.mjs @@ -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 }, diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 42a8ed521..cc8b32035 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -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(); diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index 3fcfdf28e..ad65dcef2 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -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(), diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index abdc01c7a..a80ad6560 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -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,