Add ground fills to the globe contract

engine.ground draws sets of polygons (with holes), discs, cells and
corridors draped on the surface, with fill and outline colours, order and
picks, written in bulk and committed. Every engine implements it:

- Cesium batches each set into one GroundPrimitive for fills and one
  GroundPolylinePrimitive for outlines and pixel corridors, classified
  against terrain and 3D tiles; sets that are not pickable stay out of the
  pick pass.
- Navara draws them in one screen pass after tone mapping that finds each
  pixel's ground from the scene depth and tests the items in its screen
  cell; long rings are split into edge bins. Measured at Dubai against
  SDK vector layers, the pass matched Cesium's frame time up to 10,000
  polygons with a few megabytes instead of 70 to 146, drew exact colours
  and outlines, and hid reliably across an engine switch, where vector
  layers hidden with update({ show: false }) never hid. Dense corridors
  remain its limit (10,000 cost 135 ms a frame).
- The fake engine keeps the same registry and CPU picks.

The contract suite checks it on both engines (Cesium 66/66, Navara 69/69).
Earthquakes and fire perimeters have unwired ground prototypes whose
drawing matches today's Cesium one (0.998 on Cesium, 0.976 on Navara).
docs/GLOBE-PRIMITIVES.md describes the primitives and when to use each.
This commit is contained in:
German Khokhlov committed 2026-11-07 05:14:42 +04:00
1 parent 6dc643182e
commit 974a0b6361
27 files changed
+5256 -685

No files matched your search

