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

+17 -2
View File
@@ -183,10 +183,25 @@ one application, and only the globe-bound parts move to the next engine.
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
on the real Cesium and Navara of a keyless dev server, with a real pointer
and real canvases; checks of state the app drives itself (contact sonar)
are skipped and named. On November 7, 2026 Cesium passed 63 of 63 and
Navara 66 of 66. `movers` and `lines` are required of every engine: Cesium
are skipped and named. On November 7, 2026 Cesium passed 66 of 66 and
Navara 69 of 69. `movers` and `lines` are required of every engine: Cesium
draws them with billboards, points, models and polyline primitives
(`src/globe/cesium/movers.js`, `lines.js`).
- **Fills on the ground** (`engine.ground`) are required of every engine
too. They come in sets of polygons with holes, discs, lon/lat cells or
corridors (a width in px or m), draped over terrain and 3D tiles. Each
item has its own fill, outline and `pickId`. A set has a draw `order`, and
shows, hides and replaces its items in bulk.
- Cesium draws them with batched `GroundPrimitive` and
`GroundPolylinePrimitive`.
- Navara draws them in one screen pass classified on the scene depth
(`src/globe/navara/ground/`).
- Navara's vector layers were measured against that pass and lost
(`docs/GLOBE-PRIMITIVES.md`).
- No layer draws through `engine.ground` yet. Earthquakes and fire
perimeters have prototypes in `render/ground.js`.
- On November 7, 2026, `scripts/qa-ground-suite.mjs` (the ground checks
with pixels) passed 3 of 3 on both engines.
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
engine-specific code outside the engines: renderer lines per engine, Cesium
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
+226
View File
@@ -0,0 +1,226 @@
# Globe primitives
A layer draws through the globe-engine contract (`src/globe/contract.js`),
not through Cesium or Navara. The contract has three drawing primitives:
- `engine.movers`: things at points;
- `engine.lines`: lines in the air;
- `engine.ground`: fills on the ground.
Each engine implements all three. Cesium's are in `src/globe/cesium/`,
Navara's in `src/globe/navara/`, and the fake engine's in
`src/globe/testing/fakeEngine.js`. The checks every engine must pass are in
`src/globe/testing/contractSuite.mjs`.
## Movers: things at a point
`engine.movers.createSet({ id, kind, capacity, ... })` makes a set of up to
`capacity` instances drawn as one batch. Write them with
`set(index, fields)` and `setCount(n)`, then upload the changes with one
`commit()`.
There are three kinds:
- `sprite`: an image from an atlas, a square of `sizePx`, turned by
`rotation`;
- `point`: a filled disc with an outline;
- `model`: a glTF in metres, at least `minimumPixelSize` on screen.
Use movers for aircraft, vessels, satellites, cameras, stations: anything
that has a position and is drawn the same size on screen (or as a model).
Instances move every frame without a rebuild. `depthTest` decides whether
the scene hides them. A pick returns the instance's `pickId` when the set is
`pickable`.
## Lines: polylines in the air
`engine.lines.createLine({ id, widthPx, color, ... })` makes one polyline.
You can change its positions, colour and model matrix at any time.
The width is in CSS pixels. The line follows the 3D positions it is given;
it does not follow the ground. Options:
- `arc: 'geodesic'` bends it along great circles;
- `dash` draws dashes;
- `depthFailAlpha` draws a dimmer copy where the scene hides it.
Use lines for trails, orbits, launch paths, a ring at a fixed height, and
leader lines.
## Ground: fills on the ground
`engine.ground.createSet({ id, kind, order, pickable, drape })` makes a set
of flat fills that drape over the terrain and the 3D tiles. Nothing is
extruded.
Work with a set like this:
- `replace(items)` checks and stores all its items at once;
- `commit()` draws them in place of the last ones;
- `show(visible)` shows or hides the whole set;
- `list()` names the sets.
Positions are `[longitude, latitude]` in degrees, as GeoJSON stores them.
Edges follow great circles.
There are four kinds:
| kind | item | for |
| ---------- | ------------------------------------------------------- | --------------------------------------------------------- |
| `polygon` | `rings: [outer, ...holes]`, `fill`, `outline` | perimeters, airspace, footprints, cones, KML polygons |
| `disc` | `center`, `radiusM` (metres along the ground) | earthquakes, air-quality stations, a pad zone |
| `cell` | `west`, `south`, `east`, `north` (meridians, parallels) | GNSS and FIRMS cells, privacy cells |
| `corridor` | `positions`, `color`, `widthPx` or `widthM` | routes, cables, roads, coverage lines, outlines in metres |
Item options:
- Every item has its own colours, rgba in 0..1; the alpha is the opacity.
- `fill` and `outline` (`{ color, widthPx }`) are optional; an outline runs
along every ring.
- Within a set, a later item draws on top. Between sets, a higher `order`
draws on top. All ground fills draw under movers, lines and models.
- `pickable: true` makes picks return an item's `pickId`, with `owner` (the
set) and `instance` (the item's index).
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Cesium
honours it; Navara drapes on whatever surface it draws.
### How each engine draws the ground
**Cesium** (`src/globe/cesium/ground.js`) builds at most two batches per set:
- one `GroundPrimitive` for all the fills (polygons, ellipses, rectangles and
metre corridors);
- one `GroundPolylinePrimitive` for all the outlines and pixel corridors.
Both use per-instance colours and the set's `classificationType`. Cesium
builds them in workers; the set's `ready()` turns true once they are drawn.
A set that is not pickable stays out of the pick pass, so it never hides
what lies under it.
**Navara** (`src/globe/navara/ground/`) draws every shown set in one screen
pass after tone mapping. Each pixel's ground position comes from the scene
depth (terrain and 3D tiles), and the pixel is tested against the items in
its screen cell:
- a disc against its radius;
- an area against its edges;
- a corridor against its distance to the line.
The tests run in each item's own gnomonic plane, so great-circle edges stay
straight. Fills and outlines are anti-aliased across one pixel and blended
in display space, in draw order, as Cesium blends them.
Large shapes get extra structure:
- An area with more than 32 edges gets a grid of bins. A pixel then tests
only the edges of its own bin.
- A long corridor is split into chunks of 4 segments.
- The terrain heights under each item bound its screen box.
Picks are CPU picks at the ground under the pointer (`src/globe/ground.js`).
Navara's vector layers (`src/globe/navara/ground/vector.js`) were measured
as the other candidate and kept only as a prototype. The measurements are
below.
### Measurements
Setup:
- Dubai, 1280×800, keyless, daylight;
- headless Chrome on an Apple M2 Pro (ANGLE Metal);
- `scripts/qa-ground-bench.mjs`.
Frame times are medians, from preUpdate to a 1-pixel readPixels after
postRender. "Still" means the camera is at rest; "moving" means it turns
every frame. The empty scene takes about 6–9 ms in both cases. Memory is
measured after a GC, against the scene before the commit.
Small polygons (10 vertices, 100–400 m, eight colours, 1 px outline):
| N | Cesium still / moving | Navara pass still / moving | Navara vector still / moving |
| ------ | --------------------- | -------------------------- | ---------------------------- |
| 10 | 4.9 / 5.0 ms | 5.7 / 9.5 ms | 5.7 / 24.5 ms |
| 1 000 | 5.8 / 5.9 ms | 6.2 / 10.9 ms | 5.9 / 29.0 ms |
| 10 000 | 12.0 / 11.2 ms | 10.9 / 15.9 ms | 5.7 / 29.0 ms |
What each approach costs, apart from frame time:
| N | Cesium | Navara pass | Navara vector |
| ------ | ---------------------------- | ------------------------- | -------------------------------- |
| 10 | 0.2 s build, +0.2 MB WebGL | 16 ms build, +0.04 MB GPU | 0.24 s build, about +70 MB WebGL |
| 1 000 | 0.23 s build, +19.5 MB WebGL | 68 ms build, +0.45 MB GPU | 0.23 s build, +144 MB WebGL |
| 10 000 | 0.8 s build, +40 MB WebGL | 0.26 s build, +4.0 MB GPU | 0.5 s build, +146 MB WebGL |
The JS heap at 10 000 polygons: Cesium +47 MB, Navara pass +16 MB, Navara
vector +19 MB.
One polygon of N vertices across the view (2 px outline), still:
| N | Cesium | Navara pass | Navara vector |
| ----- | ------ | ----------- | ------------- |
| 100 | 6.2 ms | 6.6 ms | 5.3 ms |
| 1 000 | 5.9 ms | 7.0 ms | 5.3 ms |
| 5 000 | 6.4 ms | 7.3 ms | 6.3 ms |
Without the bins, the pass took 46 ms at 1 000 vertices and 137 ms at 5 000.
Corridors (40-point lines, 2.5 px), still / moving:
| N | Cesium | Navara pass | Navara vector |
| ------ | -------------- | -------------- | ------------- |
| 10 | 5.8 / 5.8 ms | 5.7 / 9.7 ms | 6.6 / 25.8 ms |
| 1 000 | 11.2 / 11.2 ms | 18.6 / 24.4 ms | 6.7 / 28.4 ms |
| 10 000 | 21.6 / 21.6 ms | 135 / 174 ms | 6.3 / 28.0 ms |
At 10 000 corridors Cesium allocated 613 MB of WebGL memory, the pass 44 MB
and the vector layers about 70 MB.
Edge quality over the Hajar mountains, oblique view:
- **Cesium:** a sharp drape with its outline.
- **Navara pass:** the same.
- **Navara vector layers:** they are lit and tone-mapped like the scene, so
a fill's colour is not the one asked for. They draw under Navara's clouds.
A clamped polygon's outline is not drawn at all.
Show and hide (`scripts/qa-ground-switch.mjs`), before and after a Navara →
Cesium → Navara switch:
- **The pass:** correct every time.
- **Vector layers hidden by `layer.update({ show: false })`:** never hidden,
before or after a switch.
- **Vector layers hidden by deleting them** (today's workaround in
`navaraPolygons.js`): correct.
### Decision
**Navara draws every kind through the screen pass.** For polygons, discs and
cells it is as fast as Cesium up to 10 000 items. It holds a few megabytes
instead of a hundred or more. It draws exact colours and outlines, and its
show and hide are reliable.
The vector layers draw thin lines cheaply at rest. They cost about 20 ms
more on every frame the camera moves, whatever their count. They draw no
ground outlines and no metre widths. Their colours are lit, and hiding them
needs a rebuild.
Corridors are the pass's limit: about 1 000 lines draw at 18 ms, but 10 000
take 135 ms. A layer with more lines than that (Street Level's up to 14 400
sequences) needs a set-wide grid of bins first, as areas have, before it
moves to `engine.ground`.
### Choosing a primitive
- **A position and an on-screen size:** a mover.
- **A line at given heights, or one that moves or is restyled every frame**
(trails, orbits, a ring at the terrain height): a line.
- **Anything that lies on the ground** and follows its relief:
- an area: `polygon`, or `disc` or `cell` when that is its shape (they
are exact and cheaper);
- a line on the ground: `corridor`, with `widthM` when its width is a
real width (an outline in metres) and `widthPx` otherwise.
- Group items that change together into one set, and replace them in bulk.
A ground set is rebuilt on every commit, so an animated fill (a pulse, a
flowing route) is not a ground set yet.