Add a Navara HD renderer choice, release a parked engine after three minutes, and cut Navara's memory

Navara now draws at CSS pixels without MSAA, like Cesium; Navara HD keeps the device pixel ratio and MSAA 4. Only one Navara variant is ever kept. A parked engine is released after three minutes and its WebGL context is lost. Three leaks that kept a released Navara alive are fixed, the canvas context drops its unused antialias buffer, cloud shadows use two 256 px cascades, and the tile cache is budgeted from the screen buffers.
This commit is contained in:
German Khokhlov committed 2026-11-06 14:41:16 +04:00
1 parent a6c64713ee
commit 9825b02aab
22 files changed
+657 -58

No files matched your search

+2
View File
@@ -362,6 +362,8 @@ const runs = [
['strict-rapid-switch', 'strict-rapid-switch', []],
['strict-hidden-switch', 'strict-hidden-switch', []],
['strict-context-loss', 'strict-context-loss', []],
['strict-parked-release', 'strict-parked-release', []],
['strict-navara-hd', 'strict-navara-hd', []],
['strict-failure-matrix', 'strict-failure-matrix', [], [], 600],
['strict-cold-chunks', 'strict-cold-chunks', []],
['strict-prod-smoke', 'strict-prod-smoke', []],
+21 -2
View File
@@ -29,6 +29,8 @@ import {
import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs';
export const ENGINES = Object.freeze(['cesium', 'navara']);
/** What the Globe Engine picker offers: the engines and Navara HD. */
const CHOICES = Object.freeze([...ENGINES, 'navara-hd']);
/** Local ports that belong to the user's own servers: never a QA preview. */
export const RESERVED_PORTS = Object.freeze([
'',
@@ -406,7 +408,7 @@ export async function pickEngine(
kind,
{ settle = true, afterInput = null } = {},
) {
assert.ok(ENGINES.includes(kind), kind);
assert.ok(CHOICES.includes(kind), kind);
const expanded = () =>
page.$eval(
'#control-panel-toggle',
@@ -430,6 +432,21 @@ export async function pickEngine(
});
const from = await page.$eval('#globe-engine', (el) => el.value);
assert.notEqual(from, kind, `the picker already shows ${kind}`);
// The key typed below is `kind`'s first letter: it selects the next option
// starting with it (after `from` when `from` starts with it too). Each key
// is a change that starts a switch, so `kind` must be one key away
// (Cesium → Navara HD goes through Navara).
const values = await page.$$eval('#globe-engine option', (options) =>
options.map((option) => option.value),
);
const matches = values.filter((value) => value[0] === kind[0]);
assert.equal(
from[0] === kind[0]
? matches[(matches.indexOf(from) + 1) % matches.length]
: matches[0],
kind,
`${kind} is not one key away from ${from}`,
);
// Where a pointer can reach the picker: the label's own text (focuses the
// select without opening its menu), else the select itself.
const target = await page.$eval('#globe-engine', (el) => {
@@ -537,7 +554,9 @@ export async function pickEngine(
export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
await page.waitForFunction(
(expected) =>
window.__godsEyeView?.engine?.kind === expected &&
// Navara HD runs the Navara engine.
window.__godsEyeView?.engine?.kind ===
(expected === 'cesium' ? 'cesium' : 'navara') &&
document.querySelector('#globe-engine')?.value === expected &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
+145
View File
@@ -0,0 +1,145 @@
// Contract: the two Navara choices on a 2x screen. Navara draws at CSS
// pixels, Navara HD at the device pixel ratio. A pick between them builds
// the other variant on the same session and releases the one it leaves (its
// WebGL context is lost: only one Navara holds memory), the new one draws a
// non-blank frame, the address and the stored choice follow the pick, and
// Cesium stays the warm, parked engine across them.
//
// node scripts/qa-strict-navara-hd.mjs http://127.0.0.1:4208
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
installFirstFrameProbe,
installSessionFixtures,
openApp,
rememberEngine,
sessionState,
startSession,
} from './qa-strict-session.mjs';
const base = localPreview(process.argv[2]);
const strict = createStrict({ origin: base.origin, layers: [] });
await strict.run({}, async (b) => {
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
const errors = [];
p.on('pageerror', (e) => errors.push(e.message));
await installSessionFixtures(p, { flights: 20 });
await installFirstFrameProbe(p);
await openApp(p, base.origin, 'navara');
await startSession(p, ['earthquakes', 'flights']);
const contract = (name, value, detail) => {
assert.ok(value, name + ' ' + JSON.stringify(detail ?? ''));
strict.extra.contracts ??= [];
strict.extra.contracts.push({ name, pass: true, detail });
console.log('PASS', name);
};
// The running engine's canvas: its pixel ratio, and whether the engine
// remembered under `kind` still has a live context.
const look = (kind) =>
p.evaluate((kind) => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const old = window.__qaEngines.get(kind);
const oldCanvas = old?.host.canvas;
return {
engine: engine.kind,
ratio: Math.round((canvas.width / canvas.clientWidth) * 100) / 100,
same: old === engine,
oldLost:
(
oldCanvas?.getContext('webgl2') ?? oldCanvas?.getContext('webgl')
)?.isContextLost() ?? null,
stored: localStorage.getItem('gev.engine'),
};
}, kind);
const draws = (kind) =>
p.evaluate(
(kind) =>
Promise.race([
window.__qaFirstFrame(kind).then(() => true),
new Promise((resolve) => setTimeout(() => resolve(false), 15000)),
]),
kind,
);
const session = async () => {
const state = await sessionState(p);
return state.manager && state.models && state.enabled ? state : null;
};
const pick = async (choice) => {
await strict.pick(p, choice);
await waitForEngine(p, choice);
};
const start = await look('navara');
contract(
'Navara draws at CSS pixels on a 2x screen',
start.ratio === 1,
start,
);
// Navara → Navara HD: a new engine at the device pixel ratio.
await pick('navara-hd');
const hd = await look('navara');
const hdState = await session();
contract(
'Navara HD is a new Navara engine at the device pixel ratio; the Navara it left is released',
hd.engine === 'navara' &&
hd.ratio === 2 &&
!hd.same &&
hd.oldLost === true &&
hdState?.address === 'navara-hd' &&
hdState?.picker === 'navara-hd' &&
hd.stored === 'navara-hd',
{ ...hd, address: hdState?.address, picker: hdState?.picker },
);
await rememberEngine(p);
contract('Navara HD draws a non-blank frame', await draws('navara'));
// Navara HD → Cesium → Navara HD: Cesium is built once, HD stays warm.
await pick('cesium');
await rememberEngine(p);
await pick('navara');
await pick('cesium');
const cesium = await look('cesium');
contract(
'Cesium stays the warm parked engine across the Navara variants',
cesium.same && cesium.ratio === 1,
cesium,
);
// Back to Navara HD through Navara: each pick releases the other variant.
await pick('navara');
await rememberEngine(p);
await pick('navara-hd');
const again = await look('navara');
contract(
'a pick from Navara to Navara HD releases Navara and keeps the session',
!again.same &&
again.oldLost === true &&
again.ratio === 2 &&
(await session()) !== null,
again,
);
await rememberEngine(p);
await pick('navara');
const back = await look('navara');
const backState = await session();
contract(
'a pick from Navara HD to Navara draws at CSS pixels again, on the same session',
!back.same &&
back.oldLost === true &&
back.ratio === 1 &&
backState?.address === 'navara' &&
back.stored === 'navara',
back,
);
await rememberEngine(p);
contract('Navara draws a non-blank frame again', await draws('navara'));
contract('no uncaught page errors', errors.length === 0, errors);
});
+113
View File
@@ -0,0 +1,113 @@
// Contract: a parked globe is released after ?parkedMs= (3 s here; three
// minutes by default). A switch back in time is warm (the same engine
// object); after the delay the parked engine's WebGL context is lost and a
// switch back builds a new engine on the same session that draws a
// non-blank frame. Both directions.
//
// node scripts/qa-strict-parked-release.mjs http://127.0.0.1:4208
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
assertSession,
installFirstFrameProbe,
installSessionFixtures,
openApp,
rememberEngine,
startSession,
} from './qa-strict-session.mjs';
const PARKED_MS = 3000;
const base = localPreview(process.argv[2]);
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
const strict = createStrict({ origin: base.origin, layers: [] });
await strict.run({}, async (b) => {
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 800 });
const errors = [];
p.on('pageerror', (e) => errors.push(e.message));
await installSessionFixtures(p, { flights: 20 });
await installFirstFrameProbe(p);
await openApp(p, base.origin, 'navara', undefined, `&parkedMs=${PARKED_MS}`);
await startSession(p, ['earthquakes', 'flights']);
const contract = (name, value, detail) => {
assert.ok(value, name + ' ' + JSON.stringify(detail ?? ''));
strict.extra.contracts ??= [];
strict.extra.contracts.push({ name, pass: true, detail });
console.log('PASS', name);
};
const pick = async (kind) => {
await strict.pick(p, kind);
await waitForEngine(p, kind);
};
// The running engine is the one remembered under its kind (warm), and
// the context of `kind`'s remembered engine is lost or not.
const engines = (kind) =>
p.evaluate((kind) => {
const engine = window.__qaEngines.get(kind);
const canvas = engine?.host.canvas;
const context =
canvas?.getContext('webgl2') ?? canvas?.getContext('webgl');
return {
warm:
window.__qaEngines.get(window.__godsEyeView.engine.kind) ===
window.__godsEyeView.engine,
lost: context?.isContextLost() ?? null,
};
}, kind);
const draws = (kind) =>
p.evaluate(
(kind) =>
Promise.race([
window.__qaFirstFrame(kind).then(() => true),
new Promise((resolve) => setTimeout(() => resolve(false), 15000)),
]),
kind,
);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (const parked of ['navara', 'cesium']) {
const running = other(parked);
// Park `parked` afresh (either engine may have been released before).
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== parked) {
await pick(parked);
await rememberEngine(p);
}
await pick(running);
await rememberEngine(p);
// A switch back in time is warm.
await wait(PARKED_MS / 3);
await pick(parked);
const early = await engines(parked);
await assertSession(p, parked, `a quick switch back to ${parked}`);
contract(
`a switch back to ${parked} within the delay reuses the parked engine`,
early.warm && early.lost === false,
early,
);
// Parked past the delay: released, and the next switch back is cold.
await pick(running);
await wait(PARKED_MS + 1500);
const released = await engines(parked);
contract(
`${parked} parked past the delay is released: its context is lost`,
released.lost === true,
released,
);
await pick(parked);
const cold = await engines(parked);
await assertSession(p, parked, `the cold switch back to ${parked}`);
await rememberEngine(p);
const drawn = await draws(parked);
contract(
`the switch back to a released ${parked} builds a new engine on the same session and draws`,
!cold.warm && drawn,
{ ...cold, drawn },
);
}
contract('no uncaught page errors', errors.length === 0, errors);
});
+12 -3
View File
@@ -103,10 +103,19 @@ export async function installSessionFixtures(
);
}
/** Open the app on `engine` with the session hash and wait until it runs. */
export async function openApp(page, origin, engine, hash = SESSION_HASH) {
/**
* Open the app on `engine` with the session hash (and `search`, more query
* parameters such as `&parkedMs=3000`) and wait until it runs.
*/
export async function openApp(
page,
origin,
engine,
hash = SESSION_HASH,
search = '',
) {
const url = new URL(origin);
url.search = `?engine=${engine}&welcome=0`;
url.search = `?engine=${engine}&welcome=0${search}`;
url.hash = hash;
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await waitForEngine(page, engine);