+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env node
/**
* Measure engine.ground at Dubai (1280×800, keyless) with N polygons:
* Cesium (GroundPrimitive), Navara's screen pass (engine.ground,
* src/globe/navara/ground/pass.js) and the Navara vector-layer prototype
* (src/globe/navara/ground/vector.js). For each N: the time from commit to
* the first item drawn, the frame time with the camera still and turning
* (preUpdate to postRender, closed by a 1-pixel readPixels so the GPU work
* of the frame is in it), the JS heap after a GC and the WebGL bytes the
* page allocated (a tally of bufferData, texImage/texStorage and
* renderbuffer storage, less deletions), each against the empty scene; then
* one polygon over the Hajar mountains, obliquely, for the edge quality.
*
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
* node scripts/qa-ground-bench.mjs http://localhost:4271 [--counts=10,1000,10000]
* [--paths=cesium,navara-pass,navara-vector] [--out=DIR]
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const BLOCKED_HOSTS =
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
const args = process.argv.slice(2);
const origin = args.find((arg) => !arg.startsWith('--'));
const option = (name) =>
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
if (!origin) {
console.error('Usage: node scripts/qa-ground-bench.mjs <origin> [options]');
process.exit(2);
}
const counts = (option('counts') ?? '10,1000,10000').split(',').map(Number);
// polygons: N small polygons; corridors: N 40-point lines across the view,
// 2.5 px; outline: one polygon of N vertices round the view.
const shape = option('shape') ?? 'polygons';
const paths = (option('paths') ?? 'cesium,navara-pass,navara-vector').split(
',',
);
const out = option('out') ?? null;
if (out) fs.mkdirSync(out, { recursive: true });
// The page's WebGL allocations, before any script of the app runs.
function installGpuTally() {
const proto = WebGL2RenderingContext.prototype;
const sizes = new Map();
let total = 0;
const boundBuffers = new WeakMap();
const boundTextures = new WeakMap();
const boundRenderbuffers = new WeakMap();
const set = (key, bytes) => {
total += bytes - (sizes.get(key) ?? 0);
sizes.set(key, bytes);
};
const drop = (key) => {
total -= sizes.get(key) ?? 0;
sizes.delete(key);
};
const per = (map, gl) => {
if (!map.has(gl)) map.set(gl, new Map());
return map.get(gl);
};
const FLOAT = 0x1406;
const HALF_FLOAT = 0x140b;
const internalBytes = {
0x8814: 16, // RGBA32F
0x881a: 8, // RGBA16F
0x8815: 12, // RGB32F
0x822e: 4, // R32F
0x8230: 8, // RG32F
0x8058: 4, // RGBA8
0x8c43: 4, // SRGB8_ALPHA8
0x88f0: 4, // DEPTH24_STENCIL8
0x8cad: 8, // DEPTH32F_STENCIL8
0x8cac: 4, // DEPTH_COMPONENT32F
0x81a6: 4, // DEPTH_COMPONENT24
0x81a5: 2, // DEPTH_COMPONENT16
0x8229: 1, // R8
0x822b: 2, // RG8
};
const texelBytes = (internal, type) =>
internalBytes[internal] ??
(type === FLOAT ? 16 : type === HALF_FLOAT ? 8 : 4);
const wrap = (name, after) => {
const original = proto[name];
proto[name] = function (...call) {
const result = original.apply(this, call);
try {
after.call(this, call, result);
} catch {
// A tally error never breaks the page.
}
return result;
};
};
wrap('bindBuffer', function ([target, buffer]) {
per(boundBuffers, this).set(target, buffer);
});
wrap('bufferData', function ([target, data]) {
const buffer = per(boundBuffers, this).get(target);
if (buffer)
set(buffer, typeof data === 'number' ? data : (data?.byteLength ?? 0));
});
wrap('deleteBuffer', ([buffer]) => drop(buffer));
wrap('bindTexture', function ([target, texture]) {
per(boundTextures, this).set(target, texture);
});
wrap('texImage2D', function (call) {
const texture = per(boundTextures, this).get(call[0]);
if (!texture || call[1] !== 0) return;
let bytes;
if (call.length >= 8 && typeof call[3] === 'number')
bytes = call[3] * call[4] * texelBytes(call[2], call[7]);
else {
const source = call[5];
bytes = (source?.width ?? 0) * (source?.height ?? 0) * 4;
}
set(texture, bytes);
});
wrap('texStorage2D', function ([target, levels, internal, width, height]) {
const texture = per(boundTextures, this).get(target);
if (texture)
set(
texture,
width * height * texelBytes(internal) * (levels > 1 ? 4 / 3 : 1),
);
});
wrap('texImage3D', function (call) {
const texture = per(boundTextures, this).get(call[0]);
if (texture && call[1] === 0)
set(texture, call[3] * call[4] * call[5] * texelBytes(call[2], call[8]));
});
wrap('texStorage3D', function ([target, , internal, width, height, depth]) {
const texture = per(boundTextures, this).get(target);
if (texture) set(texture, width * height * depth * texelBytes(internal));
});
wrap('deleteTexture', ([texture]) => drop(texture));
wrap('bindRenderbuffer', function ([, renderbuffer]) {
boundRenderbuffers.set(this, renderbuffer);
});
wrap('renderbufferStorage', function ([, internal, width, height]) {
const renderbuffer = boundRenderbuffers.get(this);
if (renderbuffer) set(renderbuffer, width * height * texelBytes(internal));
});
wrap(
'renderbufferStorageMultisample',
function ([, samples, internal, width, height]) {
const renderbuffer = boundRenderbuffers.get(this);
if (renderbuffer)
set(
renderbuffer,
width * height * texelBytes(internal) * Math.max(1, samples),
);
},
);
wrap('deleteRenderbuffer', ([renderbuffer]) => drop(renderbuffer));
window.__gpuBytes = () => total;
}
const DUBAI = { lon: 55.27, lat: 25.15, alt: 70000 };
async function openEngine(browser, kind) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
page.on('pageerror', (error) => console.log(' page error:', error.message));
await page.evaluateOnNewDocument(installGpuTally);
await page.setRequestInterception(true);
page.on('request', (request) => {
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
else request.continue();
});
await page.goto(
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${DUBAI.lat}&lon=${DUBAI.lon}&alt=${DUBAI.alt}&heading=0&pitch=-90&map=osm&l=`,
);
await page.waitForFunction(
(expected) =>
window.__godsEyeView?.engine?.kind === expected &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 300_000, polling: 250 },
kind,
);
// Daylight in Dubai (Navara lights the scene with the app clock's sun).
await page.evaluate(async () => {
const { appClock } = await import('/src/time/clock.js');
appClock.freeze(Date.UTC(2026, 10, 7, 8, 0));
window.__godsEyeView.engine.render.request('ground-bench-daylight');
});
const cdp = await page.createCDPSession();
await cdp.send('HeapProfiler.enable');
// Helpers in the page: polygons, the approach under test, timing.
await page.evaluate(() => {
const { engine } = window.__godsEyeView;
const random = (seed) => () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 2 ** 32;
};
const PALETTE = [
[1, 0.2, 0.2],
[1, 0.6, 0],
[1, 1, 0.2],
[0.2, 0.9, 0.3],
[0, 0.8, 1],
[0.3, 0.4, 1],
[0.7, 0.3, 1],
[0.9, 0.9, 0.9],
];
window.__bench = {
engine,
// N polygons over Dubai (deterministic): 10 vertices, 100–400 m;
// item 0 a 3 km magenta polygon at the view centre.
polygons(count) {
const next = random(7);
const items = [];
for (let index = 0; index < count; index++) {
const centre =
index === 0
? [55.27, 25.15]
: [54.95 + next() * 0.65, 24.95 + next() * 0.4];
const radius = index === 0 ? 3000 : 100 + next() * 300;
const ring = [];
for (let k = 0; k < 10; k++) {
const angle = (k / 10) * 2 * Math.PI;
const r = radius * (0.75 + 0.25 * (index === 0 ? 1 : next()));
ring.push([
centre[0] +
(r * Math.cos(angle)) /
(111320 * Math.cos((centre[1] * Math.PI) / 180)),
centre[1] + (r * Math.sin(angle)) / 110540,
]);
}
items.push({
rings: [ring],
// Eight colours, as the layers use a handful (depth bands,
// levels); the vector prototype makes one layer per style.
fill:
index === 0
? [1, 0, 1, 0.9]
: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.35],
outline: { color: [1, 1, 1, 0.9], widthPx: 1 },
pickId: index,
});
}
return items;
},
corridors(count) {
const next = random(11);
const items = [window.__bench.centreDisc()];
for (let index = 1; index < count; index++) {
let lon = 54.95 + next() * 0.65;
let lat = 24.95 + next() * 0.4;
const heading = next() * 2 * Math.PI;
const positions = [];
for (let k = 0; k < 40; k++) {
positions.push([lon, lat]);
const turn = heading + (next() - 0.5) * 1.2;
lon += 0.004 * Math.cos(turn);
lat += 0.004 * Math.sin(turn);
}
items.push({
positions,
color: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.9],
widthPx: 2.5,
pickId: index,
});
}
return items;
},
// The magenta mark of the corridors scenario: a 40 px corridor.
centreDisc: () => ({
positions: [
[55.255, 25.15],
[55.285, 25.15],
],
color: [1, 0, 1, 0.9],
widthPx: 40,
pickId: 0,
}),
outline(count) {
const ring = [];
for (let k = 0; k < count; k++) {
const angle = (k / count) * 2 * Math.PI;
const wobble = 1 + 0.03 * Math.sin(angle * 37);
ring.push([
55.27 + 0.28 * wobble * Math.cos(angle),
25.15 + 0.2 * wobble * Math.sin(angle),
]);
}
return [
{
rings: [ring],
fill: [1, 0, 1, 0.5],
outline: { color: [1, 1, 1, 1], widthPx: 2 },
pickId: 0,
},
];
},
async ground(kind) {
if (kind !== 'navara-vector') return engine.ground;
const { createNavaraVectorGround } =
await import('/src/globe/navara/ground/vector.js');
window.__bench.vector ??= createNavaraVectorGround(
engine.extensions.navara.view,
);
return window.__bench.vector.ground;
},
frame: () =>
new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
engine.render.request('ground-bench');
}),
// Frame times in ms (preUpdate → postRender + 1 px readPixels).
async frameTimes(frames, turn = false) {
const gl = engine.host.canvas.getContext('webgl2');
const pixel = new Uint8Array(4);
const times = [];
let start = 0;
const removeStart = engine.frames.preUpdate.addEventListener(() => {
start = performance.now();
});
const removeEnd = engine.frames.postRender.addEventListener(() => {
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
times.push(performance.now() - start);
});
const pose = engine.camera.pose();
try {
for (let index = 0; index < frames; index++) {
if (turn)
engine.camera.setView({
destination: pose.position,
orientation: {
heading: pose.heading + (index % 2 ? 0.002 : -0.002),
pitch: pose.pitch,
roll: 0,
},
});
await window.__bench.frame();
}
} finally {
removeStart();
removeEnd();
engine.camera.setView({
destination: pose.position,
orientation: { heading: pose.heading, pitch: pose.pitch, roll: 0 },
});
}
times.sort((a, b) => a - b);
const mean = times.reduce((a, b) => a + b, 0) / times.length;
return {
mean,
median: times[Math.floor(times.length / 2)],
p95: times[Math.floor(times.length * 0.95)],
};
},
// The centre pixel shows the magenta item.
centreMagenta() {
const canvas = engine.host.canvas;
const c = document.createElement('canvas');
c.width = 4;
c.height = 4;
const context = c.getContext('2d');
context.drawImage(
canvas,
canvas.width / 2 - 2,
canvas.height / 2 - 2,
4,
4,
0,
0,
4,
4,
);
const [r, g, b] = context.getImageData(1, 1, 1, 1).data;
// Lit (the vector layers) or exact (the pass, Cesium).
return r > 150 && b > 150 && g < Math.min(r, b) - 60;
},
};
});
return { page, cdp };
}
async function settleTiles(page) {
await page.evaluate(async () => {
const { engine, frame } = window.__bench;
const started = performance.now();
let loadedFrames = 0;
while (performance.now() - started < 60000 && loadedFrames < 30) {
await frame();
loadedFrames = engine.surface.tilesLoaded() ? loadedFrames + 1 : 0;
}
});
}
async function memory(page, cdp) {
await cdp.send('HeapProfiler.collectGarbage');
await cdp.send('HeapProfiler.collectGarbage');
return page.evaluate(() => ({
js: performance.memory.usedJSHeapSize,
gpu: window.__gpuBytes(),
navara:
window.__bench.engine.extensions?.navara?.view?.memoryStats?.()
?.gpuBytesEst ?? null,
}));
}
const mb = (bytes) => (bytes / 2 ** 20).toFixed(2);
const rows = [];
async function measure(browser, kind) {
const engineKind = kind === 'cesium' ? 'cesium' : 'navara';
const { page, cdp } = await openEngine(browser, engineKind);
const renderer = await page.evaluate(() => {
const gl = window.__bench.engine.host.canvas.getContext('webgl2');
const info = gl.getExtension('WEBGL_debug_renderer_info');
return info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : 'unknown';
});
console.log(`${kind}: ${renderer}`);
await settleTiles(page);
// Warm the approach (shader compile) with one item, then measure empty.
await page.evaluate(async (kind) => {
const ground = await window.__bench.ground(kind);
const set = ground.createSet({ id: 'bench-warm', kind: 'polygon' });
set.replace(window.__bench.polygons(1));
set.commit();
const started = performance.now();
while (
!window.__bench.centreMagenta() &&
performance.now() - started < 30000
)
await window.__bench.frame();
set.destroy();
for (let index = 0; index < 10; index++) await window.__bench.frame();
}, kind);
const emptyFrames = {
still: await page.evaluate(() => window.__bench.frameTimes(90)),
turning: await page.evaluate(() => window.__bench.frameTimes(90, true)),
};
rows.push({ path: kind, shape, count: 0, ...emptyFrames });
for (const count of counts) {
const before = await memory(page, cdp);
const built = await page.evaluate(
async (kind, count, shape) => {
const ground = await window.__bench.ground(kind);
const items =
shape === 'corridors'
? window.__bench.corridors(count)
: shape === 'outline'
? window.__bench.outline(count)
: window.__bench.polygons(count);
const set = ground.createSet({
id: 'bench',
kind: shape === 'corridors' ? 'corridor' : 'polygon',
});
const started = performance.now();
set.replace(items);
set.commit();
const committed = performance.now() - started;
while (
!window.__bench.centreMagenta() &&
performance.now() - started < 120000
)
await window.__bench.frame();
window.__bench.set = set;
const drawnMs = performance.now() - started;
for (let index = 0; index < 10; index++) await window.__bench.frame();
return { commitMs: committed, drawnMs };
},
kind,
count,
shape,
);
// Right after the build, before tiles stream again.
const loaded = await memory(page, cdp);
const passBytes = await page.evaluate(
() => window.__bench.engine.groundDiagnostics?.()?.textureBytes ?? null,
);
await settleTiles(page);
const frames = {
still: await page.evaluate(() => window.__bench.frameTimes(90)),
turning: await page.evaluate(() => window.__bench.frameTimes(90, true)),
};
if (out)
await page.screenshot({
path: path.join(out, `dubai-${shape}-${kind}-${count}.png`),
});
await page.evaluate(async () => {
window.__bench.set.destroy();
for (let index = 0; index < 20; index++) await window.__bench.frame();
});
const released = await memory(page, cdp);
const row = {
path: kind,
shape,
count,
...built,
...frames,
jsMB: mb(loaded.js - before.js),
gpuMB: mb(loaded.gpu - before.gpu),
navaraGpuMB:
loaded.navara == null ? null : mb(loaded.navara - before.navara),
passTexturesMB: passBytes == null ? null : mb(passBytes),
jsAfterDestroyMB: mb(released.js - before.js),
gpuAfterDestroyMB: mb(released.gpu - before.gpu),
};
rows.push(row);
console.log(
` N=${count}: drawn ${row.drawnMs.toFixed(0)} ms (commit ${row.commitMs.toFixed(0)}), frame still ${frames.still.median.toFixed(2)} ms (empty ${emptyFrames.still.median.toFixed(2)}), turning ${frames.turning.median.toFixed(2)} ms (empty ${emptyFrames.turning.median.toFixed(2)}), JS +${row.jsMB} MB, WebGL +${row.gpuMB} MB${row.navaraGpuMB == null ? '' : `, Navara gpuBytesEst +${row.navaraGpuMB} MB`}${row.passTexturesMB == null || kind !== 'navara-pass' ? '' : `, pass textures ${row.passTexturesMB} MB`}; after destroy JS +${row.jsAfterDestroyMB}, WebGL +${row.gpuAfterDestroyMB} MB`,
);
}
// Edge quality over terrain: one polygon on the Hajar mountains, oblique.
if (out && shape === 'polygons') {
await page.evaluate(async (kind) => {
const { engine, frame } = window.__bench;
const ground = await window.__bench.ground(kind);
const geo = await import('/src/geo/index.js');
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(56.05, 24.98, 9000),
orientation: { heading: 0.5, pitch: -0.45, roll: 0 },
});
const set = ground.createSet({ id: 'bench-terrain', kind: 'polygon' });
const ring = [];
for (let k = 0; k < 24; k++) {
const angle = (k / 24) * 2 * Math.PI;
ring.push([
56.12 + 0.03 * Math.cos(angle),
25.07 + 0.022 * Math.sin(angle),
]);
}
set.replace([
{
rings: [ring],
fill: [1, 0.3, 0, 0.45],
outline: { color: [1, 1, 0, 1], widthPx: 2 },
},
]);
set.commit();
for (let index = 0; index < 20; index++) await frame();
}, kind);
await settleTiles(page);
await page.screenshot({ path: path.join(out, `terrain-${kind}.png`) });
}
await page.close();
}
const browser = await puppeteer.launch({ headless: true });
try {
for (const kind of paths) await measure(browser, kind);
} finally {
await browser.close();
}
if (out)
fs.writeFileSync(
path.join(out, `bench-${shape}.json`),
JSON.stringify(rows, null, 2),
);