diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6073b5c84..a83c58959 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -891,9 +891,11 @@ image proxy accepts `bbox=west,south,east,north` for every product: inside the product bounds, 2:1 within 1 %, rounded to 0.25°, up to 4096×2048 (the default), cached by product, time, size and bbox. Diagnostics report `shell.detail` (`bbox`, `size`, `ready`, `enabled`). -Clouds only applies a soft brightness ramp to decoded pixels once, using +Clouds only applies a soft brightness ramp, using Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40 -to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are +to 0.70. Globe hosts apply it to decoded pixels once; a shell applies the same +ramp in its fragment shader from a strength uniform, so its images are drawn +as decoded and a strength change neither fetches nor rewrites an image. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are preserved in Full image mode; both modes use the chosen layer opacity. This is a display filter, not a cloud mask. The cloud-filter slider is a continuous 0–100% strength: 0% is Full, 100% is the existing Clouds only key, and @@ -909,13 +911,13 @@ and processes it before staging. Globe hosts crop that canvas into 256 px tiles to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent, avoiding request-dependent contrast seams. On globe hosts, regional infrared retains network tiles and processes each -decoded tile once; shells process each whole frame once. +decoded tile once; shells never read a frame's pixels back. Exact-time tile and image responses are immutable for 24 hours; manifests and errors remain uncached. Each globe renderer retains up to 6 processed global mosaics in a least-recently-used cache keyed by observation time and infrared mode (up to 48 MiB of canvas pixels). Each shell keeps decoded full-extent and detail images in one least-recently-used cache bounded at 128 MiB, keyed by -time, mode and window: the shown frame and the warmed next frame, each +time and window: the shown frame and the warmed next frame, each full-extent and detail; nothing older survives them. Disable clears the caches. Cache hits skip fetch/decode and report `mosaic.cached: true` with zero decode time. diff --git a/src/layers/weather/infraredAlpha.js b/src/layers/weather/infraredAlpha.js index 287e985e0..0f70edf10 100644 --- a/src/layers/weather/infraredAlpha.js +++ b/src/layers/weather/infraredAlpha.js @@ -1,7 +1,9 @@ export const INFRARED_ALPHA_T0 = 0.4; export const INFRARED_ALPHA_T1 = 0.7; -function infraredAmount(strength) { +/** How much of the cloud key applies, 0–1, for a display mode or a 0–100 + * strength. The weather shell passes it to its fragment shader. */ +export function infraredAmount(strength) { return strength === 'full' ? 0 : strength === 'filtered' || !Number.isFinite(strength) diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index 3a017171b..945503acc 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -38,7 +38,15 @@ function applyInfraredTransfer(context, canvas, mode) { /** Draw pixels a frame source supplied into a new canvas, with the display * transfer for infrared products, and release them. They must be `size`. */ -function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) { +function drawSuppliedImage( + pixels, + product, + mode, + size, + signal, + createCanvas, + transfer, +) { try { signal.throwIfAborted(); const { width, height } = weatherPixelSize(pixels); @@ -49,7 +57,7 @@ function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) { texture.height = height; const context = texture.getContext('2d'); drawWeatherPixels(context, pixels); - if (product === 'clouds' || product === 'clouds-regional') + if (transfer && (product === 'clouds' || product === 'clouds-regional')) applyInfraredTransfer(context, texture, mode); return texture; } finally { @@ -89,7 +97,8 @@ export function decodeInfraredImage(blob, signal) { } /** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw - * it into a canvas. Infrared frames get the display transfer; a canvas also keeps + * it into a canvas. Infrared frames get the display transfer unless `transfer` + * is false, for a caller that applies it when drawing; a canvas also keeps * Cesium's texture row order, which an ImageBitmap upload would not. The decoded * image is always released. With a `frameSource` that has `getImage`, the host * supplies the decoded pixels instead and nothing is fetched or decoded here. */ @@ -108,6 +117,7 @@ export async function acquireWeatherImage( now = () => performance.now(), onFetched = () => {}, frameSource = null, + transfer = true, }, ) { if (frameSource?.getImage) { @@ -128,6 +138,7 @@ export async function acquireWeatherImage( size, signal, createCanvas, + transfer, ); return { texture, decodeMs: now() - started }; } @@ -148,7 +159,7 @@ export async function acquireWeatherImage( if (image.width !== size.width || image.height !== size.height) throw new Error('Invalid weather image dimensions'); let texture; - if (product === 'clouds' || product === 'clouds-regional') + if (transfer && (product === 'clouds' || product === 'clouds-regional')) texture = processInfraredImage(image, mode, createCanvas); else { texture = createCanvas(); diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index a3fe08308..fd03cd61a 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -48,6 +48,53 @@ test('the transfer is applied to the canvas pixels in place, with no copy writte assert.deepEqual([...read.data], [194, 0, 0, 98]); }); +test('with transfer off an infrared frame is drawn and never read back', async () => { + const drawn = []; + const createCanvas = () => { + const output = {}; + output.getContext = () => ({ + drawImage: (image) => drawn.push(image), + getImageData: () => { + throw new Error('the frame was read back on the page'); + }, + putImageData: () => { + throw new Error('the frame was rewritten on the page'); + }, + }); + return output; + }; + const size = { width: 4096, height: 2048 }; + const image = { ...size, close() {} }; + const options = { + signal: new AbortController().signal, + mode: 'filtered', + size, + maxBytes: 1024, + createCanvas, + transfer: false, + }; + const fetched = await acquireWeatherImage( + 'clouds-regional', + '2026-09-15T20:00:00.000Z', + { + ...options, + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + arrayBuffer: async () => new ArrayBuffer(1), + }), + decodeImage: async () => image, + }, + ); + assert.equal(fetched.texture.width, 4096); + const supplied = { ...size }; + await acquireWeatherImage('clouds-regional', '2026-09-15T20:00:00.000Z', { + ...options, + frameSource: { getImage: async () => supplied }, + }); + assert.deepEqual(drawn, [image, supplied]); +}); + test('decoded pixels are processed once on a same-sized canvas', () => { const image = { width: 1, height: 1 }; const output = processInfraredImage(image, 'filtered', canvas); diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index fdcb8ca3b..88c7bcde2 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -1,4 +1,9 @@ import { acquireWeatherImage } from './infraredImage.js'; +import { + INFRARED_ALPHA_T0, + INFRARED_ALPHA_T1, + infraredAmount, +} from './infraredAlpha.js'; import { imageryHostStatus } from './imageryHost.js'; import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js'; import { msg } from '../../i18n/index.js'; @@ -36,6 +41,10 @@ const LIFT_STEP = 500; // draw nothing until the full-extent image has arrived, and sample the detail // image only once it has arrived. The window is west, south, east, north in the // full-extent image's texture coordinates. +// `infrared` is how much of the cloud key applies, 0–1 (infraredAlpha.js has +// the same curve for the globe's draped imagery). It is applied here, to the +// image as it was decoded, so a 4096×2048 frame is never read back and +// rewritten on the page, and a strength change is one uniform, not a new image. const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); @@ -46,7 +55,8 @@ const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput material float useDetail = inside * step(1.5, float(detailDimensions.x)); vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail); material.diffuse = czm_gammaCorrect(c.rgb); - material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x)); + float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2)); + material.alpha = c.a * mix(1.0, key, infrared) * alpha * step(1.5, float(imageDimensions.x)); return material; } `; @@ -63,6 +73,7 @@ function registerMaterial(cesium) { image: cesium.Material.DefaultImageId, detail: cesium.Material.DefaultImageId, alpha: 1, + infrared: 0, window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 }, }, // Diffuse and alpha share these samples, so one source, not components. @@ -245,6 +256,12 @@ export function createShellSurface({ material.uniforms.alpha = value; scene.requestRender(); }, + /** How much of the infrared cloud key applies, 0–1. */ + setInfrared(value) { + if (destroyed || material.uniforms.infrared === value) return; + material.uniforms.infrared = value; + scene.requestRender(); + }, /** Draw `next` inside `rect` (west, south, east, north in the full-extent * image's texture coordinates, 0–1); null clears it. In the same window only * the image changes, and Cesium keeps drawing the previous texture until @@ -438,10 +455,11 @@ export function createWeatherShell({ const visible = () => !hidden && !suspended(); const boundsKey = ({ west, south, east, north }) => `${west},${south},${east},${north}`; - const cacheKey = (time, mode, box = null) => - `${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`; + // One image per time and window: the infrared mode is a uniform, not pixels. + const cacheKey = (time, box = null) => + box ? `${time}|${boundsKey(box)}` : time; const wantedDetail = () => - current && view ? cacheKey(current.time, current.infrared, view) : null; + current && view ? cacheKey(current.time, view) : null; function evict(keep = []) { const kept = new Set([...keep, current?.key, detail?.key]); @@ -454,12 +472,11 @@ export function createWeatherShell({ } async function acquire( time, - mode, signal, { box = null, keep = [], onFetched } = {}, ) { signal.throwIfAborted(); - const key = cacheKey(time, mode, box); + const key = cacheKey(time, box); const entry = images.get(key); if (entry) { images.delete(key); @@ -468,7 +485,8 @@ export function createWeatherShell({ } const result = await acquireWeatherImage(product, time, { signal, - mode, + // The surface's shader applies the infrared transfer. + transfer: false, size: box ? detailSize : size, bbox: box, maxBytes: MAX_IMAGE_BYTES, @@ -564,9 +582,7 @@ export function createWeatherShell({ const job = { key, box: view, controller: new AbortController() }; detailJob = job; job.timeout = setTimeout(() => job.controller.abort(), timeoutMs); - void acquire(current.time, current.infrared, job.controller.signal, { - box: view, - }) + void acquire(current.time, job.controller.signal, { box: view }) .then(({ texture }) => installDetail(job, texture)) .catch(() => failDetail(job)); } @@ -661,6 +677,7 @@ export function createWeatherShell({ loadMs: now() - frame.startedAt, }; surface.setAlpha(alpha); + surface.setInfrared(infrared ? infraredAmount(frame.infrared) : 0); applyVisibility(); lastError = null; evict(); @@ -715,7 +732,7 @@ export function createWeatherShell({ return changed; }, cancelPrefetch, - async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) { + async prefetch(snapshot, time) { let job; try { if ( @@ -729,8 +746,8 @@ export function createWeatherShell({ // Warm the next frame's detail window too while the window stays put. const box = view && viewBounds === boundsKey(snapshot.bounds) ? view : null; - const coarse = cacheKey(time, mode); - const key = box ? cacheKey(time, mode, box) : coarse; + const coarse = cacheKey(time); + const key = box ? cacheKey(time, box) : coarse; if (prefetchJob?.key === key || prefetchedKey === key) return false; cancelPrefetch(); job = { key, controller: new AbortController() }; @@ -739,8 +756,8 @@ export function createWeatherShell({ job.timeout = setTimeout(() => job.controller.abort(), timeoutMs); // Both images at once: one after the other would outlast playback's step. await Promise.all([ - acquire(time, mode, signal, { keep: box ? [key] : [] }), - box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null, + acquire(time, signal, { keep: box ? [key] : [] }), + box ? acquire(time, signal, { box, keep: [coarse] }) : null, ]); signal.throwIfAborted(); if (prefetchJob === job) prefetchedKey = key; @@ -782,7 +799,7 @@ export function createWeatherShell({ infrared: mode, bounds, product: snapshot.product, - key: cacheKey(time, mode), + key: cacheKey(time), mosaic: { fetched: false, decodeMs: null, cached: false }, controller: new AbortController(), startedAt: now(), @@ -797,7 +814,7 @@ export function createWeatherShell({ }); watch(frame, signal); frame.timeout = setTimeout(() => fail(frame), timeoutMs); - void acquire(time, mode, frame.controller.signal, { + void acquire(time, frame.controller.signal, { onFetched: () => { frame.mosaic.fetched = true; }, diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index f756fd7b9..6223af01c 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -11,6 +11,7 @@ import { WEATHER_SHELL_HEIGHTS, } from './shellRendering.js'; import { NO_IMAGERY_HOST } from './imageryHost.js'; +import { INFRARED_ALPHA_T0, INFRARED_ALPHA_T1 } from './infraredAlpha.js'; import { createShellCesium, createShellScene, @@ -147,12 +148,13 @@ test('surface builds a flat raised rectangle drawn in primitive order without de image: Cesium.Material.DefaultImageId, detail: Cesium.Material.DefaultImageId, alpha: 1, + infrared: 0, window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 }, }); const { source } = template.fabric; assert.match( source, - /material\.alpha = c\.a \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/, + /material\.alpha = c\.a \* mix\(1\.0, key, infrared\) \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/, 'nothing draws before the full-extent image', ); assert.match( @@ -373,6 +375,7 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an /uniform sampler2D image_\d+;/, /uniform sampler2D detail_\d+;/, /uniform float alpha_\d+;/, + /uniform float infrared_\d+;/, /uniform vec4 window_\d+;/, /uniform ivec3 imageDimensions_\d+;/, /uniform ivec3 detailDimensions_\d+;/, @@ -384,9 +387,16 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an assert.match(shader, /step\(1\.5, float\(detailDimensions_\d+\.x\)\)/); assert.match( shader, - /material\.alpha = c\.a \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/, + /material\.alpha = c\.a \* mix\(1\.0, key, infrared_\d+\) \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/, 'the uniform is renamed, the material field is not', ); + // The cloud key the globe's draped imagery gets from infraredAlpha.js: + // brightest channel, 2.2 power, the shared thresholds. + assert.ok( + shader.includes( + `float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2));`, + ), + ); assert.match(shader, /float useDetail = inside \* step/, 'locals keep names'); const detail = { width: 4096, height: 2048 }; surface.setDetail(detail, { west: 0.1, south: 0.2, east: 0.3, north: 0.4 }); @@ -743,12 +753,12 @@ test('prefetch warms a decoded frame without staging and cancels cleanly', async h.shell.clear(); }); -test('infrared frames get the display transfer; radar and lightning keep their pixels', async () => { - for (const [product, transfer] of [ - ['clouds-regional', true], - ['clouds', true], - ['radar', false], - ['lightning', false], +test('infrared frames reach the shader as decoded, with the cloud key as a uniform; radar and lightning get none', async () => { + for (const [product, amount] of [ + ['clouds-regional', 1], + ['clouds', 1], + ['radar', 0], + ['lightning', 0], ]) { const h = harness({ product }); const pending = h.shell.setFrame(snapshot(product), times[0]); @@ -756,11 +766,10 @@ test('infrared frames get the display transfer; radar and lightning keep their p const image = h.material().uniforms.image; assert.equal(image.source, h.decoded[0], 'drawn into a canvas'); assert.equal(h.decoded[0].closed, true); - assert.equal(image.pixels !== undefined, transfer, product); - if (transfer) - assert.deepEqual([...image.pixels], [194, 0, 0, 98], 'clouds only'); + assert.equal(image.pixels, undefined, `${product}: pixels never rewritten`); h.render(); assert.equal(await pending, true); + assert.equal(h.material().uniforms.infrared, amount, product); assert.equal( h.shell.getDiagnostics().height, WEATHER_SHELL_HEIGHTS[product], @@ -769,20 +778,28 @@ test('infrared frames get the display transfer; radar and lightning keep their p } }); -test('an infrared mode change restages the same time with its own cached canvas', async () => { +test('an infrared mode change is a uniform on the same image: no fetch, no new canvas', async () => { const h = harness({ product: 'clouds' }); assert.equal(await show(h, times[0]), true); - const filtered = h.material().uniforms.image; + const image = h.material().uniforms.image; + assert.equal(h.material().uniforms.infrared, 1); assert.equal(await show(h, times[0]), true, 'same frame is a no-op'); assert.equal(h.fetches.length, 1); + const renders = h.scene.renders; assert.equal(await show(h, times[0], { infrared: 'full' }), true); - const full = h.material().uniforms.image; - assert.notEqual(full, filtered); - assert.deepEqual([...full.pixels], [194, 0, 0, 200]); + assert.equal(h.material().uniforms.image, image); + assert.equal(h.material().uniforms.infrared, 0); assert.equal(h.shell.getDiagnostics().infrared, 'full'); + assert.ok(h.scene.renders > renders, 'the new strength is drawn'); + assert.equal(await show(h, times[0], { infrared: 40 }), true); + assert.equal(h.material().uniforms.infrared, 0.4); + assert.equal(h.shell.getDiagnostics().infrared, 40); assert.equal(await show(h, times[0], { infrared: 'filtered' }), true); - assert.equal(h.material().uniforms.image, filtered, 'cache hit'); - assert.equal(h.fetches.length, 2); + assert.equal(h.material().uniforms.image, image); + assert.equal(h.material().uniforms.infrared, 1); + assert.equal(h.fetches.length, 1); + assert.equal(h.decoded.length, 1); + assert.equal(h.shell.getDiagnostics().cache.mosaics, 1); h.shell.clear(); });