From 06c51d3438cbcdc0367220ae997b62583a8b0527 Mon Sep 17 00:00:00 2001 From: nu-stats <113466924+nu-stats@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:07:12 -0400 Subject: [PATCH] perf(weather): wake a staged frame on a 150 ms timer, not every render (#1345) Wake staged weather imagery on a bounded timer instead of requesting every render. Contribution by @nu-stats. --- changelog.d/1345-weather-staging-wake.md | 3 ++ docs/CURRENT-STATE.md | 4 +++ src/layers/weather/rendering.js | 21 +++++++++-- src/layers/weather/weather.test.mjs | 46 +++++++++++++++++++++++- 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1345-weather-staging-wake.md diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md new file mode 100644 index 000000000..fec4d54ec --- /dev/null +++ b/changelog.d/1345-weather-staging-wake.md @@ -0,0 +1,3 @@ +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b09cef331..4b9e306ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -306,6 +306,10 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. +A staged weather frame with loaded tiles and no pending requests rechecks +readiness on a single 150 ms wake timer instead of requesting every render. +The existing readiness conditions and two settled frames still govern promotion; +completion, cancellation, replacement and teardown clear the wake timer. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index b25bcceb2..0f2fad68b 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -14,6 +14,8 @@ 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; +// How often a staged frame waiting out its quiet interval rechecks readiness. +export const READY_WAKE_MS = 150; /** Globe imagery: own at most a displayed and a staging frame. Use native Cesium * tile scheduling, projection and texture disposal; the application clock is never touched. */ @@ -126,6 +128,7 @@ function createGlobeRendering({ frame.offRender?.(); frame.offCamera?.(); clearTimeout(frame.timeout); + clearTimeout(frame.wake); for (const request of frame.requests) request.cancel?.(); frame.requests.clear(); frame.retries.clear(); @@ -277,6 +280,8 @@ function createGlobeRendering({ frame.offCamera?.(); frame.offCamera = null; clearTimeout(frame.timeout); + clearTimeout(frame.wake); + frame.wake = null; frame.offAbort?.(); if (ok) { lastError = null; @@ -450,8 +455,20 @@ function createGlobeRendering({ if (++settled >= 2) finish(true); else viewer.scene.requestRender(); } else settled = 0; - if (incoming === frame && frame.pending === 0 && frame.loaded > 0) - viewer.scene.requestRender(); + // Waiting out the quiet interval needs another readiness check + // later, not a render every frame: wake on a timer so the render + // governor's frame cap still holds while a frame is staged. + if ( + incoming === frame && + frame.pending === 0 && + frame.loaded > 0 && + !frame.wake + ) + frame.wake = setTimeout(() => { + frame.wake = null; + if (!frame.closed && incoming === frame) + viewer.scene.requestRender(); + }, READY_WAKE_MS); }); viewer.scene.requestRender(); onChange(); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 486d42520..a42485ed0 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; import * as Cesium from 'cesium'; @@ -242,6 +242,50 @@ test('a ready weather frame does not wait for unrelated terrain, but requires su h.rendering.clear(); }); +test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; + const h = renderingHarness({ now: () => now }); + h.viewer.scene.globe.tilesLoaded = false; + const loaded = h.rendering.setFrame(snapshot, times[0]); + const tile = deferred(); + h.providers[0].response = tile; + const request = h.providers[0].requestImage(0, 0, 0, {}); + tile.resolve({}); + await request; + const before = h.renders(); + for (let i = 0; i < 10; i++) h.postRender.emit(); + assert.equal( + h.renders(), + before, + 'renders while waiting do not request further renders', + ); + t.mock.timers.tick(READY_WAKE_MS - 1); + assert.equal(h.renders(), before); + t.mock.timers.tick(1); + assert.equal(h.renders(), before + 1, 'one wake per interval'); + now = 200; + h.postRender.emit(); + h.postRender.emit(); + assert.equal(await loaded, true); + const settledRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), settledRenders, 'commit clears the wake timer'); + + const abandoned = h.rendering.setFrame(snapshot, times[1]); + const second = deferred(); + h.providers.at(-1).response = second; + const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {}); + second.resolve({}); + await secondRequest; + h.postRender.emit(); + h.rendering.clear(); + assert.equal(await abandoned, false); + const clearedRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer'); +}); + test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => { let now = 0; const h = renderingHarness({ now: () => now });