mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Bin Navara ground corridors set-wide and give corridors glow, pulse and dash
Navara's ground pass cut each corridor into chunks and tested them per screen cell, so dense corridor sets were slow (10,000 cost about 150 ms a frame). A corridor set is now cut into pieces along its great circles, sorted into lon/lat tiles, each with a pyramid of bins listing the segment runs within its margin; a pixel takes its tile and the finest bin that covers its reach and tests only those runs. At 10,000 corridors a frame now takes 11.8 ms still (Cesium 23 ms), at 15,000 17.9 ms. A pixel corridor's width is measured across the line, so diagonal lines no longer draw up to 1.4 times wider. Pixel corridors take three looks, evaluated on the GPU from a time uniform: Cesium's PolylineGlow falloff (glow), an alpha pulse and the annotation route's flowing dash. While a shown set pulses or flows the engine draws continuously, as Cesium did. Traffic's jam lines take the glow and pulse again and the Directions route its flowing dash and render hold, so on Cesium both match the drawing before they moved to the contract pixel for pixel. The contract suite passes on both engines (Cesium 69/69, Navara 72/72).
This commit is contained in:
1 parent
48885d9e61
commit
47d63128ff
30 files changed
+2501
-391
No files matched your search
@@ -209,12 +209,15 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
- Navara draws them in one screen pass classified on the scene depth
|
||||
(`src/globe/navara/ground/`). A corridor set is tiles of binned
|
||||
segments (`corridors.js`): 10,000 corridors draw in about 12 ms still
|
||||
and 15 ms moving, where Cesium takes 23 ms (the chunks before took 151
|
||||
and 14 ms moving, where Cesium takes 23 ms (the chunks before took 151
|
||||
and 196 ms).
|
||||
- Navara's vector layers were measured against that pass and lost
|
||||
(`docs/GLOBE-PRIMITIVES.md`).
|
||||
- On November 7, 2026, `scripts/qa-ground-suite.mjs` (the ground checks
|
||||
with pixels) passed 3 of 3 on both engines.
|
||||
with pixels, the corridor looks among them) passed 4 of 4 on both
|
||||
engines, and `scripts/qa-ground-looks.mjs` drew the jam line and the
|
||||
route on Cesium as the old materials did, with no pixel changed at four
|
||||
moments of the pulse and the dashes.
|
||||
- **Rasters on the ground** (`engine.imagery`) are required of every engine
|
||||
too: layers of Web Mercator tiles from a URL template or of one image (a
|
||||
canvas, an image, RGBA bytes or an image URL) over a rectangle, each with
|
||||
|
||||
+10
-10
@@ -237,18 +237,18 @@ At 10 000 corridors Cesium allocated 613 MB of WebGL memory, the pass 44 MB
|
||||
and the vector layers about 70 MB.
|
||||
|
||||
The pass with corridor tiles, measured again with the pass before them on
|
||||
the same afternoon (the machine busier, the empty scene 6.0–8.9 ms):
|
||||
the same day (the machine busier, the empty scene 6.7–8.1 ms):
|
||||
|
||||
| N | Cesium | pass before | pass with tiles |
|
||||
| ------ | -------------- | -------------- | --------------- |
|
||||
| 1 000 | 12.8 / 13.9 ms | 19.8 / 24.9 ms | 9.1 / 12.6 ms |
|
||||
| 10 000 | 23.1 / 23.2 ms | 151 / 196 ms | 12.1 / 15.4 ms |
|
||||
| 15 000 | 31.8 / 31.4 ms | 226 / 289 ms | 17.7 / 19.7 ms |
|
||||
| 1 000 | 12.8 / 13.9 ms | 19.8 / 24.9 ms | 8.7 / 10.0 ms |
|
||||
| 10 000 | 23.1 / 23.2 ms | 151 / 196 ms | 11.8 / 13.8 ms |
|
||||
| 15 000 | 31.8 / 31.4 ms | 226 / 289 ms | 17.9 / 19.9 ms |
|
||||
|
||||
The tiles hold about what the chunks held: 47 MB of textures at 10 000
|
||||
corridors (46 MB before), 68 MB at 15 000 (69 MB). Packing takes longer:
|
||||
the commit of 10 000 corridors went from 0.6 s to about 1.9 s, and 15 000
|
||||
from 0.8 s to 3.0 s.
|
||||
The tiles hold about what the chunks held: 43 MB of textures at 10 000
|
||||
corridors (46 MB before), 66 MB at 15 000 (69 MB). Packing takes longer:
|
||||
the commit of 10 000 corridors went from 0.6 s to 1.1 s, and 15 000 from
|
||||
0.8 s to 1.7 s.
|
||||
|
||||
A pixel corridor's width is now measured across the line (the gradient of
|
||||
the distance to it), so it is the same at any angle, as on Cesium. Before,
|
||||
@@ -284,8 +284,8 @@ ground outlines and no metre widths. Their colours are lit, and hiding them
|
||||
needs a rebuild.
|
||||
|
||||
Corridors were the pass's limit: 10 000 lines took 135 ms. With a
|
||||
set-wide grid of bins (corridor tiles), 10 000 take 12 ms, under Cesium's
|
||||
23 ms.
|
||||
set-wide grid of bins (corridor tiles), 10 000 take 12 ms still and 14 ms
|
||||
moving, under Cesium's 23 ms.
|
||||
|
||||
## Imagery: rasters on the ground
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"screenSpaceHandlerFiles": 4,
|
||||
"engineNameLines": 66
|
||||
},
|
||||
"measuredAt": "after one layer manifest and one tools tree, 2026-11-07"
|
||||
"measuredAt": "after animated and binned ground corridors, 2026-11-07"
|
||||
}
|
||||
@@ -1653,5 +1653,7 @@
|
||||
"src/globe/navara/imagery/index.test.mjs",
|
||||
"src/globe/navara/imagery/pass.js",
|
||||
"src/layers/recentImagery/render/display.js",
|
||||
"src/layers/recentImagery/render/display.test.mjs"
|
||||
"src/layers/recentImagery/render/display.test.mjs",
|
||||
"src/globe/navara/ground/corridors.js",
|
||||
"scripts/qa-ground-looks.mjs"
|
||||
]
|
||||
@@ -3577,7 +3577,8 @@
|
||||
"src/globe/navara/imagery/pass.js",
|
||||
"src/globe/navara/imagery/index.js",
|
||||
"src/globe/imagery.js",
|
||||
"src/globe/cesium/imagery.js"
|
||||
"src/globe/cesium/imagery.js",
|
||||
"src/globe/navara/ground/corridors.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
@@ -241,9 +241,10 @@ async function openEngine(browser, kind) {
|
||||
}
|
||||
return items;
|
||||
},
|
||||
// N - 1 lines, then the magenta mark on top of them.
|
||||
corridors(count) {
|
||||
const next = random(11);
|
||||
const items = [window.__bench.centreDisc()];
|
||||
const items = [];
|
||||
for (let index = 1; index < count; index++) {
|
||||
let lon = 54.95 + next() * 0.65;
|
||||
let lat = 24.95 + next() * 0.4;
|
||||
@@ -262,6 +263,7 @@ async function openEngine(browser, kind) {
|
||||
pickId: index,
|
||||
});
|
||||
}
|
||||
items.push(window.__bench.centreDisc());
|
||||
return items;
|
||||
},
|
||||
// The magenta mark of the corridors scenario: a 40 px corridor.
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compare the animated ground corridors with the Cesium drawing they
|
||||
* replace, on the Cesium engine (keyless, headless): Street Traffic's jam
|
||||
* heat lines (PolylineGlow, glowPower 0.25, alpha 0.55 ± 0.2 on
|
||||
* sin(now / 260 ms)) and the Directions route (the annotation route's
|
||||
* GevRouteFlow material, 9 px). One road and one route over Dubai, nadir,
|
||||
* drawn at fixed page times (performance.now() frozen), so every variant
|
||||
* shows the same phase of the pulse and the dashes:
|
||||
*
|
||||
* --variant=old the old drawing, rebuilt with Cesium directly: a
|
||||
* GroundPolylinePrimitive with PolylineGlow, its alpha
|
||||
* set from the frozen time, and the Directions route
|
||||
* entity with FlowMaterialProperty;
|
||||
* --variant=engine engine.ground: the jam as the traffic layer gives it
|
||||
* (glow and pulse when the build has them), the route
|
||||
* through the Directions display.
|
||||
*
|
||||
* Both drape on terrain and tiles ('both'): keyless there are no 3D tiles,
|
||||
* where the traffic layer drapes its heat lines.
|
||||
*
|
||||
* `--engine=navara` draws the engine variant on Navara (daylight app clock;
|
||||
* its frames at the live page time, 400 ms apart: Navara's frame loop stops
|
||||
* on a frozen one).
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-looks.mjs http://localhost:4271 --variant=old --out=DIR
|
||||
* node scripts/qa-ground-looks.mjs --compare=DIR_A,DIR_B
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
// Page times (ms) the frames are drawn at: across one pulse (1.63 s) and
|
||||
// a few dash cells.
|
||||
const TIMES = [100_000, 100_400, 100_800, 101_200];
|
||||
const CROP = { left: 340, top: 200, width: 600, height: 400 };
|
||||
// Metres over the ground: the road and the route fill the crop.
|
||||
const ALTITUDE = 6000;
|
||||
|
||||
async function compare([a, b]) {
|
||||
const rows = [];
|
||||
for (const name of fs
|
||||
.readdirSync(a)
|
||||
.filter((file) => file.endsWith('.png'))) {
|
||||
if (!fs.existsSync(path.join(b, name))) continue;
|
||||
const read = (dir) =>
|
||||
sharp(path.join(dir, name))
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const [left, right] = await Promise.all([read(a), read(b)]);
|
||||
let sum = 0;
|
||||
let over = 0;
|
||||
const pixels = left.info.width * left.info.height;
|
||||
for (let i = 0; i < left.data.length; i += 3) {
|
||||
let most = 0;
|
||||
for (let k = 0; k < 3; k++)
|
||||
most = Math.max(most, Math.abs(left.data[i + k] - right.data[i + k]));
|
||||
sum += most;
|
||||
if (most > 24) over++;
|
||||
}
|
||||
rows.push({
|
||||
image: name,
|
||||
meanDiff: +(sum / pixels).toFixed(2),
|
||||
over24: +(over / pixels).toFixed(4),
|
||||
});
|
||||
}
|
||||
console.table(rows);
|
||||
}
|
||||
|
||||
async function draw(origin, variant, out, engineKind) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 600_000,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (error) => console.log('page error:', error.message));
|
||||
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=${engineKind}#v=2&lat=25.2&lon=55.3&alt=${ALTITUDE}&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(kind) =>
|
||||
window.__godsEyeView?.engine?.kind === kind &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
engineKind,
|
||||
);
|
||||
await page.evaluate(
|
||||
async (variant, altitude) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7, 8, 0));
|
||||
const frame = () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-ground-looks');
|
||||
});
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(55.3, 25.2, altitude),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
// Tiles in.
|
||||
const started = performance.now();
|
||||
let loaded = 0;
|
||||
while (performance.now() - started < 60_000 && loaded < 30) {
|
||||
await frame();
|
||||
loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0;
|
||||
}
|
||||
// A jam road across the view, and a route with turns below it.
|
||||
const road = [
|
||||
[55.288, 25.204],
|
||||
[55.296, 25.206],
|
||||
[55.304, 25.203],
|
||||
[55.312, 25.205],
|
||||
];
|
||||
const route = [
|
||||
[55.286, 25.196],
|
||||
[55.294, 25.197],
|
||||
[55.297, 25.192],
|
||||
[55.306, 25.193],
|
||||
[55.314, 25.196],
|
||||
];
|
||||
const policy = await import('/src/layers/traffic/policy.js');
|
||||
const jamRgba = policy.HEAT_JAM_COLOR.rgba();
|
||||
if (variant === 'old') {
|
||||
// The app's own Cesium module: the URL the dev server gave the
|
||||
// engine's import of it.
|
||||
const served = await (
|
||||
await fetch('/src/globe/cesium/ground.js')
|
||||
).text();
|
||||
const Cesium = await import(
|
||||
served.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1]
|
||||
);
|
||||
const { FlowMaterialProperty, ensureFlowFabricRegistered } =
|
||||
await import('/src/annotations/worldAnnotationRenderer.js');
|
||||
const { viewer } = engine.extensions.cesium;
|
||||
const color = new Cesium.Color(...jamRgba.slice(0, 3), 0.55);
|
||||
const jam = viewer.scene.groundPrimitives.add(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: [
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.GroundPolylineGeometry({
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(road.flat()),
|
||||
width: 9,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material: Cesium.Material.fromType('PolylineGlow', {
|
||||
color,
|
||||
glowPower: 0.25,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
ensureFlowFabricRegistered();
|
||||
viewer.entities.add({
|
||||
id: 'qa-route',
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(route.flat()),
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty('#39d0ff'),
|
||||
clampToGround: true,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
// The traffic animation set the jam's alpha every frame.
|
||||
engine.frames.preRender.addEventListener(() => {
|
||||
jam.appearance.material.uniforms.color.alpha =
|
||||
policy.HEAT_JAM_BASE_ALPHA +
|
||||
policy.HEAT_JAM_PULSE_ALPHA * Math.sin(performance.now() / 260);
|
||||
});
|
||||
window.__qaReady = () => jam.ready;
|
||||
} else {
|
||||
const { createDirectionsDisplay } =
|
||||
await import('/src/layers/directions/render/display.js');
|
||||
const animated = policy.HEAT_JAM_GLOW_POWER !== undefined;
|
||||
const set = engine.ground.createSet({
|
||||
id: 'qa-jam',
|
||||
kind: 'corridor',
|
||||
drape: 'both',
|
||||
});
|
||||
// As src/layers/traffic/rendering.js gives the jam bucket.
|
||||
set.replace([
|
||||
{
|
||||
positions: road,
|
||||
widthPx: policy.HEAT_LINE_JAM_WIDTH,
|
||||
...(animated
|
||||
? {
|
||||
color: [...jamRgba.slice(0, 3), 1],
|
||||
glow: { power: policy.HEAT_JAM_GLOW_POWER },
|
||||
pulse: {
|
||||
periodMs: policy.HEAT_JAM_PULSE_PERIOD_MS,
|
||||
min:
|
||||
policy.HEAT_JAM_BASE_ALPHA -
|
||||
policy.HEAT_JAM_PULSE_ALPHA,
|
||||
max:
|
||||
policy.HEAT_JAM_BASE_ALPHA +
|
||||
policy.HEAT_JAM_PULSE_ALPHA,
|
||||
},
|
||||
}
|
||||
: {
|
||||
color: [...jamRgba.slice(0, 3), policy.HEAT_JAM_BASE_ALPHA],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
set.commit();
|
||||
const display = createDirectionsDisplay(engine);
|
||||
display.setRoute(route);
|
||||
window.__qaReady = () => set.ready();
|
||||
}
|
||||
const until = Date.now() + 60_000;
|
||||
while (!window.__qaReady() && Date.now() < until) await frame();
|
||||
loaded = 0;
|
||||
while (Date.now() < until + 60_000 && loaded < 30) {
|
||||
await frame();
|
||||
loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0;
|
||||
}
|
||||
},
|
||||
variant,
|
||||
ALTITUDE,
|
||||
);
|
||||
for (const time of TIMES) {
|
||||
await page.evaluate(async (time) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
// Navara's frame loop stops on a frozen page clock: there the frames
|
||||
// are drawn at the live time, 400 ms apart.
|
||||
if (engine.kind === 'navara')
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
else performance.now = () => time;
|
||||
for (let k = 0; k < 4; k++)
|
||||
await new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-ground-looks');
|
||||
});
|
||||
}, time);
|
||||
await page.screenshot({
|
||||
path: path.join(out, `looks-${time}.png`),
|
||||
clip: {
|
||||
x: CROP.left,
|
||||
y: CROP.top,
|
||||
width: CROP.width,
|
||||
height: CROP.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const pair = option('compare');
|
||||
if (pair) await compare(pair.split(','));
|
||||
else {
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
if (!origin) {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-ground-looks.mjs <origin> --variant=old|engine --out=DIR | --compare=A,B',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const variant = option('variant') ?? 'engine';
|
||||
// The old drawing is Cesium's own; engine.ground also draws on Navara.
|
||||
const engineKind =
|
||||
variant === 'old' ? 'cesium' : (option('engine') ?? 'cesium');
|
||||
await draw(origin, variant, option('out') ?? 'qa-ground-looks', engineKind);
|
||||
}
|
||||
+156
-12
@@ -5,14 +5,25 @@
|
||||
// fill (polygons, discs as EllipseGeometry, cells as RectangleGeometry,
|
||||
// metre corridors as CorridorGeometry) and one GroundPolylinePrimitive of
|
||||
// every outline and pixel corridor, over the fills, both with per-instance
|
||||
// colours and the set's classificationType. Cesium builds them in workers;
|
||||
// the engine keeps requesting frames until they are drawn. A set made
|
||||
// colours and the set's classificationType. A pixel corridor with a glow,
|
||||
// a pulse or a dash draws in a GroundPolylinePrimitive of its own look and
|
||||
// colour (a material has one colour), over the set's other batches; its
|
||||
// material reads a time uniform the engine writes before every frame, and
|
||||
// while a shown set has a pulse or a dash the engine asks for a frame after
|
||||
// every frame, the continuous render Cesium drew them with. Cesium builds
|
||||
// them in workers; the engine keeps requesting frames until they are
|
||||
// drawn. A set made
|
||||
// without pickable stays out of the pick pass (a GroundPrimitive with
|
||||
// allowPicking false still writes its stencil there and hides what lies
|
||||
// under it); identify() names a picked item.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { areaRings, createGroundRegistry } from '../ground.js';
|
||||
import {
|
||||
areaRings,
|
||||
createGroundRegistry,
|
||||
DASH_EDGE,
|
||||
groundSeconds,
|
||||
} from '../ground.js';
|
||||
|
||||
// Read when a set is made: no module reads Cesium while it loads.
|
||||
const classificationOf = (drape) =>
|
||||
@@ -55,6 +66,72 @@ const DEFAULT_FACTORIES = {
|
||||
|
||||
const colorOf = (rgba) => new Cesium.Color(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
|
||||
// One material for every animated corridor: PolylineGlow's falloff across
|
||||
// the width (st.t) and taper along it (st.s), an alpha pulse, and the
|
||||
// annotation route's flowing dashes (src/annotations/worldAnnotationRenderer.js,
|
||||
// GevRouteFlow): its diffuse plus emission and alpha. Each part is off at
|
||||
// its zero uniform. Fragments are LDR and premultiplied, as Cesium writes
|
||||
// ground polylines.
|
||||
const CORRIDOR_MATERIAL = 'GevGroundCorridor';
|
||||
const CORRIDOR_SOURCE = /* glsl */ `
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
vec2 st = materialInput.st;
|
||||
vec3 rgb = color.rgb;
|
||||
float alpha = color.a;
|
||||
if (pulsePeriod > 0.0)
|
||||
alpha *= mix(pulseMin, pulseMax, 0.5 + 0.5 * sin(6.283185307179586 * time / pulsePeriod));
|
||||
if (glowPower > 0.0) {
|
||||
float glow = glowPower / abs(st.t - 0.5) - (glowPower / 0.5);
|
||||
if (taperPower <= 0.99999)
|
||||
glow *= min(1.0, taperPower / (0.5 - st.s * 0.5) - (taperPower / 0.5));
|
||||
rgb = max(vec3(glow - 1.0 + rgb), rgb);
|
||||
alpha *= clamp(glow, 0.0, 1.0);
|
||||
}
|
||||
if (dashCells > 0.0) {
|
||||
float flow = fract(st.s * dashCells - time * dashSpeed);
|
||||
float on = 1.0 - smoothstep(dashDuty - ${DASH_EDGE}, dashDuty + ${DASH_EDGE}, flow);
|
||||
rgb *= 1.0 + dashEmission * on;
|
||||
alpha *= max(on, dashGap);
|
||||
}
|
||||
material.emission = rgb;
|
||||
material.alpha = alpha;
|
||||
return material;
|
||||
}`;
|
||||
|
||||
/** The uniforms of an animated corridor's material (pure, for tests). */
|
||||
export function corridorUniforms(item) {
|
||||
const { glow, pulse, dash } = item.animation;
|
||||
return {
|
||||
color: colorOf(item.fill),
|
||||
time: 0,
|
||||
glowPower: glow?.power ?? 0,
|
||||
taperPower: glow?.taper ?? 1,
|
||||
pulsePeriod: pulse ? pulse.periodMs / 1000 : 0,
|
||||
pulseMin: pulse?.min ?? 1,
|
||||
pulseMax: pulse?.max ?? 1,
|
||||
dashCells: dash?.cells ?? 0,
|
||||
dashDuty: dash?.duty ?? 0.5,
|
||||
dashSpeed: dash?.speed ?? 0,
|
||||
dashGap: dash?.gapAlpha ?? 0,
|
||||
dashEmission: dash?.emission ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const corridorMaterial = (item) =>
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: CORRIDOR_MATERIAL,
|
||||
uniforms: corridorUniforms(item),
|
||||
source: CORRIDOR_SOURCE,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
|
||||
// Items of one look and colour share a primitive.
|
||||
const lookKey = (item) =>
|
||||
JSON.stringify([Array.from(item.fill), item.animation]);
|
||||
|
||||
function positionsOf(flat) {
|
||||
const positions = [];
|
||||
for (let index = 0; index < flat.length; index += 2)
|
||||
@@ -104,19 +181,34 @@ export function fillGeometry(kind, item) {
|
||||
|
||||
/**
|
||||
* The geometry instances of a commit: `fills` for the GroundPrimitive,
|
||||
* `lines` for the GroundPolylinePrimitive. Instance ids name the set and
|
||||
* item ({ gevGroundSet, instance }).
|
||||
* `lines` for the GroundPolylinePrimitive, `animated` the corridors with a
|
||||
* glow, pulse or dash ({ item, instances }: one per look and colour, by
|
||||
* their first item). Instance ids name the set and item ({ gevGroundSet,
|
||||
* instance }).
|
||||
*/
|
||||
export function groundInstances(options, items) {
|
||||
const fills = [];
|
||||
const lines = [];
|
||||
const looks = new Map();
|
||||
items.forEach((item, instance) => {
|
||||
const id = { gevGroundSet: options.id, instance };
|
||||
const attributes = (rgba) => ({
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(colorOf(rgba)),
|
||||
});
|
||||
if (options.kind === 'corridor') {
|
||||
if (item.widthM)
|
||||
if (item.animation) {
|
||||
const key = lookKey(item);
|
||||
if (!looks.has(key)) looks.set(key, { item, instances: [] });
|
||||
looks.get(key).instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.GroundPolylineGeometry({
|
||||
positions: positionsOf(item.path),
|
||||
width: item.widthPx,
|
||||
}),
|
||||
id,
|
||||
}),
|
||||
);
|
||||
} else if (item.widthM)
|
||||
fills.push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: fillGeometry('corridor', item),
|
||||
@@ -161,7 +253,7 @@ export function groundInstances(options, items) {
|
||||
}),
|
||||
);
|
||||
});
|
||||
return { fills, lines };
|
||||
return { fills, lines, animated: [...looks.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +289,32 @@ export function createCesiumGround(
|
||||
}
|
||||
});
|
||||
};
|
||||
// Animated materials of every live set: their time is written before
|
||||
// every frame, and a frame follows every frame while a shown set has a
|
||||
// pulse or a dash.
|
||||
const materials = new Set();
|
||||
let removeTick = null;
|
||||
let removeAnimate = null;
|
||||
const animate = () => {
|
||||
if (materials.size && !removeTick && scene.preRender)
|
||||
removeTick = scene.preRender.addEventListener(() => {
|
||||
const seconds = groundSeconds();
|
||||
for (const material of materials) material.uniforms.time = seconds;
|
||||
});
|
||||
if (!materials.size && removeTick) {
|
||||
removeTick();
|
||||
removeTick = null;
|
||||
}
|
||||
const wanted = registry.animating();
|
||||
if (wanted && !removeAnimate && scene.postRender)
|
||||
removeAnimate = scene.postRender.addEventListener(() =>
|
||||
requestRender('ground-animate'),
|
||||
);
|
||||
if (!wanted && removeAnimate) {
|
||||
removeAnimate();
|
||||
removeAnimate = null;
|
||||
}
|
||||
};
|
||||
const rootCollection = () => {
|
||||
if (!root) {
|
||||
root = new Cesium.PrimitiveCollection({ destroyPrimitives: true });
|
||||
@@ -210,14 +328,20 @@ export function createCesiumGround(
|
||||
ordered.splice(at, 0, { order: options.order, slot });
|
||||
rootCollection().add(slot, at);
|
||||
let drawn = [];
|
||||
let looks = [];
|
||||
const classificationType = classificationOf(options.drape);
|
||||
const allowPicking = options.pickable;
|
||||
const forget = () => {
|
||||
for (const primitive of drawn) building.delete(primitive);
|
||||
for (const material of looks) materials.delete(material);
|
||||
drawn = [];
|
||||
looks = [];
|
||||
};
|
||||
return {
|
||||
commit(items) {
|
||||
for (const primitive of drawn) building.delete(primitive);
|
||||
forget();
|
||||
slot.removeAll();
|
||||
drawn = [];
|
||||
const { fills, lines } = groundInstances(options, items);
|
||||
const { fills, lines, animated } = groundInstances(options, items);
|
||||
if (fills.length)
|
||||
drawn.push(
|
||||
make.fills({
|
||||
@@ -239,22 +363,38 @@ export function createCesiumGround(
|
||||
allowPicking,
|
||||
}),
|
||||
);
|
||||
for (const { item, instances } of animated) {
|
||||
const material = corridorMaterial(item);
|
||||
material.uniforms.time = groundSeconds();
|
||||
looks.push(material);
|
||||
materials.add(material);
|
||||
drawn.push(
|
||||
make.lines({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PolylineMaterialAppearance({ material }),
|
||||
classificationType,
|
||||
allowPicking,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const primitive of drawn) {
|
||||
primitive.gevGroundSet = options.id;
|
||||
slot.add(primitive);
|
||||
building.add(primitive);
|
||||
}
|
||||
watch();
|
||||
animate();
|
||||
requestRender('ground');
|
||||
},
|
||||
show(visible) {
|
||||
slot.show = visible;
|
||||
animate();
|
||||
requestRender('ground');
|
||||
},
|
||||
ready: () => drawn.every((primitive) => primitive.ready === true),
|
||||
destroy() {
|
||||
for (const primitive of drawn) building.delete(primitive);
|
||||
drawn = [];
|
||||
forget();
|
||||
animate();
|
||||
ordered.splice(
|
||||
ordered.findIndex((entry) => entry.slot === slot),
|
||||
1,
|
||||
@@ -284,6 +424,10 @@ export function createCesiumGround(
|
||||
registry.destroyAll();
|
||||
removeWatch?.();
|
||||
removeWatch = null;
|
||||
removeTick?.();
|
||||
removeAnimate?.();
|
||||
removeTick = removeAnimate = null;
|
||||
materials.clear();
|
||||
building.clear();
|
||||
if (root) {
|
||||
scene.groundPrimitives.remove(root);
|
||||
|
||||
@@ -5,6 +5,17 @@ import { normalizeGroundItem } from '../ground.js';
|
||||
import { createCesiumGround, groundInstances } from './ground.js';
|
||||
import { createFakeViewer } from './testing/fakeViewer.mjs';
|
||||
|
||||
// Cesium's Material types its uniforms against the browser's image and
|
||||
// canvas classes; Node has none of them.
|
||||
for (const name of [
|
||||
'HTMLCanvasElement',
|
||||
'HTMLImageElement',
|
||||
'HTMLVideoElement',
|
||||
'ImageBitmap',
|
||||
'OffscreenCanvas',
|
||||
])
|
||||
globalThis[name] ??= class {};
|
||||
|
||||
const items = (kind, list) =>
|
||||
list.map((item, index) => normalizeGroundItem(kind, item, index));
|
||||
|
||||
@@ -157,3 +168,81 @@ test('sets join scene.groundPrimitives in order and rebuild on commit', () => {
|
||||
part.destroy();
|
||||
assert.equal(added.length, 0);
|
||||
});
|
||||
|
||||
test('a glow, a pulse or a dash draws through its own material, timed every frame', () => {
|
||||
const viewer = createFakeViewer();
|
||||
viewer.scene.groundPrimitives = { add() {}, remove() {} };
|
||||
const made = [];
|
||||
const fake = (type) => (options) => {
|
||||
const primitive = {
|
||||
type,
|
||||
options,
|
||||
ready: false,
|
||||
destroy() {},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
made.push(primitive);
|
||||
return primitive;
|
||||
};
|
||||
const renders = [];
|
||||
const part = createCesiumGround(viewer, {
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
factories: { fills: fake('fills'), lines: fake('lines') },
|
||||
});
|
||||
const line = (lat, extra) => ({
|
||||
positions: [
|
||||
[0, lat],
|
||||
[1, lat],
|
||||
],
|
||||
color: [1, 0, 0, 1],
|
||||
widthPx: 9,
|
||||
...extra,
|
||||
});
|
||||
const pulse = { periodMs: 1600, min: 0.35, max: 0.75 };
|
||||
const set = part.ground.createSet({ id: 'heat', kind: 'corridor' });
|
||||
set.replace([
|
||||
line(0),
|
||||
line(1, { glow: { power: 0.25 }, pulse }),
|
||||
line(2, { glow: { power: 0.25 }, pulse }),
|
||||
line(3, { glow: { power: 0.25 }, pulse, color: [1, 1, 1, 1] }),
|
||||
line(4, { dash: { cells: 64, duty: 0.46, speed: 0.55 } }),
|
||||
]);
|
||||
set.commit();
|
||||
// The plain line batch, then one per look and colour.
|
||||
assert.deepEqual(
|
||||
made.map(({ type }) => type),
|
||||
['lines', 'lines', 'lines', 'lines'],
|
||||
);
|
||||
const [plain, red, white, dashed] = made;
|
||||
assert.ok(plain.options.appearance instanceof Cesium.PolylineColorAppearance);
|
||||
assert.equal(red.options.geometryInstances.length, 2);
|
||||
assert.deepEqual(
|
||||
red.options.geometryInstances.map(({ id }) => id.instance),
|
||||
[1, 2],
|
||||
);
|
||||
const material = red.options.appearance.material;
|
||||
assert.equal(material.type, 'GevGroundCorridor');
|
||||
assert.equal(material.uniforms.glowPower, 0.25);
|
||||
assert.ok(Math.abs(material.uniforms.pulsePeriod - 1.6) < 1e-9);
|
||||
assert.equal(material.uniforms.dashCells, 0, 'no dash');
|
||||
assert.equal(white.options.appearance.material.uniforms.color.green, 1);
|
||||
assert.equal(dashed.options.appearance.material.uniforms.dashCells, 64);
|
||||
assert.equal(dashed.options.appearance.material.uniforms.glowPower, 0);
|
||||
// Before every frame the materials read the time; after it, a frame more.
|
||||
const before = performance.now() / 1000;
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.ok(material.uniforms.time >= before);
|
||||
renders.length = 0;
|
||||
viewer.scene.postRender.raiseEvent();
|
||||
assert.ok(renders.includes('ground-animate'));
|
||||
set.show(false);
|
||||
renders.length = 0;
|
||||
viewer.scene.postRender.raiseEvent();
|
||||
assert.ok(!renders.includes('ground-animate'), 'none while hidden');
|
||||
set.show(true);
|
||||
set.destroy();
|
||||
renders.length = 0;
|
||||
viewer.scene.postRender.raiseEvent();
|
||||
assert.ok(!renders.includes('ground-animate'), 'none once destroyed');
|
||||
part.destroy();
|
||||
});
|
||||
+15
-2
@@ -489,7 +489,19 @@
|
||||
* - cell: `west`, `south`, `east`, `north` (west > east across the
|
||||
* antimeridian), edges along meridians and parallels;
|
||||
* - corridor: `positions` (a line), `color`, and `widthPx` (CSS pixels) or
|
||||
* `widthM` (metres).
|
||||
* `widthM` (metres). A `widthPx` corridor may take a look that Cesium's
|
||||
* ground polyline materials drew, worked out on the GPU each frame:
|
||||
* `glow` ({ power, taper = 1 }: PolylineGlow's glowPower and
|
||||
* taperPower, brighter toward the centre line and fading to its
|
||||
* edges), `pulse` ({ periodMs, min, max }: the alpha times a factor
|
||||
* going from min to max and back, a sine of the page's monotonic clock,
|
||||
* performance.now()) and `dash` ({ cells, duty, speed = 0,
|
||||
* gapAlpha = 0, emission = 0 }: `cells` dashes along the whole line,
|
||||
* each lit for its first `duty` with an edge of 0.08 of a cell, moving
|
||||
* toward the line's end at `speed` cells a second; between them the
|
||||
* alpha times `gapAlpha`; a lit dash brightened by `emission` times its
|
||||
* colour). A shown set with a pulse or a dash keeps the engine drawing
|
||||
* a frame after every frame, as Cesium's continuous render did.
|
||||
* Polygons, discs and cells take `fill` (omitted: none) and `outline`
|
||||
* ({ color, widthPx }, a ground line on the boundary of every ring;
|
||||
* omitted: none). Every kind takes `pickId`: what a pick of the item
|
||||
@@ -511,7 +523,8 @@
|
||||
*
|
||||
* @typedef {object} GlobeGround
|
||||
* @property {(options: GroundSetOptions) => GroundSet} createSet
|
||||
* @property {() => { id: string, kind: string, count: number, shown: boolean, order: number }[]} list
|
||||
* @property {() => { id: string, kind: string, count: number, shown: boolean, order: number, animated: boolean }[]} list
|
||||
* `animated`: the last commit has a pulse or a dash.
|
||||
*
|
||||
* @typedef {{ west: number, south: number, east: number, north: number }} DegreeRectangle
|
||||
* Degrees; south below north; west > east crosses the antimeridian.
|
||||
|
||||
+106
-1
@@ -213,16 +213,107 @@ export function normalizeGroundItem(kind, item, index = 0) {
|
||||
const width = hasPx ? item.widthPx : item.widthM;
|
||||
if (!(isFiniteNumber(width) && width > 0))
|
||||
fail(`item ${index} width must be a positive number`);
|
||||
const animation = animationOf(item, index);
|
||||
if (animation && !hasPx)
|
||||
fail(`item ${index} glow, pulse and dash need widthPx`);
|
||||
return {
|
||||
...base,
|
||||
path,
|
||||
fill: Float32Array.from(item.color),
|
||||
outline: null,
|
||||
...(hasPx ? { widthPx: width } : { widthM: width }),
|
||||
animation,
|
||||
bounds: boundsOf([path]),
|
||||
};
|
||||
}
|
||||
|
||||
const unit = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
|
||||
|
||||
// A pixel corridor's look over its width and time, as Cesium's ground
|
||||
// polyline materials drew it: `glow` PolylineGlow's falloff, `pulse` its
|
||||
// alpha going up and down, `dash` the annotation route's flowing dashes.
|
||||
// Null without any.
|
||||
function animationOf(item, index) {
|
||||
const { glow, pulse, dash } = item;
|
||||
if (glow == null && pulse == null && dash == null) return null;
|
||||
const label = `item ${index}`;
|
||||
let glowOut = null;
|
||||
if (glow != null) {
|
||||
if (!isObject(glow)) fail(`${label} glow must be { power, taper }`);
|
||||
const taper = glow.taper ?? 1;
|
||||
if (!(isFiniteNumber(glow.power) && glow.power > 0 && glow.power <= 0.5))
|
||||
fail(`${label} glow.power must be in (0, 0.5]: ${glow.power}`);
|
||||
if (!(isFiniteNumber(taper) && taper > 0 && taper <= 1))
|
||||
fail(`${label} glow.taper must be in (0, 1]: ${taper}`);
|
||||
glowOut = Object.freeze({ power: glow.power, taper });
|
||||
}
|
||||
let pulseOut = null;
|
||||
if (pulse != null) {
|
||||
if (!isObject(pulse)) fail(`${label} pulse must be { periodMs, min, max }`);
|
||||
const { periodMs, min, max } = pulse;
|
||||
if (!(isFiniteNumber(periodMs) && periodMs > 0))
|
||||
fail(`${label} pulse.periodMs must be a positive number: ${periodMs}`);
|
||||
if (!unit(min) || !unit(max) || min > max)
|
||||
fail(`${label} pulse needs 0 <= min <= max <= 1: ${min}, ${max}`);
|
||||
pulseOut = Object.freeze({ periodMs, min, max });
|
||||
}
|
||||
let dashOut = null;
|
||||
if (dash != null) {
|
||||
if (!isObject(dash))
|
||||
fail(`${label} dash must be { cells, duty, speed, gapAlpha, emission }`);
|
||||
const { cells, duty } = dash;
|
||||
const speed = dash.speed ?? 0;
|
||||
const gapAlpha = dash.gapAlpha ?? 0;
|
||||
const emission = dash.emission ?? 0;
|
||||
if (!(isFiniteNumber(cells) && cells > 0))
|
||||
fail(`${label} dash.cells must be a positive number: ${cells}`);
|
||||
if (!(isFiniteNumber(duty) && duty > 0 && duty < 1))
|
||||
fail(`${label} dash.duty must be in (0, 1): ${duty}`);
|
||||
if (!isFiniteNumber(speed))
|
||||
fail(`${label} dash.speed must be a number: ${speed}`);
|
||||
if (!unit(gapAlpha))
|
||||
fail(`${label} dash.gapAlpha must be in 0..1: ${gapAlpha}`);
|
||||
if (!(isFiniteNumber(emission) && emission >= 0))
|
||||
fail(`${label} dash.emission must be a number >= 0: ${emission}`);
|
||||
dashOut = Object.freeze({ cells, duty, speed, gapAlpha, emission });
|
||||
}
|
||||
return Object.freeze({ glow: glowOut, pulse: pulseOut, dash: dashOut });
|
||||
}
|
||||
|
||||
/** Whether a checked item changes over time (a pulse or a dash). */
|
||||
export const animates = (item) =>
|
||||
Boolean(item.animation?.pulse || item.animation?.dash);
|
||||
|
||||
/**
|
||||
* The time animated corridors are drawn at, in seconds: the page's
|
||||
* monotonic clock, as Cesium's route and heat-line materials read it (not
|
||||
* the app clock: a frozen or sped-up scene time leaves them as they are).
|
||||
*/
|
||||
export const groundSeconds = () => performance.now() / 1000;
|
||||
|
||||
/** A pulse's alpha factor at `seconds`: min..max, a sine of its period. */
|
||||
export function pulseFactor({ periodMs, min, max }, seconds) {
|
||||
const wave = Math.sin((2 * Math.PI * seconds * 1000) / periodMs);
|
||||
return min + (max - min) * (0.5 + 0.5 * wave);
|
||||
}
|
||||
|
||||
/** Half the soft edge of a dash, in cells (the route flow material's). */
|
||||
export const DASH_EDGE = 0.08;
|
||||
|
||||
/**
|
||||
* How lit a dash is (0..1) at `along` (0 at the corridor's start, 1 at its
|
||||
* end) and `seconds`: the cells scroll toward the end at `speed` a second.
|
||||
*/
|
||||
export function dashLit({ cells, duty, speed }, along, seconds) {
|
||||
const flow = along * cells - seconds * speed;
|
||||
const at = flow - Math.floor(flow);
|
||||
const t = Math.min(
|
||||
1,
|
||||
Math.max(0, (at - (duty - DASH_EDGE)) / (2 * DASH_EDGE)),
|
||||
);
|
||||
return 1 - t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/** A disc's boundary as a ring of `segments` points (degrees, flat). */
|
||||
export function discRing({ center, radiusM }, segments = 64) {
|
||||
const origin = geo.cartesian3.fromDegrees(center[0], center[1]);
|
||||
@@ -301,6 +392,8 @@ export function createGroundRegistry(build) {
|
||||
// What the last commit drew: picks read it.
|
||||
items: [],
|
||||
shown: true,
|
||||
// Whether the last commit has a pulse or a dash.
|
||||
animated: false,
|
||||
destroyed: false,
|
||||
};
|
||||
const alive = () => {
|
||||
@@ -318,6 +411,7 @@ export function createGroundRegistry(build) {
|
||||
commit() {
|
||||
alive();
|
||||
state.items = state.pending;
|
||||
state.animated = state.items.some(animates);
|
||||
part.commit(state.items);
|
||||
},
|
||||
show(visible) {
|
||||
@@ -336,6 +430,7 @@ export function createGroundRegistry(build) {
|
||||
part.destroy();
|
||||
state.pending = [];
|
||||
state.items = [];
|
||||
state.animated = false;
|
||||
},
|
||||
isDestroyed: () => state.destroyed,
|
||||
};
|
||||
@@ -344,17 +439,27 @@ export function createGroundRegistry(build) {
|
||||
return set;
|
||||
},
|
||||
list: () =>
|
||||
[...live.values()].map(({ options, items, shown }) => ({
|
||||
[...live.values()].map(({ options, items, shown, animated }) => ({
|
||||
id: options.id,
|
||||
kind: options.kind,
|
||||
count: items.length,
|
||||
shown,
|
||||
order: options.order,
|
||||
animated,
|
||||
})),
|
||||
};
|
||||
return {
|
||||
ground,
|
||||
live,
|
||||
/**
|
||||
* Whether a shown set has a pulse or a dash: the engine draws a frame
|
||||
* after every frame while it does, as Cesium's continuous render did.
|
||||
*/
|
||||
animating() {
|
||||
for (const state of live.values())
|
||||
if (state.shown && state.animated) return true;
|
||||
return false;
|
||||
},
|
||||
destroyAll() {
|
||||
for (const state of [...live.values()]) state.set.destroy();
|
||||
},
|
||||
|
||||
+113
-1
@@ -2,12 +2,15 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../geo/index.js';
|
||||
import {
|
||||
animates,
|
||||
cellRing,
|
||||
createGroundPicks,
|
||||
createGroundRegistry,
|
||||
dashLit,
|
||||
discRing,
|
||||
normalizeGroundItem,
|
||||
normalizeGroundSetOptions,
|
||||
pulseFactor,
|
||||
} from './ground.js';
|
||||
|
||||
test('set options are checked and defaulted', () => {
|
||||
@@ -182,7 +185,14 @@ test('the registry stores on replace, draws on commit, lists, shows and destroys
|
||||
assert.equal(set.count(), 1);
|
||||
set.show(false);
|
||||
assert.deepEqual(ground.list(), [
|
||||
{ id: 'zones', kind: 'cell', count: 1, shown: false, order: 2 },
|
||||
{
|
||||
id: 'zones',
|
||||
kind: 'cell',
|
||||
count: 1,
|
||||
shown: false,
|
||||
order: 2,
|
||||
animated: false,
|
||||
},
|
||||
]);
|
||||
assert.equal(set.ready(), true);
|
||||
destroyAll();
|
||||
@@ -196,6 +206,108 @@ test('the registry stores on replace, draws on commit, lists, shows and destroys
|
||||
]);
|
||||
});
|
||||
|
||||
test("a pixel corridor's glow, pulse and dash are checked and defaulted", () => {
|
||||
const line = (extra) => ({
|
||||
positions: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
color: [1, 0, 0, 1],
|
||||
widthPx: 9,
|
||||
...extra,
|
||||
});
|
||||
const item = normalizeGroundItem(
|
||||
'corridor',
|
||||
line({
|
||||
glow: { power: 0.25 },
|
||||
pulse: { periodMs: 1600, min: 0.35, max: 0.75 },
|
||||
dash: { cells: 64, duty: 0.46 },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(item.animation, {
|
||||
glow: { power: 0.25, taper: 1 },
|
||||
pulse: { periodMs: 1600, min: 0.35, max: 0.75 },
|
||||
dash: { cells: 64, duty: 0.46, speed: 0, gapAlpha: 0, emission: 0 },
|
||||
});
|
||||
assert.ok(Object.isFrozen(item.animation.dash));
|
||||
assert.equal(animates(item), true);
|
||||
assert.equal(
|
||||
animates(normalizeGroundItem('corridor', line({ glow: { power: 0.1 } }))),
|
||||
false,
|
||||
'a glow alone is still',
|
||||
);
|
||||
assert.equal(normalizeGroundItem('corridor', line()).animation, null);
|
||||
for (const extra of [
|
||||
{ glow: { power: 0.6 } },
|
||||
{ glow: { power: 0.2, taper: 0 } },
|
||||
{ pulse: { periodMs: -1, min: 0, max: 1 } },
|
||||
{ pulse: { periodMs: 100, min: 0.5, max: 0.4 } },
|
||||
{ pulse: { periodMs: 100, min: 0, max: 2 } },
|
||||
{ dash: { cells: 0, duty: 0.5 } },
|
||||
{ dash: { cells: 4, duty: 0 } },
|
||||
{ dash: { cells: 4, duty: 0.5, gapAlpha: 2 } },
|
||||
{ dash: { cells: 4, duty: 0.5, emission: -1 } },
|
||||
{ dash: 'on' },
|
||||
])
|
||||
assert.throws(
|
||||
() => normalizeGroundItem('corridor', line(extra)),
|
||||
TypeError,
|
||||
JSON.stringify(extra),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeGroundItem('corridor', {
|
||||
...line({ pulse: { periodMs: 100, min: 0, max: 1 } }),
|
||||
widthPx: undefined,
|
||||
widthM: 10,
|
||||
}),
|
||||
/widthPx/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a pulse swings between its min and max; a dash scrolls toward the end', () => {
|
||||
const pulse = { periodMs: 2000, min: 0.2, max: 0.8 };
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 0) - 0.5) < 1e-9);
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 0.5) - 0.8) < 1e-9);
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 1.5) - 0.2) < 1e-9);
|
||||
const dash = { cells: 10, duty: 0.5, speed: 1 };
|
||||
// Lit for the first half of each cell, dark for the second, soft edges.
|
||||
assert.equal(dashLit(dash, 0.02, 0), 1);
|
||||
assert.equal(dashLit(dash, 0.08, 0), 0);
|
||||
assert.ok(Math.abs(dashLit(dash, 0.05, 0) - 0.5) < 1e-9);
|
||||
// A second later the pattern has moved one cell toward the end.
|
||||
assert.equal(dashLit(dash, 0.12, 1), dashLit(dash, 0.02, 0));
|
||||
});
|
||||
|
||||
test('the registry knows when a shown set animates', () => {
|
||||
const { ground, animating } = createGroundRegistry(() => ({
|
||||
commit() {},
|
||||
show() {},
|
||||
destroy() {},
|
||||
}));
|
||||
const set = ground.createSet({ id: 'route', kind: 'corridor' });
|
||||
set.replace([
|
||||
{
|
||||
positions: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
color: [1, 1, 1, 1],
|
||||
widthPx: 9,
|
||||
dash: { cells: 8, duty: 0.5, speed: 1 },
|
||||
},
|
||||
]);
|
||||
assert.equal(animating(), false, 'not before the commit');
|
||||
set.commit();
|
||||
assert.equal(animating(), true);
|
||||
assert.equal(ground.list()[0].animated, true);
|
||||
set.show(false);
|
||||
assert.equal(animating(), false, 'not while hidden');
|
||||
set.show(true);
|
||||
set.destroy();
|
||||
assert.equal(animating(), false, 'not once destroyed');
|
||||
});
|
||||
|
||||
test('CPU picks find the item under the picked ground, on top first', () => {
|
||||
const states = [];
|
||||
const { ground, live } = createGroundRegistry(() => ({
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
// A corridor set as Navara's ground pass draws it (pack.js), without three:
|
||||
// the set's lines, cut into pieces of at most MAX_PIECE_DEG along their
|
||||
// great circles, fall into a tree of longitude/latitude tiles of at most
|
||||
// TILE_PIECES pieces each. A tile is one pass item: a gnomonic plane of its
|
||||
// own, the pieces that come near it as segments in that plane, and a
|
||||
// pyramid of square bins over it. Each bin of level z (2^z times the finest
|
||||
// bin) lists the runs of segments, item by item in draw order, that reach
|
||||
// within a margin of it: MARGIN_RATIO bins for the set's thinnest corridor,
|
||||
// more for a wider one, plus its width in metres. A pixel takes the tile
|
||||
// whose longitude/latitude cell holds its ground (tested once per pixel in
|
||||
// world space, so neighbouring tiles never both draw it), the finest level
|
||||
// whose margin covers its widest reach, and tests only its bin's runs.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
|
||||
const { cartesian3, ellipsoid } = geo;
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
/** Pieces a tile holds before it splits. */
|
||||
export const TILE_PIECES = 20000;
|
||||
/** A piece's longest arc, degrees. */
|
||||
export const MAX_PIECE_DEG = 0.5;
|
||||
// A tile's longest side, degrees: its plane stays precise in float32 and
|
||||
// its meridians less than 180° apart.
|
||||
const MAX_TILE_DEG = 20;
|
||||
// A tile this small (metres) never splits: its pieces share a few metres.
|
||||
const MIN_TILE_M = 50;
|
||||
/** A bin's margin, in bins at its level, for the set's thinnest corridor. */
|
||||
export const MARGIN_RATIO = 0.5;
|
||||
// Runs the finest level holds per piece of the tile, about: it sets the
|
||||
// finest bin (smaller bins test fewer segments a pixel and hold more runs).
|
||||
const RUN_BUDGET = 2;
|
||||
// The coarsest level has at most this many bins across; its margin bounds
|
||||
// how far a tile reaches into its neighbours.
|
||||
const TOP_ACROSS = 8;
|
||||
// The finest level has at most this many.
|
||||
const MAX_ACROSS = 128;
|
||||
/** Slack for the pixel's world position, metres. */
|
||||
export const EPSILON_M = 4;
|
||||
// Segments of one line a run spans over: fewer, longer runs.
|
||||
const RUN_GAP = 2;
|
||||
const METRES_PER_DEG = ellipsoid.WGS84.maximumRadius * DEG;
|
||||
const E2 =
|
||||
1 - (ellipsoid.WGS84.minimumRadius / ellipsoid.WGS84.maximumRadius) ** 2;
|
||||
|
||||
/** Item looks as the shader reads them: glow, pulse and dash bits. */
|
||||
export const LOOK = Object.freeze({ glow: 1, pulse: 2, dash: 4 });
|
||||
/** RGBA32F texels per corridor item. */
|
||||
export const ITEM_TEXELS = 5;
|
||||
|
||||
const wrap180 = (degrees) => degrees - 360 * Math.round(degrees / 360);
|
||||
const geodetic = (latcDeg) =>
|
||||
Math.abs(latcDeg) >= 90
|
||||
? latcDeg
|
||||
: Math.atan(Math.tan(latcDeg * DEG) / (1 - E2)) / DEG;
|
||||
|
||||
/** One item's look: five texels (colour, widths, glow/pulse, dash). */
|
||||
function itemTexels(item, lengthM) {
|
||||
const { glow, pulse, dash } = item.animation ?? {};
|
||||
const flags =
|
||||
(glow ? LOOK.glow : 0) | (pulse ? LOOK.pulse : 0) | (dash ? LOOK.dash : 0);
|
||||
return [
|
||||
...item.fill,
|
||||
item.widthPx ?? 0,
|
||||
item.widthM ?? 0,
|
||||
lengthM,
|
||||
flags,
|
||||
glow?.power ?? 0,
|
||||
glow?.taper ?? 1,
|
||||
pulse ? pulse.periodMs / 1000 : 1,
|
||||
pulse?.min ?? 1,
|
||||
pulse?.max ?? 1,
|
||||
dash?.cells ?? 0,
|
||||
dash?.duty ?? 0.5,
|
||||
dash?.speed ?? 0,
|
||||
dash?.gapAlpha ?? 0,
|
||||
dash?.emission ?? 0,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
}
|
||||
|
||||
// Every item's line as pieces: unit directions from the Earth's centre
|
||||
// (a plane through the centre holds each piece), their ends in longitude
|
||||
// and geocentric latitude, and their place along the line in metres.
|
||||
function piecesOf(items) {
|
||||
const item = [];
|
||||
const index = [];
|
||||
const ends = [];
|
||||
const lonLat = [];
|
||||
const along = [];
|
||||
const lengths = [];
|
||||
const lineLengths = [];
|
||||
const point = cartesian3.create();
|
||||
items.forEach((entry, itemIndex) => {
|
||||
const flat = entry.path;
|
||||
const count = flat.length / 2;
|
||||
const directions = [];
|
||||
const surface = [];
|
||||
for (let k = 0; k < count; k++) {
|
||||
cartesian3.fromDegrees(flat[k * 2], flat[k * 2 + 1], 0, undefined, point);
|
||||
surface.push([point.x, point.y, point.z]);
|
||||
const length = Math.hypot(point.x, point.y, point.z);
|
||||
directions.push([point.x / length, point.y / length, point.z / length]);
|
||||
}
|
||||
let total = 0;
|
||||
let piece = 0;
|
||||
for (let k = 0; k + 1 < count; k++) {
|
||||
const a = directions[k];
|
||||
const b = directions[k + 1];
|
||||
const angle = Math.acos(
|
||||
Math.min(1, Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2])),
|
||||
);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
Math.ceil(angle / (MAX_PIECE_DEG * DEG) - 1e-9),
|
||||
);
|
||||
const chord = Math.hypot(
|
||||
surface[k + 1][0] - surface[k][0],
|
||||
surface[k + 1][1] - surface[k][1],
|
||||
surface[k + 1][2] - surface[k][2],
|
||||
);
|
||||
// Along the great circle from a to b (the plane through both and
|
||||
// the centre), in equal steps.
|
||||
const sin = Math.sin(angle);
|
||||
const between = (t) => {
|
||||
if (sin < 1e-12) return a;
|
||||
const wa = Math.sin((1 - t) * angle) / sin;
|
||||
const wb = Math.sin(t * angle) / sin;
|
||||
return [
|
||||
wa * a[0] + wb * b[0],
|
||||
wa * a[1] + wb * b[1],
|
||||
wa * a[2] + wb * b[2],
|
||||
];
|
||||
};
|
||||
let from = a;
|
||||
for (let step = 1; step <= steps; step++) {
|
||||
const to = step === steps ? b : between(step / steps);
|
||||
item.push(itemIndex);
|
||||
index.push(piece++);
|
||||
ends.push(...from, ...to);
|
||||
const lonA = Math.atan2(from[1], from[0]) / DEG;
|
||||
const lonB = Math.atan2(to[1], to[0]) / DEG;
|
||||
lonLat.push(
|
||||
lonA,
|
||||
Math.asin(Math.max(-1, Math.min(1, from[2]))) / DEG,
|
||||
lonA + wrap180(lonB - lonA),
|
||||
Math.asin(Math.max(-1, Math.min(1, to[2]))) / DEG,
|
||||
);
|
||||
along.push(total + (chord * (step - 1)) / steps);
|
||||
lengths.push(chord / steps);
|
||||
from = to;
|
||||
}
|
||||
total += chord;
|
||||
}
|
||||
lineLengths.push(total);
|
||||
});
|
||||
return {
|
||||
count: item.length,
|
||||
item: Int32Array.from(item),
|
||||
index: Int32Array.from(index),
|
||||
ends: Float64Array.from(ends),
|
||||
lonLat: Float64Array.from(lonLat),
|
||||
along: Float64Array.from(along),
|
||||
lengths: Float64Array.from(lengths),
|
||||
lineLengths,
|
||||
};
|
||||
}
|
||||
|
||||
// The middle of the widest gap between the pieces' longitudes: the set's
|
||||
// longitudes run from there round to there.
|
||||
function seamOf(lonLat, count) {
|
||||
const lons = new Float64Array(count * 2);
|
||||
for (let piece = 0; piece < count; piece++) {
|
||||
lons[piece * 2] = wrap180(lonLat[piece * 4]);
|
||||
lons[piece * 2 + 1] = wrap180(lonLat[piece * 4 + 2]);
|
||||
}
|
||||
lons.sort();
|
||||
let seam = lons[0] - 180;
|
||||
let widest = -1;
|
||||
for (let k = 0; k < lons.length; k++) {
|
||||
const next = k + 1 < lons.length ? lons[k + 1] : lons[0] + 360;
|
||||
if (next - lons[k] > widest) {
|
||||
widest = next - lons[k];
|
||||
seam = (lons[k] + next) / 2;
|
||||
}
|
||||
}
|
||||
return seam;
|
||||
}
|
||||
|
||||
// The longest east-west and north-south sides of a rectangle, metres.
|
||||
function sidesM(rect) {
|
||||
const nearest = Math.min(
|
||||
89.9,
|
||||
rect.south <= 0 && rect.north >= 0
|
||||
? 0
|
||||
: Math.min(Math.abs(rect.south), Math.abs(rect.north)),
|
||||
);
|
||||
return [
|
||||
(rect.east - rect.west) * METRES_PER_DEG * Math.cos(nearest * DEG),
|
||||
(rect.north - rect.south) * METRES_PER_DEG,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a piece's padded bounds come within `metres` of `rect`, the
|
||||
* metres as degrees of longitude by `lonPerLat` (rect's coldest latitude
|
||||
* and the farthest margin from it).
|
||||
*/
|
||||
function near(boxes, piece, rect, metres, lonPerLat) {
|
||||
const b = piece * 4;
|
||||
const dLat = metres / METRES_PER_DEG;
|
||||
if (boxes[b + 1] > rect.north + dLat || boxes[b + 3] < rect.south - dLat)
|
||||
return false;
|
||||
const dLon = dLat * lonPerLat;
|
||||
const west = rect.west - dLon;
|
||||
const east = rect.east + dLon;
|
||||
return (
|
||||
(boxes[b] <= east && boxes[b + 2] >= west) ||
|
||||
(boxes[b] + 360 <= east && boxes[b + 2] + 360 >= west) ||
|
||||
(boxes[b] - 360 <= east && boxes[b + 2] - 360 >= west)
|
||||
);
|
||||
}
|
||||
|
||||
// Degrees of longitude per degree of latitude near `rect`, `metres` from it.
|
||||
function lonPerLatNear(rect, metres) {
|
||||
const coldest = Math.min(
|
||||
89.9,
|
||||
Math.max(Math.abs(rect.south), Math.abs(rect.north)) +
|
||||
metres / METRES_PER_DEG,
|
||||
);
|
||||
return 1 / Math.max(1e-3, Math.cos(coldest * DEG));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles of a corridor set: { pieces (piecesOf, with each piece's
|
||||
* padded bounds in `boxes`: west, south, east, north), tiles }. A tile is
|
||||
* { west, south, east, north } (longitude, geocentric latitude, degrees;
|
||||
* the tiles split the set's bounds and a border round them), `own` (the
|
||||
* pieces in it) and `near` (those that may come within their margin of
|
||||
* it). `reachOf(item)` is [half width in pixels + 0.5, half width in
|
||||
* metres]; `reference` the set's least pixel reach; `tilePieces` (a test
|
||||
* seam) the pieces a tile holds before it splits.
|
||||
*/
|
||||
export function tileCorridors(
|
||||
items,
|
||||
reachOf,
|
||||
reference,
|
||||
tilePieces = TILE_PIECES,
|
||||
) {
|
||||
const pieces = piecesOf(items);
|
||||
const { count, lonLat } = pieces;
|
||||
if (!count) return { pieces, tiles: [] };
|
||||
const seam = seamOf(lonLat, count);
|
||||
const boxes = new Float64Array(count * 4);
|
||||
// A piece's margin per metre of a tile's side, and its fixed metres.
|
||||
const perSide = new Float64Array(count);
|
||||
const fixed = new Float64Array(count);
|
||||
// Its pixel reach over the reference's, and its half width in metres.
|
||||
const ratio = new Float64Array(count);
|
||||
const metres = new Float64Array(count);
|
||||
let west = Infinity;
|
||||
let east = -Infinity;
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
for (let piece = 0; piece < count; piece++) {
|
||||
const lonA = seam + ((((lonLat[piece * 4] - seam) % 360) + 360) % 360);
|
||||
const lonB = lonA + (lonLat[piece * 4 + 2] - lonLat[piece * 4]);
|
||||
const latA = lonLat[piece * 4 + 1];
|
||||
const latB = lonLat[piece * 4 + 3];
|
||||
// A great circle bows off the straight line between its ends in
|
||||
// longitude and latitude; pad by a bound of that.
|
||||
const span = Math.hypot(
|
||||
(lonB - lonA) * Math.cos(((latA + latB) / 2) * DEG),
|
||||
latB - latA,
|
||||
);
|
||||
const steepest = Math.min(89, Math.max(Math.abs(latA), Math.abs(latB)));
|
||||
const bow =
|
||||
(((span * DEG) ** 2 / 8 / DEG) * (1 + Math.tan(steepest * DEG)) * 2) /
|
||||
Math.max(0.02, Math.cos(steepest * DEG)) +
|
||||
1e-7;
|
||||
const box = [
|
||||
Math.min(lonA, lonB) - bow,
|
||||
Math.max(-90, Math.min(latA, latB) - bow),
|
||||
Math.max(lonA, lonB) + bow,
|
||||
Math.min(90, Math.max(latA, latB) + bow),
|
||||
];
|
||||
boxes.set(box, piece * 4);
|
||||
const [px, m] = reachOf(items[pieces.item[piece]]);
|
||||
ratio[piece] = px / reference;
|
||||
metres[piece] = m;
|
||||
perSide[piece] = 1.1 * MARGIN_RATIO * ratio[piece];
|
||||
fixed[piece] = m + 2 * EPSILON_M;
|
||||
west = Math.min(west, box[0]);
|
||||
south = Math.min(south, box[1]);
|
||||
east = Math.max(east, box[2]);
|
||||
north = Math.max(north, box[3]);
|
||||
}
|
||||
Object.assign(pieces, { boxes, ratio, metres });
|
||||
// The tiles split the set's bounds; a tile on an edge of them also takes
|
||||
// every pixel beyond that edge (its side is open), as if the pixel were
|
||||
// moved onto the bounds.
|
||||
const root = {
|
||||
west,
|
||||
south,
|
||||
east: Math.max(east, west + 1e-9),
|
||||
north: Math.max(north, south + 1e-9),
|
||||
open: { west: true, south: true, east: true, north: true },
|
||||
};
|
||||
let widestFixed = 0;
|
||||
let widestPerSide = 0;
|
||||
for (let piece = 0; piece < count; piece++) {
|
||||
widestFixed = Math.max(widestFixed, fixed[piece]);
|
||||
widestPerSide = Math.max(widestPerSide, perSide[piece]);
|
||||
}
|
||||
const tiles = [];
|
||||
const split = (rect, own, nearby) => {
|
||||
const lonSpan = rect.east - rect.west;
|
||||
const latSpan = rect.north - rect.south;
|
||||
const [widthM, heightM] = sidesM(rect);
|
||||
const tooWide = lonSpan > MAX_TILE_DEG || latSpan > MAX_TILE_DEG;
|
||||
if (
|
||||
!tooWide &&
|
||||
(own.length <= tilePieces || Math.max(widthM, heightM) < 2 * MIN_TILE_M)
|
||||
) {
|
||||
// A tile no line reaches draws nothing.
|
||||
if (nearby.length) tiles.push({ ...rect, own, near: nearby });
|
||||
return;
|
||||
}
|
||||
const alongLon = tooWide ? lonSpan >= latSpan : widthM >= heightM;
|
||||
const mid = alongLon
|
||||
? (rect.west + rect.east) / 2
|
||||
: (rect.south + rect.north) / 2;
|
||||
const halves = alongLon
|
||||
? [
|
||||
{ ...rect, east: mid, open: { ...rect.open, east: false } },
|
||||
{ ...rect, west: mid, open: { ...rect.open, west: false } },
|
||||
]
|
||||
: [
|
||||
{ ...rect, north: mid, open: { ...rect.open, north: false } },
|
||||
{ ...rect, south: mid, open: { ...rect.open, south: false } },
|
||||
];
|
||||
for (const half of halves) {
|
||||
const side = Math.max(...sidesM(half));
|
||||
const lonPerLat = lonPerLatNear(half, widestFixed + widestPerSide * side);
|
||||
split(
|
||||
half,
|
||||
own.filter((piece) => near(boxes, piece, half, 0, 1)),
|
||||
nearby.filter((piece) =>
|
||||
near(
|
||||
boxes,
|
||||
piece,
|
||||
half,
|
||||
fixed[piece] + perSide[piece] * side,
|
||||
lonPerLat,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
const all = Array.from({ length: count }, (_, piece) => piece);
|
||||
split(root, all, all);
|
||||
return { pieces, tiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* One tile packed for the pass, in `frame`'s gnomonic plane (pack.js
|
||||
* frameAt): its region's bounds in the plane, its segments (the pieces
|
||||
* near it, item by item), its bins' runs and its levels.
|
||||
* `reachOf(item)` is the item's [half width in pixels + 0.5, half width in
|
||||
* metres]; `reference` the set's least pixel reach.
|
||||
*/
|
||||
export function packTile(tile, pieces, items, frame, reachOf, reference) {
|
||||
const { project } = frame;
|
||||
// Its region in the plane, from its edges.
|
||||
let x0 = Infinity;
|
||||
let y0 = Infinity;
|
||||
let x1 = -Infinity;
|
||||
let y1 = -Infinity;
|
||||
const STEPS = 8;
|
||||
for (let k = 0; k <= STEPS; k++) {
|
||||
const f = k / STEPS;
|
||||
for (const [lon, latc] of [
|
||||
[tile.west + f * (tile.east - tile.west), tile.south],
|
||||
[tile.west + f * (tile.east - tile.west), tile.north],
|
||||
[tile.west, tile.south + f * (tile.north - tile.south)],
|
||||
[tile.east, tile.south + f * (tile.north - tile.south)],
|
||||
]) {
|
||||
const [x, y] = project(direction(lon, latc));
|
||||
x0 = Math.min(x0, x);
|
||||
y0 = Math.min(y0, y);
|
||||
x1 = Math.max(x1, x);
|
||||
y1 = Math.max(y1, y);
|
||||
}
|
||||
}
|
||||
const pad = 0.01 * Math.max(x1 - x0, y1 - y0) + EPSILON_M;
|
||||
x0 -= pad;
|
||||
y0 -= pad;
|
||||
x1 += pad;
|
||||
y1 += pad;
|
||||
const width = x1 - x0;
|
||||
const height = y1 - y0;
|
||||
// A line crosses about (1 + 2 MARGIN_RATIO) bins of the finest level a
|
||||
// bin's length along it.
|
||||
const counted = tile.own.length ? tile.own : tile.near;
|
||||
let countedLength = 0;
|
||||
for (const piece of counted) countedLength += pieces.lengths[piece];
|
||||
const size = Math.max(
|
||||
Math.max(width, height) / MAX_ACROSS,
|
||||
((1 + 2 * MARGIN_RATIO) * countedLength) / (RUN_BUDGET * counted.length),
|
||||
1e-3,
|
||||
);
|
||||
const columns = Math.max(1, Math.ceil(width / size));
|
||||
const rows = Math.max(1, Math.ceil(height / size));
|
||||
const levels = [];
|
||||
for (let c = columns, r = rows; ; c = (c + 1) >> 1, r = (r + 1) >> 1) {
|
||||
levels.push([c, r]);
|
||||
if (Math.max(c, r) <= TOP_ACROSS || (c === 1 && r === 1)) break;
|
||||
}
|
||||
const top = levels.length - 1;
|
||||
// A piece's margin at level z: its metres, its pixel reach scaled to the
|
||||
// reference's margin, and the slack.
|
||||
const marginOf = (piece, z) => {
|
||||
return (
|
||||
pieces.metres[piece] +
|
||||
MARGIN_RATIO * size * 2 ** z * pieces.ratio[piece] +
|
||||
EPSILON_M
|
||||
);
|
||||
};
|
||||
// Every piece that comes within its coarsest margin of the tile, by item
|
||||
// and along each line.
|
||||
const rect = {
|
||||
west: tile.west,
|
||||
south: tile.south,
|
||||
east: tile.east,
|
||||
north: tile.north,
|
||||
};
|
||||
let widest = 0;
|
||||
for (const piece of tile.near)
|
||||
widest = Math.max(widest, marginOf(piece, top));
|
||||
const lonPerLat = lonPerLatNear(rect, widest);
|
||||
const { boxes } = pieces;
|
||||
const nearby = tile.near.filter((piece) =>
|
||||
near(boxes, piece, rect, marginOf(piece, top), lonPerLat),
|
||||
);
|
||||
const segments = new Float64Array(nearby.length * 4);
|
||||
nearby.forEach((piece, at) => {
|
||||
const e = piece * 6;
|
||||
const a = project([pieces.ends[e], pieces.ends[e + 1], pieces.ends[e + 2]]);
|
||||
const b = project([
|
||||
pieces.ends[e + 3],
|
||||
pieces.ends[e + 4],
|
||||
pieces.ends[e + 5],
|
||||
]);
|
||||
segments.set([a[0] - x0, a[1] - y0, b[0] - x0, b[1] - y0], at * 4);
|
||||
});
|
||||
// Lines: consecutive segments of one item, next to each other along it.
|
||||
const line = new Int32Array(nearby.length);
|
||||
nearby.forEach((piece, at) => {
|
||||
const previous = nearby[at - 1];
|
||||
line[at] =
|
||||
at > 0 &&
|
||||
pieces.item[previous] === pieces.item[piece] &&
|
||||
pieces.index[previous] + 1 === pieces.index[piece]
|
||||
? line[at - 1]
|
||||
: at;
|
||||
});
|
||||
const bins = [];
|
||||
const runs = [];
|
||||
const clampTo = (value, max) => Math.min(max - 1, Math.max(0, value));
|
||||
levels.forEach(([c, r], z) => {
|
||||
const binSize = size * 2 ** z;
|
||||
const counts = new Uint32Array(c * r);
|
||||
const ranges = new Int32Array(nearby.length * 4);
|
||||
const reach = new Float64Array(nearby.length);
|
||||
nearby.forEach((piece, at) => {
|
||||
const s = at * 4;
|
||||
const margin = marginOf(piece, z);
|
||||
reach[at] = margin;
|
||||
const ax = segments[s];
|
||||
const ay = segments[s + 1];
|
||||
const bx = segments[s + 2];
|
||||
const by = segments[s + 3];
|
||||
ranges[s] = clampTo(Math.floor((Math.min(ax, bx) - margin) / binSize), c);
|
||||
ranges[s + 1] = clampTo(
|
||||
Math.floor((Math.min(ay, by) - margin) / binSize),
|
||||
r,
|
||||
);
|
||||
ranges[s + 2] = clampTo(
|
||||
Math.floor((Math.max(ax, bx) + margin) / binSize),
|
||||
c,
|
||||
);
|
||||
ranges[s + 3] = clampTo(
|
||||
Math.floor((Math.max(ay, by) + margin) / binSize),
|
||||
r,
|
||||
);
|
||||
});
|
||||
// A long diagonal segment skips the bins of its box it never nears.
|
||||
const nears = (at, column, row) => {
|
||||
const s = at * 4;
|
||||
const ax = segments[s];
|
||||
const ay = segments[s + 1];
|
||||
const dx = segments[s + 2] - ax;
|
||||
const dy = segments[s + 3] - ay;
|
||||
const cx = (column + 0.5) * binSize - ax;
|
||||
const cy = (row + 0.5) * binSize - ay;
|
||||
const lengthSq = dx * dx + dy * dy;
|
||||
const t =
|
||||
lengthSq > 0
|
||||
? Math.max(0, Math.min(1, (cx * dx + cy * dy) / lengthSq))
|
||||
: 0;
|
||||
return (
|
||||
Math.hypot(cx - t * dx, cy - t * dy) <=
|
||||
reach[at] + binSize * Math.SQRT1_2
|
||||
);
|
||||
};
|
||||
const each = (visit) => {
|
||||
for (let at = 0; at < nearby.length; at++) {
|
||||
const s = at * 4;
|
||||
const sparse =
|
||||
ranges[s + 2] - ranges[s] > 1 && ranges[s + 3] - ranges[s + 1] > 1;
|
||||
for (let row = ranges[s + 1]; row <= ranges[s + 3]; row++)
|
||||
for (let column = ranges[s]; column <= ranges[s + 2]; column++)
|
||||
if (!sparse || nears(at, column, row)) visit(row * c + column, at);
|
||||
}
|
||||
};
|
||||
each((bin) => counts[bin]++);
|
||||
const offsets = new Uint32Array(c * r + 1);
|
||||
for (let bin = 0; bin < c * r; bin++)
|
||||
offsets[bin + 1] = offsets[bin] + counts[bin];
|
||||
const entries = new Int32Array(offsets[c * r]);
|
||||
const cursor = offsets.slice(0, c * r);
|
||||
each((bin, at) => {
|
||||
entries[cursor[bin]++] = at;
|
||||
});
|
||||
for (let bin = 0; bin < c * r; bin++) {
|
||||
const start = runs.length / 4;
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
const flush = () => {
|
||||
if (first < 0) return;
|
||||
const piece = nearby[first];
|
||||
runs.push(
|
||||
pieces.item[piece],
|
||||
first,
|
||||
last - first + 1,
|
||||
pieces.along[piece],
|
||||
);
|
||||
};
|
||||
for (let k = offsets[bin]; k < offsets[bin + 1]; k++) {
|
||||
const at = entries[k];
|
||||
if (first >= 0 && line[at] === line[first] && at - last <= RUN_GAP + 1)
|
||||
last = at;
|
||||
else {
|
||||
flush();
|
||||
first = last = at;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
bins.push(start, runs.length / 4 - start, 0, 0);
|
||||
}
|
||||
});
|
||||
// The segments in the frame's plane, as the shader reads them.
|
||||
for (let at = 0; at < segments.length; at += 2) {
|
||||
segments[at] += x0;
|
||||
segments[at + 1] += y0;
|
||||
}
|
||||
// The widest reach of its corridors: in pixels and in metres.
|
||||
let pixels = 0;
|
||||
let metres = 0;
|
||||
for (const piece of nearby) {
|
||||
pixels = Math.max(pixels, pieces.ratio[piece] * reference);
|
||||
metres = Math.max(metres, pieces.metres[piece]);
|
||||
}
|
||||
return {
|
||||
origin: [x0, y0],
|
||||
size,
|
||||
columns,
|
||||
rows,
|
||||
top,
|
||||
pixels,
|
||||
metres,
|
||||
segments,
|
||||
runs,
|
||||
bins,
|
||||
// The plane radius the region reaches from the frame's origin.
|
||||
extent: Math.max(
|
||||
Math.hypot(x0, y0),
|
||||
Math.hypot(x1, y0),
|
||||
Math.hypot(x0, y1),
|
||||
Math.hypot(x1, y1),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The unit direction at a longitude and geocentric latitude (degrees). */
|
||||
export function direction(lon, latc) {
|
||||
const cosLat = Math.cos(latc * DEG);
|
||||
return [
|
||||
cosLat * Math.cos(lon * DEG),
|
||||
cosLat * Math.sin(lon * DEG),
|
||||
Math.sin(latc * DEG),
|
||||
];
|
||||
}
|
||||
|
||||
/** Terrain sample positions for a tile: [lon, lat geodetic, …] degrees. */
|
||||
export function tileSamples(tile, pieces, limit = 48) {
|
||||
const flat = [];
|
||||
for (let k = 0; k < 9; k++) {
|
||||
const lon = tile.west + ((k % 3) / 2) * (tile.east - tile.west);
|
||||
const latc =
|
||||
tile.south + (Math.floor(k / 3) / 2) * (tile.north - tile.south);
|
||||
flat.push(wrap180(lon), geodetic(latc));
|
||||
}
|
||||
const own = tile.own.length ? tile.own : tile.near;
|
||||
const step = Math.max(1, Math.ceil(own.length / (limit - 9)));
|
||||
for (let k = 0; k < own.length; k += step) {
|
||||
const e = own[k] * 6;
|
||||
const x = pieces.ends[e];
|
||||
const y = pieces.ends[e + 1];
|
||||
const z = pieces.ends[e + 2];
|
||||
flat.push(
|
||||
Math.atan2(y, x) / DEG,
|
||||
geodetic(Math.asin(Math.max(-1, Math.min(1, z))) / DEG),
|
||||
);
|
||||
}
|
||||
return Float64Array.from(flat);
|
||||
}
|
||||
|
||||
export { itemTexels, geodetic };
|
||||
@@ -3,13 +3,15 @@
|
||||
// alike. A commit packs the set's items once (pack.js), with the terrain
|
||||
// heights under them that bound each item's screen box; a set packed before
|
||||
// its terrain loaded is packed again a few times, further apart each time,
|
||||
// until it has them. A show, hide or commit composes the shown sets in draw
|
||||
// order (set order, then creation) and asks for one frame. Nothing animates
|
||||
// on its own. Picks are CPU picks at the globe under the pointer
|
||||
// (src/globe/ground.js), wired by the engine.
|
||||
// until it has them (only the heights are sampled again). A show, hide or
|
||||
// commit composes the shown sets in draw order (set order, then creation)
|
||||
// and asks for one frame; while a shown set has a pulse or a dash, every
|
||||
// frame asks for the next, the continuous render Cesium drew them with (the
|
||||
// pass reads the time each frame). Picks are CPU picks at the globe under
|
||||
// the pointer (src/globe/ground.js), wired by the engine.
|
||||
|
||||
import { createGroundRegistry } from '../../ground.js';
|
||||
import { composeGround, packSet } from './pack.js';
|
||||
import { composeGround, packHeights, packSet } from './pack.js';
|
||||
|
||||
// Frames before the first new try at a set's missing terrain heights; each
|
||||
// later try waits twice as long, up to RETRIES tries.
|
||||
@@ -18,14 +20,38 @@ const RETRIES = 6;
|
||||
|
||||
/**
|
||||
* @param {object} view an initialised Navara ThreeView
|
||||
* @param {{ createPass: (view: object) => ReturnType<typeof import('./pass.js').createGroundPass> }} options
|
||||
* @param {{ createPass: (view: object) => ReturnType<typeof import('./pass.js').createGroundPass>,
|
||||
* nextFrame?: (run: () => void) => void }} options
|
||||
* `createPass` makes the pass (pass.js createGroundPass; the view's three
|
||||
* runtime, which Node tests replace).
|
||||
* runtime, which Node tests replace); `nextFrame` runs a callback before
|
||||
* the next display frame (requestAnimationFrame).
|
||||
* Navara clears its update request once a frame is drawn, so one made
|
||||
* while the frame is drawn (in postRender) is asked again from the next
|
||||
* display frame.
|
||||
* @returns {{ ground: import('../../contract.js').GlobeGround,
|
||||
* pickables: () => object[], diagnostics: () => object, destroy: () => void }}
|
||||
*/
|
||||
export function createNavaraGround(view, { createPass }) {
|
||||
export function createNavaraGround(
|
||||
view,
|
||||
{
|
||||
createPass,
|
||||
nextFrame = (run) =>
|
||||
globalThis.requestAnimationFrame
|
||||
? globalThis.requestAnimationFrame(run)
|
||||
: setTimeout(run, 16),
|
||||
},
|
||||
) {
|
||||
const pass = createPass(view);
|
||||
let destroyed = false;
|
||||
let asked = false;
|
||||
const askNextFrame = () => {
|
||||
if (asked) return;
|
||||
asked = true;
|
||||
nextFrame(() => {
|
||||
asked = false;
|
||||
if (!destroyed) view.forceUpdate();
|
||||
});
|
||||
};
|
||||
const entries = new Set();
|
||||
const terrainHeight = (lon, lat) => {
|
||||
const height = view.sampleTerrainHeight?.({ lat, lng: lon });
|
||||
@@ -35,6 +61,10 @@ export function createNavaraGround(view, { createPass }) {
|
||||
entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight });
|
||||
entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0;
|
||||
};
|
||||
const sampleAgain = (entry) => {
|
||||
entry.pack.heights = packHeights(entry.pack, { terrainHeight });
|
||||
entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0;
|
||||
};
|
||||
const compose = () => {
|
||||
const drawn = [...entries]
|
||||
.filter((entry) => entry.shown && entry.pack?.count)
|
||||
@@ -49,13 +79,14 @@ export function createNavaraGround(view, { createPass }) {
|
||||
for (const entry of entries) {
|
||||
if (!entry.wait || !entry.shown || --entry.wait > 0) continue;
|
||||
const tries = entry.tries + 1;
|
||||
pack(entry);
|
||||
sampleAgain(entry);
|
||||
entry.tries = tries;
|
||||
if (entry.wait)
|
||||
entry.wait = tries < RETRIES ? RETRY_FRAMES * 2 ** tries : 0;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) compose();
|
||||
if (changed || registry.animating()) askNextFrame();
|
||||
};
|
||||
view.on('postRender', retry);
|
||||
const registry = createGroundRegistry((options) => {
|
||||
@@ -99,6 +130,7 @@ export function createNavaraGround(view, { createPass }) {
|
||||
textureBytes: pass.textureBytes(),
|
||||
}),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
view.off('postRender', retry);
|
||||
registry.destroyAll();
|
||||
pass.destroy();
|
||||
|
||||
@@ -87,3 +87,41 @@ test('a set packed before its terrain loaded is packed again later', () => {
|
||||
assert.equal(pass.states.length, before + 1, 'no more once it has them');
|
||||
ground.destroy();
|
||||
});
|
||||
|
||||
test('while a shown set pulses or dashes, every frame asks for the next', () => {
|
||||
const view = fakeView();
|
||||
const pass = fakePass();
|
||||
const queued = [];
|
||||
const ground = createNavaraGround(view, {
|
||||
createPass: () => pass,
|
||||
nextFrame: (run) => queued.push(run),
|
||||
});
|
||||
const set = ground.ground.createSet({ id: 'route', kind: 'corridor' });
|
||||
set.replace([
|
||||
{
|
||||
positions: [
|
||||
[0, 0],
|
||||
[0.1, 0],
|
||||
],
|
||||
color: [0, 1, 1, 1],
|
||||
widthPx: 9,
|
||||
dash: { cells: 8, duty: 0.5, speed: 1 },
|
||||
},
|
||||
]);
|
||||
set.commit();
|
||||
const before = view.frames;
|
||||
view.postRender();
|
||||
view.postRender();
|
||||
assert.equal(queued.length, 1, 'one request a display frame');
|
||||
queued.shift()();
|
||||
assert.equal(view.frames, before + 1);
|
||||
set.show(false);
|
||||
view.postRender();
|
||||
assert.equal(queued.length, 0, 'none while hidden');
|
||||
set.show(true);
|
||||
view.postRender();
|
||||
ground.destroy();
|
||||
const destroyed = view.frames;
|
||||
queued.shift()();
|
||||
assert.equal(view.frames, destroyed, 'none after destroy');
|
||||
});
|
||||
+228
-138
@@ -2,21 +2,29 @@
|
||||
// gnomonic frame of its own (the tangent plane at its centre, reached along
|
||||
// rays from the Earth's centre, where great-circle edges are straight
|
||||
// lines), its boundary as segments in that plane, in float64 until the
|
||||
// numbers are small; per camera change the frames in view space, each
|
||||
// numbers are small; a corridor set as tiles of binned segments
|
||||
// (corridors.js); per camera change the frames in view space, each pass
|
||||
// item's conservative screen box and a screen grid of candidate items,
|
||||
// kept in draw order.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { cellRing } from '../../ground.js';
|
||||
import {
|
||||
ITEM_TEXELS,
|
||||
itemTexels,
|
||||
packTile,
|
||||
tileCorridors,
|
||||
tileSamples,
|
||||
} from './corridors.js';
|
||||
|
||||
const { cartesian3 } = geo;
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
/** Item kinds as the shader reads them. */
|
||||
export const KIND = Object.freeze({
|
||||
area: 0,
|
||||
disc: 1,
|
||||
corridorPx: 2,
|
||||
corridorM: 3,
|
||||
corridorTile: 4,
|
||||
});
|
||||
export const GRID_COLS = 64;
|
||||
export const GRID_ROWS = 40;
|
||||
@@ -27,9 +35,7 @@ export const STYLE_TEXELS = 6;
|
||||
/** An area of more segments than this gets a grid of bins (binArea). */
|
||||
export const BIN_THRESHOLD = 32;
|
||||
const MAX_BINS = 32;
|
||||
/** Segments of a corridor drawn as one pass item. */
|
||||
export const CORRIDOR_CHUNK = 4;
|
||||
/** Texture rows hold this many texels. */
|
||||
/** Texture rows hold this many texels (a power of two: the shader masks). */
|
||||
export const ROW = 1024;
|
||||
|
||||
// Highest ground above the ellipsoid a fill reaches (the item's volume).
|
||||
@@ -51,7 +57,7 @@ const nearFloor = (distance) => -Math.min(-FLOOR_M, 1000 + 0.05 * distance);
|
||||
const SAMPLE_STEP_DEG = 0.005;
|
||||
const lodMargin = (distance) => 450 + 0.02 * distance;
|
||||
const TILE_HEADROOM_M = 900;
|
||||
/** Terrain samples per pass item. */
|
||||
/** Terrain samples per area; a corridor tile takes more (corridors.js). */
|
||||
const SAMPLES = 9;
|
||||
const CELL_STEP_DEG = 0.25;
|
||||
|
||||
@@ -66,8 +72,18 @@ function frameAt(lon, lat) {
|
||||
if (!Number.isFinite(east.x)) Object.assign(east, { x: 1, y: 0, z: 0 });
|
||||
const north = cartesian3.cross(up, east, cartesian3.create());
|
||||
const scratch = cartesian3.create();
|
||||
// A ground position in the frame's gnomonic plane, metres.
|
||||
// A ground position in the frame's gnomonic plane, metres; or, given a
|
||||
// unit direction [x, y, z] from the Earth's centre, where its ray meets
|
||||
// the plane.
|
||||
const project = (lonDeg, latDeg) => {
|
||||
if (Array.isArray(lonDeg)) {
|
||||
const [x, y, z] = lonDeg;
|
||||
const scale = radius / (x * up.x + y * up.y + z * up.z);
|
||||
return [
|
||||
(x * east.x + y * east.y + z * east.z) * scale,
|
||||
(x * north.x + y * north.y + z * north.z) * scale,
|
||||
];
|
||||
}
|
||||
cartesian3.fromDegrees(lonDeg, latDeg, 0, undefined, scratch);
|
||||
const dx = scratch.x - center.x;
|
||||
const dy = scratch.y - center.y;
|
||||
@@ -172,56 +188,44 @@ export function binArea(list) {
|
||||
};
|
||||
}
|
||||
|
||||
// An open side of a corridor tile's cell: its meridian test always holds.
|
||||
const OPEN = 1e30;
|
||||
|
||||
// A pixel corridor reaches half its width and the half pixel of its
|
||||
// anti-aliasing in pixels; a metre one its half width in metres and the
|
||||
// half pixel.
|
||||
const reachOf = (item) => [
|
||||
(item.widthPx ?? 0) / 2 + 0.5,
|
||||
(item.widthM ?? 0) / 2,
|
||||
];
|
||||
|
||||
/**
|
||||
* One set's items packed for the pass as pass items: float64 frames, the
|
||||
* terrain heights under each (`terrainHeight(lon, lat)`, undefined where
|
||||
* not loaded: that item keeps the deep default box), the
|
||||
* style texels (segment starts relative to the set's own segments) and the
|
||||
* segments. A corridor longer than CORRIDOR_CHUNK segments becomes one pass
|
||||
* item per chunk, each in its own plane with its own screen box; a chunk
|
||||
* also holds its neighbours' end segments and draws only where its own
|
||||
* segments are the nearest, so joints blend once.
|
||||
* not loaded: that item keeps the deep default box; packHeights samples
|
||||
* them again), the style texels (segment starts relative to the set's own
|
||||
* segments) and the segments. A corridor set becomes tiles (corridors.js):
|
||||
* each a pass item with its segments, the runs of its bins and the set's
|
||||
* item looks (`items`, ITEM_TEXELS each).
|
||||
*/
|
||||
export function packSet(kind, items, { terrainHeight = null } = {}) {
|
||||
export function packSet(
|
||||
kind,
|
||||
items,
|
||||
{ terrainHeight = null, tilePieces = undefined } = {},
|
||||
) {
|
||||
const frames = [];
|
||||
const styles = [];
|
||||
const volumes = [];
|
||||
const heights = [];
|
||||
const segments = [];
|
||||
const bins = [];
|
||||
// Terrain heights on a grid of SAMPLE_STEP_DEG, each sampled once.
|
||||
const sampled = new Map();
|
||||
const heightAt = (lon, lat) => {
|
||||
const x = Math.round(lon / SAMPLE_STEP_DEG);
|
||||
const y = Math.round(lat / SAMPLE_STEP_DEG);
|
||||
const key = x * 1e6 + y;
|
||||
if (!sampled.has(key))
|
||||
sampled.set(
|
||||
key,
|
||||
terrainHeight?.(x * SAMPLE_STEP_DEG, y * SAMPLE_STEP_DEG),
|
||||
);
|
||||
return sampled.get(key);
|
||||
};
|
||||
// The lowest and highest terrain under a pass item's sample positions
|
||||
// (flat [lon, lat, …], at most SAMPLES of them), NaN when unknown.
|
||||
const sampleHeights = (flat) => {
|
||||
if (!terrainHeight) return [Number.NaN, Number.NaN];
|
||||
let low = Infinity;
|
||||
let high = -Infinity;
|
||||
const count = flat.length / 2;
|
||||
const step = Math.max(1, Math.ceil(count / SAMPLES));
|
||||
for (let index = 0; index < count; index += step) {
|
||||
const height = heightAt(flat[index * 2], flat[index * 2 + 1]);
|
||||
if (!Number.isFinite(height)) return [Number.NaN, Number.NaN];
|
||||
low = Math.min(low, height);
|
||||
high = Math.max(high, height);
|
||||
}
|
||||
return [low, high];
|
||||
};
|
||||
const emit = (frame, style, extent, padPx, list, samples, extra = null) => {
|
||||
heights.push(...sampleHeights(samples));
|
||||
const runs = [];
|
||||
const looks = [];
|
||||
const samples = [];
|
||||
const emit = (frame, style, extent, padPx, list, sampled, extra = null) => {
|
||||
samples.push(sampled);
|
||||
const start = segments.length / 4;
|
||||
segments.push(...list);
|
||||
for (let index = 0; index < list.length; index++)
|
||||
segments.push(list[index]);
|
||||
if (extra) {
|
||||
// The bins' own segment lists follow the item's, relative to its start.
|
||||
style.bins[3] = bins.length / 4;
|
||||
@@ -255,7 +259,7 @@ export function packSet(kind, items, { terrainHeight = null } = {}) {
|
||||
floor,
|
||||
CEILING_M,
|
||||
start,
|
||||
list.length / 4,
|
||||
style.count ?? list.length / 4,
|
||||
...style.bins,
|
||||
...style.origin,
|
||||
);
|
||||
@@ -272,114 +276,180 @@ export function packSet(kind, items, { terrainHeight = null } = {}) {
|
||||
};
|
||||
const reach = (points) =>
|
||||
points.reduce((most, [x, y]) => Math.max(most, Math.hypot(x, y)), 0);
|
||||
for (const item of items) {
|
||||
const none = [0, 0, 0, 0];
|
||||
if (kind === 'corridor') {
|
||||
const code = item.widthM ? KIND.corridorM : KIND.corridorPx;
|
||||
const segmentCount = item.path.length / 2 - 1;
|
||||
for (let first = 0; first < segmentCount; first += CORRIDOR_CHUNK) {
|
||||
const own = Math.min(CORRIDOR_CHUNK, segmentCount - first);
|
||||
const from = Math.max(0, first - 1);
|
||||
const to = Math.min(segmentCount, first + own + 1);
|
||||
const part = item.path.subarray(from * 2, (to + 1) * 2);
|
||||
let west = Infinity;
|
||||
let east = -Infinity;
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
for (let at = 0; at < part.length; at += 2) {
|
||||
west = Math.min(west, part[at]);
|
||||
east = Math.max(east, part[at]);
|
||||
south = Math.min(south, part[at + 1]);
|
||||
north = Math.max(north, part[at + 1]);
|
||||
}
|
||||
const frame = frameAt((west + east) / 2, (south + north) / 2);
|
||||
const points = planePoints(part, frame.project);
|
||||
if (kind === 'corridor' && items.length) {
|
||||
const reference = Math.min(...items.map((item) => reachOf(item)[0]));
|
||||
const { pieces, tiles } = tileCorridors(
|
||||
items,
|
||||
reachOf,
|
||||
reference,
|
||||
tilePieces,
|
||||
);
|
||||
items.forEach((item, index) =>
|
||||
looks.push(...itemTexels(item, pieces.lineLengths[index])),
|
||||
);
|
||||
for (const tile of tiles) {
|
||||
const frame = frameAt(
|
||||
(tile.west + tile.east) / 2,
|
||||
(tile.south + tile.north) / 2,
|
||||
);
|
||||
const packed = packTile(tile, pieces, items, frame, reachOf, reference);
|
||||
// Runs and bins relative to the tile's own: its style holds where
|
||||
// they start in the set's.
|
||||
const runStart = runs.length / 4;
|
||||
for (const value of packed.runs) runs.push(value);
|
||||
const binStart = bins.length / 4;
|
||||
for (const value of packed.bins) bins.push(value);
|
||||
// The tile's cell: east of its west meridian (a normal pointing
|
||||
// east), west of its east one (one pointing west), between its
|
||||
// parallels' sines; an open side takes everything beyond it.
|
||||
const meridian = (lon, sign) => [
|
||||
-sign * Math.sin(lon * DEG),
|
||||
sign * Math.cos(lon * DEG),
|
||||
];
|
||||
const { open } = tile;
|
||||
const parallel = (latc, side) =>
|
||||
open[side] ? (side === 'south' ? -2 : 2) : Math.sin(latc * DEG);
|
||||
emit(
|
||||
frame,
|
||||
{
|
||||
fill: [...meridian(tile.west, 1), ...meridian(tile.east, -1)],
|
||||
line: [
|
||||
parallel(tile.south, 'south'),
|
||||
parallel(tile.north, 'north'),
|
||||
reference,
|
||||
packed.top,
|
||||
],
|
||||
shape: [KIND.corridorTile, packed.size, packed.columns, packed.rows],
|
||||
count: runStart,
|
||||
bins: [...packed.origin, binStart, 0],
|
||||
// Its open meridians, and the far corner of its bins.
|
||||
origin: [
|
||||
open.west ? OPEN : 0,
|
||||
open.east ? OPEN : 0,
|
||||
packed.origin[0] + packed.columns * packed.size,
|
||||
packed.origin[1] + packed.rows * packed.size,
|
||||
],
|
||||
},
|
||||
packed.extent * 1.01 + 1 + packed.metres,
|
||||
packed.pixels,
|
||||
packed.segments,
|
||||
tileSamples(tile, pieces),
|
||||
);
|
||||
}
|
||||
} else
|
||||
for (const item of items) {
|
||||
const none = [0, 0, 0, 0];
|
||||
const style = {
|
||||
fill: item.fill ?? none,
|
||||
line: item.outline?.color ?? none,
|
||||
shape: [KIND.area, item.outline?.widthPx ?? 0, 0, 0],
|
||||
bins: [0, 0, 0, 0],
|
||||
origin: [0, 0, 0, 0],
|
||||
};
|
||||
const padPx = (item.outline?.widthPx ?? 0) / 2;
|
||||
if (kind === 'disc') {
|
||||
const frame = frameAt(item.center[0], item.center[1]);
|
||||
const radius = frame.radius * Math.tan(item.radiusM / frame.radius);
|
||||
style.shape = [KIND.disc, item.outline?.widthPx ?? 0, 0, radius];
|
||||
emit(
|
||||
frame,
|
||||
{
|
||||
fill: item.fill,
|
||||
line: none,
|
||||
shape: [code, 0, item.widthPx ?? 0, item.widthM ?? 0],
|
||||
bins: [first - from, own, 0, 0],
|
||||
origin: [0, 0, 0, 0],
|
||||
},
|
||||
reach(points) + (item.widthM ?? 0) / 2,
|
||||
(item.widthPx ?? 0) / 2,
|
||||
segmentsOf(points, false),
|
||||
part,
|
||||
style,
|
||||
radius,
|
||||
padPx,
|
||||
[],
|
||||
Float64Array.of(
|
||||
...item.center,
|
||||
item.bounds.west,
|
||||
item.center[1],
|
||||
item.bounds.east,
|
||||
item.center[1],
|
||||
item.center[0],
|
||||
item.bounds.south,
|
||||
item.center[0],
|
||||
item.bounds.north,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { bounds } = item;
|
||||
const frame = frameAt(
|
||||
(bounds.west + bounds.east) / 2,
|
||||
(bounds.south + bounds.north) / 2,
|
||||
);
|
||||
const rings = (
|
||||
kind === 'cell' ? [cellRing(item, CELL_STEP_DEG)] : item.rings
|
||||
).map((ring) => planePoints(ring, frame.project));
|
||||
const list = rings.flatMap((ring) => segmentsOf(ring, true));
|
||||
const binned = binArea(list);
|
||||
if (binned) {
|
||||
style.bins = [binned.columns, binned.rows, binned.size, 0];
|
||||
style.origin = [binned.x, binned.y, 0, 0];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const style = {
|
||||
fill: item.fill ?? none,
|
||||
line: item.outline?.color ?? none,
|
||||
shape: [KIND.area, item.outline?.widthPx ?? 0, 0, 0],
|
||||
bins: [0, 0, 0, 0],
|
||||
origin: [0, 0, 0, 0],
|
||||
};
|
||||
const padPx = (item.outline?.widthPx ?? 0) / 2;
|
||||
if (kind === 'disc') {
|
||||
const frame = frameAt(item.center[0], item.center[1]);
|
||||
const radius = frame.radius * Math.tan(item.radiusM / frame.radius);
|
||||
style.shape = [KIND.disc, item.outline?.widthPx ?? 0, 0, radius];
|
||||
emit(
|
||||
frame,
|
||||
style,
|
||||
radius,
|
||||
Math.max(...rings.map(reach)),
|
||||
padPx,
|
||||
[],
|
||||
list,
|
||||
Float64Array.of(
|
||||
...item.center,
|
||||
item.bounds.west,
|
||||
item.center[1],
|
||||
item.bounds.east,
|
||||
item.center[1],
|
||||
item.center[0],
|
||||
item.bounds.south,
|
||||
item.center[0],
|
||||
item.bounds.north,
|
||||
(bounds.west + bounds.east) / 2,
|
||||
(bounds.south + bounds.north) / 2,
|
||||
...(kind === 'cell' ? cellRing(item, CELL_STEP_DEG) : item.rings[0]),
|
||||
),
|
||||
binned,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { bounds } = item;
|
||||
const frame = frameAt(
|
||||
(bounds.west + bounds.east) / 2,
|
||||
(bounds.south + bounds.north) / 2,
|
||||
);
|
||||
const rings = (
|
||||
kind === 'cell' ? [cellRing(item, CELL_STEP_DEG)] : item.rings
|
||||
).map((ring) => planePoints(ring, frame.project));
|
||||
const list = rings.flatMap((ring) => segmentsOf(ring, true));
|
||||
const binned = binArea(list);
|
||||
if (binned) {
|
||||
style.bins = [binned.columns, binned.rows, binned.size, 0];
|
||||
style.origin = [binned.x, binned.y, 0, 0];
|
||||
}
|
||||
emit(
|
||||
frame,
|
||||
style,
|
||||
Math.max(...rings.map(reach)),
|
||||
padPx,
|
||||
list,
|
||||
Float64Array.of(
|
||||
(bounds.west + bounds.east) / 2,
|
||||
(bounds.south + bounds.north) / 2,
|
||||
...(kind === 'cell' ? cellRing(item, CELL_STEP_DEG) : item.rings[0]),
|
||||
),
|
||||
binned,
|
||||
);
|
||||
}
|
||||
return {
|
||||
const pack = {
|
||||
count: volumes.length / 4,
|
||||
bins: Float32Array.from(bins),
|
||||
heights: Float64Array.from(heights),
|
||||
frames: Float64Array.from(frames),
|
||||
styles: Float32Array.from(styles),
|
||||
volumes: Float64Array.from(volumes),
|
||||
segments: Float32Array.from(segments),
|
||||
runs: Float32Array.from(runs),
|
||||
items: Float32Array.from(looks),
|
||||
samples,
|
||||
};
|
||||
pack.heights = packHeights(pack, { terrainHeight });
|
||||
return pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lowest and highest terrain under each pass item of a pack, NaN where
|
||||
* any of its samples is not loaded: [low, high, …].
|
||||
*/
|
||||
export function packHeights(pack, { terrainHeight = null } = {}) {
|
||||
const heights = new Float64Array(pack.count * 2).fill(Number.NaN);
|
||||
if (!terrainHeight) return heights;
|
||||
// Terrain heights on a grid of SAMPLE_STEP_DEG, each sampled once.
|
||||
const sampled = new Map();
|
||||
const heightAt = (lon, lat) => {
|
||||
const x = Math.round(lon / SAMPLE_STEP_DEG);
|
||||
const y = Math.round(lat / SAMPLE_STEP_DEG);
|
||||
const key = x * 1e6 + y;
|
||||
if (!sampled.has(key))
|
||||
sampled.set(key, terrainHeight(x * SAMPLE_STEP_DEG, y * SAMPLE_STEP_DEG));
|
||||
return sampled.get(key);
|
||||
};
|
||||
pack.samples.forEach((flat, item) => {
|
||||
let low = Infinity;
|
||||
let high = -Infinity;
|
||||
const count = flat.length / 2;
|
||||
const limit =
|
||||
pack.styles[item * STYLE_TEXELS * 4 + 8] === KIND.corridorTile
|
||||
? count
|
||||
: SAMPLES;
|
||||
const step = Math.max(1, Math.ceil(count / limit));
|
||||
for (let index = 0; index < count; index += step) {
|
||||
const height = heightAt(flat[index * 2], flat[index * 2 + 1]);
|
||||
if (!Number.isFinite(height)) return;
|
||||
low = Math.min(low, height);
|
||||
high = Math.max(high, height);
|
||||
}
|
||||
heights[item * 2] = low;
|
||||
heights[item * 2 + 1] = high;
|
||||
});
|
||||
return heights;
|
||||
}
|
||||
|
||||
/** Storage for `texels` RGBA texels in rows of ROW. */
|
||||
@@ -454,9 +524,17 @@ export function composeGround(packs) {
|
||||
const bins = texelStorage(
|
||||
packs.reduce((total, pack) => total + pack.bins.length / 4, 0),
|
||||
);
|
||||
const runs = texelStorage(
|
||||
packs.reduce((total, pack) => total + pack.runs.length / 4, 0),
|
||||
);
|
||||
const looks = texelStorage(
|
||||
packs.reduce((total, pack) => total + pack.items.length / 4, 0),
|
||||
);
|
||||
let item = 0;
|
||||
let segment = 0;
|
||||
let bin = 0;
|
||||
let run = 0;
|
||||
let look = 0;
|
||||
for (const pack of packs) {
|
||||
frames64.set(pack.frames, item * 13);
|
||||
volumes.set(pack.volumes, item * 4);
|
||||
@@ -468,12 +546,22 @@ export function composeGround(packs) {
|
||||
// A binned area's first bin.
|
||||
if (styles.data[at + 8] === KIND.area && styles.data[at + 16] > 0)
|
||||
styles.data[at + 19] += bin;
|
||||
// A corridor tile's first run, first bin and its set's first item.
|
||||
if (styles.data[at + 8] === KIND.corridorTile) {
|
||||
styles.data[at + 15] += run;
|
||||
styles.data[at + 18] += bin;
|
||||
styles.data[at + 19] += look / ITEM_TEXELS;
|
||||
}
|
||||
}
|
||||
segments.data.set(pack.segments, segment * 4);
|
||||
bins.data.set(pack.bins, bin * 4);
|
||||
runs.data.set(pack.runs, run * 4);
|
||||
looks.data.set(pack.items, look * 4);
|
||||
item += pack.count;
|
||||
segment += pack.segments.length / 4;
|
||||
bin += pack.bins.length / 4;
|
||||
run += pack.runs.length / 4;
|
||||
look += pack.items.length / 4;
|
||||
}
|
||||
const frames = texelStorage(count * TEXELS);
|
||||
const grid = {
|
||||
@@ -499,6 +587,8 @@ export function composeGround(packs) {
|
||||
styles,
|
||||
segments,
|
||||
bins,
|
||||
runs,
|
||||
items: looks,
|
||||
frames,
|
||||
grid,
|
||||
get candidates() {
|
||||
|
||||
@@ -2,13 +2,20 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { normalizeGroundItem } from '../../ground.js';
|
||||
import {
|
||||
EPSILON_M,
|
||||
ITEM_TEXELS,
|
||||
LOOK,
|
||||
MARGIN_RATIO,
|
||||
MAX_PIECE_DEG,
|
||||
} from './corridors.js';
|
||||
import {
|
||||
BIN_THRESHOLD,
|
||||
binArea,
|
||||
composeGround,
|
||||
CORRIDOR_CHUNK,
|
||||
GRID_COLS,
|
||||
KIND,
|
||||
packHeights,
|
||||
packSet,
|
||||
STYLE_TEXELS,
|
||||
} from './pack.js';
|
||||
@@ -74,11 +81,21 @@ test('gnomonic segments keep a great-circle edge straight and distances right',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const [ax, ay, bx, by] = corridor.segments;
|
||||
assert.ok(Math.abs(ay) < 1e-6 && Math.abs(by) < 1e-6, 'on the equator');
|
||||
assert.ok(Math.abs(ax + bx) < 1e-6);
|
||||
assert.equal(corridor.styles[8], KIND.corridorPx);
|
||||
assert.equal(corridor.styles[10], 4);
|
||||
// Cut into pieces of at most MAX_PIECE_DEG, each straight in the plane.
|
||||
const pieces = corridor.segments.length / 4;
|
||||
assert.equal(pieces, Math.ceil(2 / MAX_PIECE_DEG));
|
||||
for (let at = 0; at < corridor.segments.length; at += 4) {
|
||||
const [ax, ay, bx, by] = corridor.segments.slice(at, at + 4);
|
||||
assert.ok(Math.abs(ay) < 1e-3 && Math.abs(by) < 1e-3, 'on the equator');
|
||||
if (at) assert.equal(ax, corridor.segments[at - 2], 'joined');
|
||||
assert.ok(bx > ax);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(corridor.segments[0] + corridor.segments.at(-2)) < 1e-3,
|
||||
'centred',
|
||||
);
|
||||
assert.equal(corridor.styles[8], KIND.corridorTile);
|
||||
assert.equal(corridor.items[4], 4, 'the item keeps its width');
|
||||
});
|
||||
|
||||
test('composed sets keep draw order and offset their segments', () => {
|
||||
@@ -171,31 +188,203 @@ test('composed sets keep draw order and offset their segments', () => {
|
||||
assert.ok(state.frames.data[2] < -1.9e6);
|
||||
});
|
||||
|
||||
test('a long corridor packs as chunks that own their own segments', () => {
|
||||
const positions = Array.from({ length: 41 }, (_, index) => [index * 0.01, 0]);
|
||||
const pack = packSet(
|
||||
'corridor',
|
||||
items('corridor', [{ positions, color: [1, 1, 1, 1], widthM: 10 }]),
|
||||
// Random lines over a region, as the ground bench draws them.
|
||||
function lines(count, { lon = 55, lat = 25, spread = 0.4, step = 0.004 } = {}) {
|
||||
let seed = 7;
|
||||
const next = () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 2 ** 32;
|
||||
};
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
let x = lon + next() * spread;
|
||||
let y = lat + next() * spread;
|
||||
const heading = next() * 2 * Math.PI;
|
||||
const positions = [];
|
||||
for (let k = 0; k < 12; k++) {
|
||||
positions.push([x, y]);
|
||||
const turn = heading + (next() - 0.5) * 1.2;
|
||||
x += step * Math.cos(turn);
|
||||
y += step * Math.sin(turn);
|
||||
}
|
||||
return {
|
||||
positions,
|
||||
color: [1, 1, 1, 1],
|
||||
...(index % 5 ? { widthPx: 2 + (index % 3) } : { widthM: 30 }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// A tile's levels as the shader walks them: [directory start, columns, rows].
|
||||
function levelsOf(style) {
|
||||
const out = [];
|
||||
let columns = style[10];
|
||||
let rows = style[11];
|
||||
let dir = style[18];
|
||||
for (let z = 0; z <= style[7]; z++) {
|
||||
out.push([dir, columns, rows]);
|
||||
dir += columns * rows;
|
||||
columns = (columns + 1) >> 1;
|
||||
rows = (rows + 1) >> 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('a corridor set packs as tiles whose bins list every segment near them', () => {
|
||||
const checked = items('corridor', lines(80));
|
||||
const pack = packSet('corridor', checked, { tilePieces: 300 });
|
||||
assert.ok(pack.count > 1, `${pack.count} tiles`);
|
||||
assert.ok(pack.count >= 1);
|
||||
assert.equal(pack.items.length, checked.length * ITEM_TEXELS * 4);
|
||||
for (let tile = 0; tile < pack.count; tile++) {
|
||||
const style = pack.styles.slice(
|
||||
tile * STYLE_TEXELS * 4,
|
||||
(tile + 1) * STYLE_TEXELS * 4,
|
||||
);
|
||||
assert.equal(style[8], KIND.corridorTile);
|
||||
const size = style[9];
|
||||
const [x0, y0] = [style[16], style[17]];
|
||||
const segmentStart = style[14];
|
||||
const runStart = style[15];
|
||||
const reference = style[6];
|
||||
for (const [z, [dir, columns, rows]] of levelsOf(style).entries()) {
|
||||
const bin = size * 2 ** z;
|
||||
// Brute force: every segment of the tile within its margin of a bin
|
||||
// is in one of the bin's runs.
|
||||
for (let row = 0; row < rows; row++)
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const at = (dir + row * columns + column) * 4;
|
||||
const listed = new Set();
|
||||
for (let r = 0; r < pack.bins[at + 1]; r++) {
|
||||
const run = (runStart + pack.bins[at] + r) * 4;
|
||||
for (let k = 0; k < pack.runs[run + 2]; k++)
|
||||
listed.add(pack.runs[run + 1] + k);
|
||||
}
|
||||
const left = x0 + column * bin;
|
||||
const bottom = y0 + row * bin;
|
||||
for (
|
||||
let s = 0;
|
||||
s <
|
||||
(tile + 1 < pack.count
|
||||
? pack.styles[(tile + 1) * STYLE_TEXELS * 4 + 14]
|
||||
: pack.segments.length / 4) -
|
||||
segmentStart;
|
||||
s++
|
||||
) {
|
||||
const g = (segmentStart + s) * 4;
|
||||
const [ax, ay, bx, by] = pack.segments.slice(g, g + 4);
|
||||
// The segment's distance to the bin's square.
|
||||
let near = Infinity;
|
||||
for (let t = 0; t <= 16; t++) {
|
||||
const px = ax + ((bx - ax) * t) / 16;
|
||||
const py = ay + ((by - ay) * t) / 16;
|
||||
const dx = Math.max(left - px, 0, px - left - bin);
|
||||
const dy = Math.max(bottom - py, 0, py - bottom - bin);
|
||||
near = Math.min(near, Math.hypot(dx, dy));
|
||||
}
|
||||
// The least margin of any item: a 2 px one (1.5 px of reach)
|
||||
// or a 30 m one (0.5 px, the reference, and 15 m).
|
||||
const margin =
|
||||
Math.min(
|
||||
(MARGIN_RATIO * bin * 1.5) / reference,
|
||||
MARGIN_RATIO * bin + 15,
|
||||
) +
|
||||
EPSILON_M * 0.5;
|
||||
if (near < margin)
|
||||
assert.ok(
|
||||
listed.has(s),
|
||||
`tile ${tile} level ${z} bin ${column},${row} misses segment ${s}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("tiles' cells split the set's region: one tile holds each point", () => {
|
||||
const checked = items('corridor', [
|
||||
...lines(400, { lon: -179.9, lat: 60, spread: 0.3, step: 0.02 }),
|
||||
...lines(400, { lon: 179.7, lat: 60.1, spread: 0.2, step: 0.02 }),
|
||||
]);
|
||||
// Few pieces a tile: many tiles.
|
||||
const pack = packSet('corridor', checked, { tilePieces: 300 });
|
||||
assert.ok(pack.count > 8, `${pack.count} tiles`);
|
||||
const cells = [];
|
||||
for (let tile = 0; tile < pack.count; tile++) {
|
||||
const at = tile * STYLE_TEXELS * 4;
|
||||
cells.push([
|
||||
...pack.styles.slice(at, at + 8),
|
||||
...pack.styles.slice(at + 20, at + 22),
|
||||
]);
|
||||
}
|
||||
let seed = 3;
|
||||
const next = () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 2 ** 32;
|
||||
};
|
||||
for (let k = 0; k < 2000; k++) {
|
||||
// Inside the set's bounds and beyond them (open sides).
|
||||
const lon = 179.4 + next() * 1.3;
|
||||
const lat = 59.8 + next() * 0.7;
|
||||
const world = geo.cartesian3.fromDegrees(lon, lat, next() * 500);
|
||||
const sinLat = world.z / Math.hypot(world.x, world.y, world.z);
|
||||
const holders = cells.filter(
|
||||
(c) =>
|
||||
world.x * c[0] + world.y * c[1] + c[8] >= 0 &&
|
||||
world.x * c[2] + world.y * c[3] + c[9] > 0 &&
|
||||
sinLat >= c[4] &&
|
||||
sinLat < c[5],
|
||||
);
|
||||
assert.equal(holders.length, 1, `${lon}, ${lat}: ${holders.length} tiles`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a corridor item's look packs for the shader", () => {
|
||||
const [item] = items('corridor', [
|
||||
{
|
||||
positions: [
|
||||
[0, 0],
|
||||
[0.01, 0],
|
||||
],
|
||||
color: [1, 0, 0, 1],
|
||||
widthPx: 9,
|
||||
glow: { power: 0.25 },
|
||||
pulse: { periodMs: 1600, min: 0.35, max: 0.75 },
|
||||
dash: {
|
||||
cells: 64,
|
||||
duty: 0.46,
|
||||
speed: 0.55,
|
||||
gapAlpha: 0.18,
|
||||
emission: 0.9,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const pack = packSet('corridor', [item]);
|
||||
const look = [...pack.items];
|
||||
assert.deepEqual(look.slice(0, 4), [1, 0, 0, 1]);
|
||||
assert.equal(look[4], 9);
|
||||
assert.ok(Math.abs(look[6] - 1113) < 5, `length ${look[6]}`);
|
||||
assert.equal(look[7], LOOK.glow | LOOK.pulse | LOOK.dash);
|
||||
assert.ok(Math.abs(look[8] - 0.25) < 1e-6);
|
||||
assert.equal(look[9], 1, 'no taper');
|
||||
assert.ok(Math.abs(look[10] - 1.6) < 1e-6);
|
||||
assert.ok(Math.abs(look[11] - 0.35) < 1e-6);
|
||||
assert.ok(Math.abs(look[12] - 0.75) < 1e-6);
|
||||
assert.deepEqual(
|
||||
look.slice(13, 16).map((v) => +v.toFixed(2)),
|
||||
[64, 0.46, 0.55],
|
||||
);
|
||||
assert.equal(pack.count, Math.ceil(40 / CORRIDOR_CHUNK));
|
||||
const owned = (index) => [
|
||||
...pack.styles.slice(
|
||||
index * STYLE_TEXELS * 4 + 16,
|
||||
index * STYLE_TEXELS * 4 + 18,
|
||||
),
|
||||
];
|
||||
const counts = (index) => pack.styles[index * STYLE_TEXELS * 4 + 15];
|
||||
// The first chunk has no segment before it, the others one each side.
|
||||
assert.deepEqual(owned(0), [0, CORRIDOR_CHUNK]);
|
||||
assert.equal(counts(0), CORRIDOR_CHUNK + 1);
|
||||
assert.deepEqual(owned(1), [1, CORRIDOR_CHUNK]);
|
||||
assert.equal(counts(1), CORRIDOR_CHUNK + 2);
|
||||
const last = pack.count - 1;
|
||||
const rest = 40 - last * CORRIDOR_CHUNK;
|
||||
assert.deepEqual(owned(last), [1, rest]);
|
||||
assert.equal(counts(last), rest + 1);
|
||||
assert.equal(pack.styles[8], KIND.corridorM);
|
||||
assert.equal(pack.styles[11], 10);
|
||||
assert.deepEqual(
|
||||
look.slice(16, 18).map((v) => +v.toFixed(2)),
|
||||
[0.18, 0.9],
|
||||
);
|
||||
});
|
||||
|
||||
test('heights are sampled again without packing the set again', () => {
|
||||
const checked = items('corridor', lines(20));
|
||||
const pack = packSet('corridor', checked);
|
||||
assert.ok(pack.heights.every(Number.isNaN));
|
||||
const heights = packHeights(pack, { terrainHeight: () => 25 });
|
||||
assert.deepEqual([...heights], Array(pack.count * 2).fill(25));
|
||||
});
|
||||
|
||||
test('sampled terrain heights bound the screen box; unknown ones keep it deep', () => {
|
||||
|
||||
+176
-36
@@ -4,11 +4,14 @@
|
||||
// 3D tiles); where that is the ground (not a mesh in front of it), the
|
||||
// candidates of its screen cell (pack.js) are tested in their own gnomonic
|
||||
// planes: a disc against its radius, an area against its segments (even-odd
|
||||
// inside, distance to the nearest edge), a corridor against its distance to
|
||||
// the line. Fills and outlines are anti-aliased over one pixel across the
|
||||
// boundary and blended in display space, in draw order, as Cesium blends
|
||||
// translucent ground primitives. Costs nothing while no item is drawn
|
||||
// (the effect is hidden).
|
||||
// inside, distance to the nearest edge), a corridor tile (corridors.js),
|
||||
// when the pixel's ground is in its cell, against the runs of the pixel's
|
||||
// bin: each item's distance to its nearest segment there. Fills, outlines
|
||||
// and corridors are anti-aliased over one pixel across the boundary and
|
||||
// blended in display space, in draw order, as Cesium blends translucent
|
||||
// ground primitives; a corridor's glow, pulse and dash follow Cesium's
|
||||
// materials, from the time uniform (src/globe/ground.js groundSeconds).
|
||||
// Costs nothing while no item is drawn (the effect is hidden).
|
||||
|
||||
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
|
||||
import { Effect, EffectAttribute } from 'postprocessing';
|
||||
@@ -21,11 +24,22 @@ import {
|
||||
RGBAFormat,
|
||||
Uniform,
|
||||
} from 'three';
|
||||
import { DASH_EDGE, groundSeconds } from '../../ground.js';
|
||||
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
|
||||
import { GRID_COLS, GRID_ROWS, KIND, STYLE_TEXELS, TEXELS } from './pack.js';
|
||||
import { ITEM_TEXELS, LOOK, MARGIN_RATIO } from './corridors.js';
|
||||
import {
|
||||
GRID_COLS,
|
||||
GRID_ROWS,
|
||||
KIND,
|
||||
ROW,
|
||||
STYLE_TEXELS,
|
||||
TEXELS,
|
||||
} from './pack.js';
|
||||
|
||||
export const GROUND_EFFECT_KEY = 'gevGround';
|
||||
|
||||
const float = (value) => (Number.isInteger(value) ? `${value}.0` : `${value}`);
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth;
|
||||
uniform sampler2D tSurfaceDepth;
|
||||
@@ -33,13 +47,17 @@ uniform highp sampler2D tFrames;
|
||||
uniform highp sampler2D tStyles;
|
||||
uniform highp sampler2D tSegments;
|
||||
uniform highp sampler2D tBins;
|
||||
uniform highp sampler2D tRuns;
|
||||
uniform highp sampler2D tItems;
|
||||
uniform highp sampler2D tGrid;
|
||||
uniform highp sampler2D tCandidates;
|
||||
uniform mat4 uInverseProjection;
|
||||
uniform mat4 uViewToWorld;
|
||||
uniform float uLogDepthFC;
|
||||
uniform float uTime;
|
||||
// Rows of ${ROW} texels (pack.js texelStorage).
|
||||
vec4 atSlot(highp sampler2D image, int slot) {
|
||||
int width = textureSize(image, 0).x;
|
||||
return texelFetch(image, ivec2(slot % width, slot / width), 0);
|
||||
return texelFetch(image, ivec2(slot & ${ROW - 1}, slot >> ${Math.log2(ROW)}), 0);
|
||||
}
|
||||
vec3 toDisplay(vec3 c) {
|
||||
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
|
||||
@@ -47,6 +65,45 @@ vec3 toDisplay(vec3 c) {
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
|
||||
}
|
||||
// A corridor item over the colour, nearest metres from its line and along
|
||||
// metres from its start: its width, glow, pulse and dash, in LDR and
|
||||
// premultiplied, as Cesium writes ground polylines.
|
||||
vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along, vec2 localDx, vec2 localDy) {
|
||||
int at = item * ${ITEM_TEXELS};
|
||||
vec4 shape = atSlot(tItems, at + 1);
|
||||
// Metres a pixel spans across the line (the screen gradient of the
|
||||
// distance to it): its width in pixels the same at any angle, as Cesium's.
|
||||
float pixel = max(length(vec2(dot(normal, localDx), dot(normal, localDy))), 1e-4);
|
||||
float halfWidth = shape.x > 0.0 ? shape.x * 0.5 * pixel : shape.y * 0.5;
|
||||
float cover = clamp((halfWidth - nearest) / pixel + 0.5, 0.0, 1.0);
|
||||
if (cover <= 0.0) return color;
|
||||
vec4 fill = atSlot(tItems, at);
|
||||
vec3 rgb = fill.rgb;
|
||||
float alpha = fill.a * cover;
|
||||
int look = int(shape.w + 0.5);
|
||||
if (look != 0) {
|
||||
vec4 glowPulse = atSlot(tItems, at + 2);
|
||||
vec4 pulseDash = atSlot(tItems, at + 3);
|
||||
vec4 dash = atSlot(tItems, at + 4);
|
||||
float s = clamp(along / max(shape.z, 1e-6), 0.0, 1.0);
|
||||
if ((look & ${LOOK.pulse}) != 0)
|
||||
alpha *= mix(glowPulse.w, pulseDash.x, 0.5 + 0.5 * sin(6.283185307179586 * uTime / glowPulse.z));
|
||||
if ((look & ${LOOK.glow}) != 0) {
|
||||
float glow = glowPulse.x / max(nearest / (2.0 * halfWidth), 1e-6) - glowPulse.x / 0.5;
|
||||
if (glowPulse.y <= 0.99999)
|
||||
glow *= min(1.0, glowPulse.y / (0.5 - s * 0.5) - glowPulse.y / 0.5);
|
||||
rgb = max(vec3(glow - 1.0) + rgb, rgb);
|
||||
alpha *= clamp(glow, 0.0, 1.0);
|
||||
}
|
||||
if ((look & ${LOOK.dash}) != 0) {
|
||||
float flow = fract(s * pulseDash.y - uTime * pulseDash.w);
|
||||
float on = 1.0 - smoothstep(pulseDash.z - ${float(DASH_EDGE)}, pulseDash.z + ${float(DASH_EDGE)}, flow);
|
||||
rgb *= 1.0 + dash.y * on;
|
||||
alpha *= max(on, dash.x);
|
||||
}
|
||||
}
|
||||
return min(color * (1.0 - alpha) + min(rgb * alpha, vec3(1.0)), vec3(1.0));
|
||||
}
|
||||
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
|
||||
outputColor = inputColor;
|
||||
float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv));
|
||||
@@ -69,6 +126,11 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
|
||||
// The packed depth also holds forward meshes, which are not ground.
|
||||
if (depth < surface - 2e-7) return;
|
||||
vec3 color = toDisplay(inputColor.rgb);
|
||||
// The pixel's ground in world space, once: every corridor tile tests the
|
||||
// same value, so exactly one tile of a set holds it.
|
||||
bool placed = false;
|
||||
vec3 world = vec3(0.0);
|
||||
float sinLatitude = 0.0;
|
||||
int first = int(range.x);
|
||||
int candidates = int(range.y);
|
||||
for (int k = 0; k < candidates; k++) {
|
||||
@@ -84,6 +146,20 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
|
||||
vec4 fillColor = atSlot(tStyles, id * ${STYLE_TEXELS});
|
||||
vec4 lineColor = atSlot(tStyles, id * ${STYLE_TEXELS} + 1);
|
||||
vec4 shape = atSlot(tStyles, id * ${STYLE_TEXELS} + 2);
|
||||
int kind = int(shape.x + 0.5);
|
||||
if (kind == ${KIND.corridorTile}) {
|
||||
if (!placed) {
|
||||
world = (uViewToWorld * vec4(point, 1.0)).xyz;
|
||||
sinLatitude = world.z / length(world);
|
||||
placed = true;
|
||||
}
|
||||
// The tile's cell: east of its west meridian, west of its east one,
|
||||
// between its parallels (fillColor and lineColor hold them); an open
|
||||
// side takes everything beyond it.
|
||||
vec4 open = atSlot(tStyles, id * ${STYLE_TEXELS} + 5);
|
||||
if (dot(world.xy, fillColor.xy) + open.x < 0.0 || dot(world.xy, fillColor.zw) + open.y <= 0.0 ||
|
||||
sinLatitude < lineColor.x || sinLatitude >= lineColor.y) continue;
|
||||
}
|
||||
// Along the ray from the Earth's centre onto the item's plane.
|
||||
float scale = origin.w / (origin.w + height);
|
||||
vec2 local = vec2(dot(delta, east.xyz), dot(delta, north.xyz)) * scale;
|
||||
@@ -92,9 +168,72 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
|
||||
// Beyond the item's extent and its padding in pixels: not this item.
|
||||
float reach = east.w + north.w * (length(localDx) + length(localDy));
|
||||
if (dot(local, local) > reach * reach) continue;
|
||||
int kind = int(shape.x + 0.5);
|
||||
if (kind == ${KIND.corridorTile}) {
|
||||
// The finest level whose margin covers the set's thinnest reach in
|
||||
// the pixel's widest metres per pixel (a wider corridor's runs are
|
||||
// listed with a wider margin).
|
||||
vec4 place = atSlot(tStyles, id * ${STYLE_TEXELS} + 4);
|
||||
vec4 open = atSlot(tStyles, id * ${STYLE_TEXELS} + 5);
|
||||
float pixelMax = max(length(localDx + localDy), length(localDx - localDy));
|
||||
// A pixel past the bins (beyond an open side) takes the bin nearest
|
||||
// it, with a margin that reaches it too.
|
||||
float past = length(max(vec2(0.0), max(place.xy - local, local - open.zw)));
|
||||
float need = (lineColor.z * pixelMax + past) / (${float(MARGIN_RATIO)} * shape.y);
|
||||
int level = need <= 1.0 ? 0 : min(int(lineColor.w + 0.5), int(ceil(log2(need))));
|
||||
int columns = int(shape.z + 0.5);
|
||||
int rows = int(shape.w + 0.5);
|
||||
int dir = int(place.z + 0.5);
|
||||
for (int z = 0; z < level; z++) {
|
||||
dir += columns * rows;
|
||||
columns = (columns + 1) >> 1;
|
||||
rows = (rows + 1) >> 1;
|
||||
}
|
||||
float size = shape.y * exp2(float(level));
|
||||
ivec2 cell = clamp(ivec2(floor((local - place.xy) / size)), ivec2(0), ivec2(columns - 1, rows - 1));
|
||||
vec4 bin = atSlot(tBins, dir + cell.y * columns + cell.x);
|
||||
int runStart = int(span.w + 0.5) + int(bin.x + 0.5);
|
||||
int runCount = int(bin.y + 0.5);
|
||||
int segmentBase = int(span.z + 0.5);
|
||||
int itemBase = int(place.w + 0.5);
|
||||
int item = -1;
|
||||
float best = 1e30;
|
||||
vec2 bestNormal = vec2(1.0, 0.0);
|
||||
float bestAlong = 0.0;
|
||||
for (int r = 0; r < runCount; r++) {
|
||||
vec4 run = atSlot(tRuns, runStart + r);
|
||||
int runItem = itemBase + int(run.x + 0.5);
|
||||
if (runItem != item) {
|
||||
if (item >= 0)
|
||||
color = blendCorridor(color, item, best, bestNormal, bestAlong, localDx, localDy);
|
||||
item = runItem;
|
||||
best = 1e30;
|
||||
}
|
||||
float along = run.w;
|
||||
int start = segmentBase + int(run.y + 0.5);
|
||||
int count = int(run.z + 0.5);
|
||||
for (int s = 0; s < count; s++) {
|
||||
vec4 segment = atSlot(tSegments, start + s);
|
||||
vec2 a = segment.xy;
|
||||
vec2 ab = segment.zw - a;
|
||||
vec2 ap = local - a;
|
||||
float lengthSq = dot(ab, ab);
|
||||
float t = clamp(dot(ap, ab) / max(lengthSq, 1e-12), 0.0, 1.0);
|
||||
vec2 off = ap - ab * t;
|
||||
float d = length(off);
|
||||
float segmentLength = sqrt(lengthSq);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
if (d > 0.0) bestNormal = off / d;
|
||||
bestAlong = along + t * segmentLength;
|
||||
}
|
||||
along += segmentLength;
|
||||
}
|
||||
}
|
||||
if (item >= 0)
|
||||
color = blendCorridor(color, item, best, bestNormal, bestAlong, localDx, localDy);
|
||||
continue;
|
||||
}
|
||||
float nearest = 1e30;
|
||||
int nearestSegment = 0;
|
||||
vec2 normal = vec2(1.0, 0.0);
|
||||
bool inside = false;
|
||||
if (kind == ${KIND.disc}) {
|
||||
@@ -111,21 +250,19 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
|
||||
bool binned = false;
|
||||
bool centreInside = false;
|
||||
vec2 centre = vec2(0.0);
|
||||
if (kind == ${KIND.area}) {
|
||||
vec4 grid = atSlot(tStyles, id * ${STYLE_TEXELS} + 4);
|
||||
if (grid.x > 0.5 &&
|
||||
(length(localDx) + length(localDy)) * (shape.y * 0.5 + 1.5) <= grid.z * 0.5) {
|
||||
vec2 origin = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).xy;
|
||||
ivec2 cell = ivec2(floor((local - origin) / grid.z));
|
||||
// Off the grid: outside the area, beyond any edge's reach.
|
||||
if (cell.x < 0 || cell.y < 0 || cell.x >= int(grid.x) || cell.y >= int(grid.y)) continue;
|
||||
vec4 bin = atSlot(tBins, int(grid.w) + cell.y * int(grid.x) + cell.x);
|
||||
start += int(bin.x);
|
||||
count = int(bin.y);
|
||||
centreInside = bin.z > 0.5;
|
||||
centre = origin + (vec2(cell) + 0.5) * grid.z;
|
||||
binned = true;
|
||||
}
|
||||
vec4 grid = atSlot(tStyles, id * ${STYLE_TEXELS} + 4);
|
||||
if (grid.x > 0.5 &&
|
||||
(length(localDx) + length(localDy)) * (shape.y * 0.5 + 1.5) <= grid.z * 0.5) {
|
||||
vec2 origin = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).xy;
|
||||
ivec2 cell = ivec2(floor((local - origin) / grid.z));
|
||||
// Off the grid: outside the area, beyond any edge's reach.
|
||||
if (cell.x < 0 || cell.y < 0 || cell.x >= int(grid.x) || cell.y >= int(grid.y)) continue;
|
||||
vec4 bin = atSlot(tBins, int(grid.w) + cell.y * int(grid.x) + cell.x);
|
||||
start += int(bin.x);
|
||||
count = int(bin.y);
|
||||
centreInside = bin.z > 0.5;
|
||||
centre = origin + (vec2(cell) + 0.5) * grid.z;
|
||||
binned = true;
|
||||
}
|
||||
for (int s = 0; s < count; s++) {
|
||||
vec4 segment = atSlot(tSegments, start + s);
|
||||
@@ -137,7 +274,6 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
|
||||
float d = length(off);
|
||||
if (d < nearest) {
|
||||
nearest = d;
|
||||
nearestSegment = s;
|
||||
if (d > 0.0) normal = off / d;
|
||||
}
|
||||
if (binned) {
|
||||
@@ -156,16 +292,6 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
|
||||
}
|
||||
// Metres one pixel spans across the boundary.
|
||||
float pixel = max(abs(dot(normal, localDx)) + abs(dot(normal, localDy)), 1e-4);
|
||||
if (kind == ${KIND.corridorPx} || kind == ${KIND.corridorM}) {
|
||||
// A chunk of a long corridor draws where its own segments are nearest.
|
||||
vec2 owned = atSlot(tStyles, id * ${STYLE_TEXELS} + 4).xy;
|
||||
if (float(nearestSegment) < owned.x - 0.5 ||
|
||||
float(nearestSegment) > owned.x + owned.y - 0.5) continue;
|
||||
float halfWidth = kind == ${KIND.corridorPx} ? shape.z * 0.5 * pixel : shape.w * 0.5;
|
||||
float cover = clamp((halfWidth - nearest) / pixel + 0.5, 0.0, 1.0);
|
||||
color = mix(color, fillColor.rgb, fillColor.a * cover);
|
||||
continue;
|
||||
}
|
||||
float inward = inside ? nearest : -nearest;
|
||||
if (fillColor.a > 0.0)
|
||||
color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0));
|
||||
@@ -190,6 +316,8 @@ function texturesFor(state) {
|
||||
styles: dataTexture(state.styles),
|
||||
segments: dataTexture(state.segments),
|
||||
bins: dataTexture(state.bins),
|
||||
runs: dataTexture(state.runs),
|
||||
items: dataTexture(state.items),
|
||||
frames: dataTexture(state.frames),
|
||||
grid: dataTexture(state.grid),
|
||||
candidates: dataTexture(state.candidates),
|
||||
@@ -199,6 +327,8 @@ function texturesFor(state) {
|
||||
'styles',
|
||||
'segments',
|
||||
'bins',
|
||||
'runs',
|
||||
'items',
|
||||
'frames',
|
||||
'grid',
|
||||
'candidates',
|
||||
@@ -216,6 +346,8 @@ export const textureBytes = (state) =>
|
||||
state.styles,
|
||||
state.segments,
|
||||
state.bins,
|
||||
state.runs,
|
||||
state.items,
|
||||
state.frames,
|
||||
state.grid,
|
||||
state.candidates,
|
||||
@@ -260,10 +392,14 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
['tStyles', new Uniform(null)],
|
||||
['tSegments', new Uniform(null)],
|
||||
['tBins', new Uniform(null)],
|
||||
['tRuns', new Uniform(null)],
|
||||
['tItems', new Uniform(null)],
|
||||
['tGrid', new Uniform(null)],
|
||||
['tCandidates', new Uniform(null)],
|
||||
['uInverseProjection', new Uniform(new Matrix4())],
|
||||
['uViewToWorld', new Uniform(new Matrix4())],
|
||||
['uLogDepthFC', new Uniform(1)],
|
||||
['uTime', new Uniform(0)],
|
||||
]),
|
||||
});
|
||||
return new NavaraEffect(camera, this.effect);
|
||||
@@ -300,11 +436,15 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
u.get('tAllDepth').value = mrt.depthBuffer;
|
||||
u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture;
|
||||
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
|
||||
u.get('uViewToWorld').value.copy(camera.matrixWorld);
|
||||
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
|
||||
u.get('uTime').value = groundSeconds();
|
||||
u.get('tFrames').value = owner.textures.frames;
|
||||
u.get('tStyles').value = owner.textures.styles;
|
||||
u.get('tSegments').value = owner.textures.segments;
|
||||
u.get('tBins').value = owner.textures.bins;
|
||||
u.get('tRuns').value = owner.textures.runs;
|
||||
u.get('tItems').value = owner.textures.items;
|
||||
u.get('tGrid').value = owner.textures.grid;
|
||||
u.get('tCandidates').value = owner.textures.candidates;
|
||||
}
|
||||
|
||||
@@ -3239,6 +3239,209 @@ function groundChecks(
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
'ground: corridors glow, pulse and dash, and keep frames coming while they move',
|
||||
async () => {
|
||||
const line = (lat, extra) => ({
|
||||
positions: [
|
||||
[19, lat],
|
||||
[21, lat],
|
||||
],
|
||||
color: [1, 0, 1, 1],
|
||||
widthPx: 12,
|
||||
...extra,
|
||||
});
|
||||
const bad = ground.createSet({
|
||||
id: 'contract-looks-bad',
|
||||
kind: 'corridor',
|
||||
});
|
||||
try {
|
||||
for (const [item, label] of [
|
||||
[
|
||||
{
|
||||
...line(9, { glow: { power: 0.25 } }),
|
||||
widthPx: undefined,
|
||||
widthM: 900,
|
||||
},
|
||||
'a glow on a metre corridor',
|
||||
],
|
||||
[
|
||||
line(9, { pulse: { periodMs: 1000, min: 0.8, max: 0.2 } }),
|
||||
'a pulse min over max',
|
||||
],
|
||||
[
|
||||
line(9, { pulse: { periodMs: 0, min: 0, max: 1 } }),
|
||||
'a pulse of no period',
|
||||
],
|
||||
[
|
||||
line(9, { dash: { cells: 8, duty: 1 } }),
|
||||
'a dash lit all its cell',
|
||||
],
|
||||
[line(9, { glow: { power: 0 } }), 'a glow of no power'],
|
||||
])
|
||||
throwsType(() => bad.replace([item]), label);
|
||||
} finally {
|
||||
bad.destroy();
|
||||
}
|
||||
const glowing = ground.createSet({
|
||||
id: 'contract-glow',
|
||||
kind: 'corridor',
|
||||
order: 5,
|
||||
});
|
||||
const pulsing = ground.createSet({
|
||||
id: 'contract-pulse',
|
||||
kind: 'corridor',
|
||||
order: 5,
|
||||
});
|
||||
const dashed = ground.createSet({
|
||||
id: 'contract-dash',
|
||||
kind: 'corridor',
|
||||
order: 5,
|
||||
});
|
||||
const listed = (id) => ground.list().find((entry) => entry.id === id);
|
||||
// Frames drawn in a window, with nothing asked of the engine.
|
||||
const framesIn = async (ms) => {
|
||||
let frames = 0;
|
||||
const remove = engine.frames.postRender.addEventListener(
|
||||
() => frames++,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
remove();
|
||||
return frames;
|
||||
};
|
||||
try {
|
||||
if (readRegion) await nadirView(1e6);
|
||||
const idle = readRegion ? await framesIn(1000) : 0;
|
||||
glowing.replace([
|
||||
line(9, {
|
||||
color: [0, 1, 0, 1],
|
||||
widthPx: 24,
|
||||
glow: { power: 0.25 },
|
||||
}),
|
||||
]);
|
||||
glowing.commit();
|
||||
ensure(
|
||||
listed('contract-glow')?.animated === false,
|
||||
'a glow alone does not animate',
|
||||
);
|
||||
pulsing.replace([
|
||||
line(10, { pulse: { periodMs: 800, min: 0.1, max: 1 } }),
|
||||
]);
|
||||
pulsing.commit();
|
||||
ensure(
|
||||
listed('contract-pulse')?.animated === true,
|
||||
'a pulse animates',
|
||||
);
|
||||
dashed.replace([
|
||||
line(11, { dash: { cells: 10, duty: 0.5, speed: 2 } }),
|
||||
]);
|
||||
dashed.commit();
|
||||
ensure(listed('contract-dash')?.animated === true, 'a dash animates');
|
||||
await until(
|
||||
() => glowing.ready() && pulsing.ready() && dashed.ready(),
|
||||
'the animated corridors built',
|
||||
);
|
||||
if (!readRegion) return;
|
||||
// (A page that already draws every frame shows nothing more.)
|
||||
const moving = await framesIn(1000);
|
||||
ensure(
|
||||
moving >= Math.min(idle + 20, 50),
|
||||
`${moving} frames in 1 s while corridors pulse and flow, ${idle} before`,
|
||||
);
|
||||
// Pixels along a line of latitude between 19.2° and 20.8°E.
|
||||
const along = (lat, samples = 120) => {
|
||||
const out = [];
|
||||
for (let k = 0; k < samples; k++) {
|
||||
const at = screenOf(19.2 + (1.6 * k) / (samples - 1), lat);
|
||||
out.push([
|
||||
...readRegion(Math.round(at.x), Math.round(at.y), 1, 1),
|
||||
]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const magenta = ([r, g, b]) => r > 200 && b > 200 && g < 60;
|
||||
// The glow: white at its centre line, green toward its edges.
|
||||
{
|
||||
const centre = screenOf(20, 9);
|
||||
const column = readRegion(
|
||||
Math.round(centre.x),
|
||||
Math.round(centre.y) - 12,
|
||||
1,
|
||||
25,
|
||||
);
|
||||
const mid = column.slice(12 * 4, 12 * 4 + 4);
|
||||
ensure(
|
||||
mid[0] > 200 && mid[1] > 200 && mid[2] > 200,
|
||||
`the glow's centre is ${[...mid.slice(0, 3)]}, not white`,
|
||||
);
|
||||
let greenish = 0;
|
||||
for (let i = 0; i < column.length; i += 4)
|
||||
if (column[i + 1] > 120 && column[i] < column[i + 1] - 60)
|
||||
greenish++;
|
||||
ensure(
|
||||
greenish >= 2,
|
||||
`the glow fades to green over ${greenish} px`,
|
||||
);
|
||||
}
|
||||
// The pulse: magenta at its peak, faint at its trough.
|
||||
{
|
||||
let peak = 0;
|
||||
let trough = 0;
|
||||
const started = performance.now();
|
||||
while (performance.now() - started < 1600) {
|
||||
const centre = screenOf(20, 10);
|
||||
const pixel = readRegion(
|
||||
Math.round(centre.x),
|
||||
Math.round(centre.y),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
if (magenta(pixel)) peak++;
|
||||
else trough++;
|
||||
await settle(1);
|
||||
}
|
||||
ensure(
|
||||
peak > 0 && trough > 0,
|
||||
`the pulse was magenta ${peak} times, not ${trough}`,
|
||||
);
|
||||
}
|
||||
// The dash: lit and dark cells along the line, moving.
|
||||
{
|
||||
const first = along(11).map(magenta);
|
||||
let changes = 0;
|
||||
for (let k = 1; k < first.length; k++)
|
||||
if (first[k] !== first[k - 1]) changes++;
|
||||
const lit = first.filter(Boolean).length / first.length;
|
||||
ensure(
|
||||
changes >= 8 && lit > 0.25 && lit < 0.75,
|
||||
`the dash is ${lit.toFixed(2)} lit with ${changes} changes along it`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await settle(2);
|
||||
const second = along(11).map(magenta);
|
||||
const moved = second.filter(
|
||||
(value, k) => value !== first[k],
|
||||
).length;
|
||||
ensure(
|
||||
moved >= 12,
|
||||
`the dash moved at ${moved} of 120 pixels in 150 ms`,
|
||||
);
|
||||
}
|
||||
pulsing.show(false);
|
||||
dashed.show(false);
|
||||
await settle(2);
|
||||
const still = await framesIn(1000);
|
||||
ensure(
|
||||
still <= idle + 3,
|
||||
`${still} frames in 1 s once hidden, ${idle} before`,
|
||||
);
|
||||
} finally {
|
||||
glowing.destroy();
|
||||
pulsing.destroy();
|
||||
dashed.destroy();
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -259,7 +259,8 @@ export function createFakeEngine(options = {}) {
|
||||
};
|
||||
return part;
|
||||
});
|
||||
// Ground sets keep their items for tests; commit counts as a render request.
|
||||
// Ground sets keep their items for tests; commit counts as a render
|
||||
// request, and so does every frame while a shown set animates.
|
||||
const groundRegistry = createGroundRegistry(() => ({
|
||||
commit() {
|
||||
fake.renderRequests++;
|
||||
@@ -948,6 +949,8 @@ export function createFakeEngine(options = {}) {
|
||||
events['frames.preRender'].raiseEvent(clock);
|
||||
cameraEvents();
|
||||
events['frames.postRender'].raiseEvent(clock);
|
||||
// A shown ground set with a pulse or a dash asks for the next frame.
|
||||
if (groundRegistry.animating()) fake.renderRequests++;
|
||||
},
|
||||
frames(count = 1, dt = 16) {
|
||||
for (let index = 0; index < count; index++) fake.frame(dt);
|
||||
|
||||
@@ -903,6 +903,9 @@ export function createDirectionsLayer({
|
||||
const _stepAnchors = new Map();
|
||||
let _selectedStep = null;
|
||||
let _listening = false;
|
||||
/** Whether a route is on the display, and the continuous render it holds. */
|
||||
let _routeDrawn = false;
|
||||
let _renderHeld = false;
|
||||
let _removeClick = null;
|
||||
let _rowControlsListener = null;
|
||||
let _dataManager = null;
|
||||
@@ -987,13 +990,28 @@ export function createDirectionsLayer({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route's dashes flow toward B: the scene renders continuously while
|
||||
* it is drawn and the layer is on, as it did under Cesium's route flow
|
||||
* material.
|
||||
*/
|
||||
function syncRenderHold() {
|
||||
const shouldHold = _enabled && _routeDrawn;
|
||||
if (shouldHold === _renderHeld) return;
|
||||
_renderHeld = shouldHold;
|
||||
if (shouldHold) services.render.holdContinuousRender?.('directions');
|
||||
else services.render.releaseContinuousRender?.('directions');
|
||||
}
|
||||
|
||||
function clearRouteGraphics() {
|
||||
services.credits?.hideOsmCredit?.(_engine, 'directions');
|
||||
_clearStepSelection();
|
||||
stopStepAnchoring();
|
||||
_display?.setRoute(null);
|
||||
_routeDrawn = false;
|
||||
_stepAnchors.clear();
|
||||
_display?.setSteps([]);
|
||||
syncRenderHold();
|
||||
}
|
||||
|
||||
function stopStepAnchoring() {
|
||||
@@ -1055,6 +1073,8 @@ export function createDirectionsLayer({
|
||||
clearRouteGraphics();
|
||||
if (!_display) return;
|
||||
_display.setRoute(route.geometry);
|
||||
_routeDrawn = true;
|
||||
syncRenderHold();
|
||||
// One dot per decision; A and B already mark departure and arrival. The dots
|
||||
// start hidden and appear as anchorStepPoints resolves each ground cell.
|
||||
const cells = [];
|
||||
@@ -1535,6 +1555,7 @@ export function createDirectionsLayer({
|
||||
(pickedId) =>
|
||||
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
|
||||
);
|
||||
syncRenderHold();
|
||||
},
|
||||
|
||||
/** Remove the route, markers and listeners. */
|
||||
@@ -1545,6 +1566,7 @@ export function createDirectionsLayer({
|
||||
_removeClickHandler();
|
||||
services.picking.unregisterPickOwner('directions');
|
||||
_display?.show(false);
|
||||
syncRenderHold();
|
||||
},
|
||||
|
||||
/** Nothing to poll: routes are requested on placement and mode change. */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Directions on the globe contract, the same on every engine:
|
||||
// - the route: one engine.ground corridor set (9 px, draped on terrain and
|
||||
// 3D tiles), its dash cells lit and dim as the route flow material draws
|
||||
// them at rest;
|
||||
// 3D tiles) of one corridor whose dashes flow toward the destination, as
|
||||
// the route flow material drew them (the engine animates them on the GPU
|
||||
// and draws every frame while the route shows);
|
||||
// - the maneuver dots: one point mover set over the scene, white with a
|
||||
// route-colour outline, picked as `directions:step:<index>`; a dot without
|
||||
// a ground anchor yet is hidden;
|
||||
@@ -23,10 +24,8 @@ import {
|
||||
MARKER_OUTLINE_PX,
|
||||
MARKER_PIXEL_SIZE,
|
||||
ORDER,
|
||||
ROUTE_DASH_CELLS,
|
||||
ROUTE_DASH_COLOR,
|
||||
ROUTE_DASH_DUTY,
|
||||
ROUTE_GAP_COLOR,
|
||||
ROUTE_COLOR,
|
||||
ROUTE_DASH,
|
||||
ROUTE_WIDTH_PX,
|
||||
STEP_COLOR,
|
||||
STEP_OUTLINE_COLOR,
|
||||
@@ -48,56 +47,6 @@ const FIRST_STEP_CAPACITY = 16;
|
||||
/** The pickId of maneuver dot `index`. */
|
||||
export const stepPickId = (index) => `${ID}:step:${index}`;
|
||||
|
||||
const wrapLon = (lon) => ((lon + 540) % 360) - 180;
|
||||
|
||||
/**
|
||||
* The route cut into its dash cells, lit and dim in turn: each of
|
||||
* ROUTE_DASH_CELLS equal lengths along the route is lit for its first
|
||||
* ROUTE_DASH_DUTY and dim for the rest. Every original vertex is kept.
|
||||
* @param {number[][]} path [lon, lat] pairs.
|
||||
* @returns {{ positions: number[][], lit: boolean }[]}
|
||||
*/
|
||||
export function routeDashPieces(path) {
|
||||
const vertices = path.map(([lon, lat]) =>
|
||||
geo.cartesian3.fromDegrees(lon, lat),
|
||||
);
|
||||
const arc = [0];
|
||||
for (let i = 1; i < vertices.length; i++)
|
||||
arc.push(
|
||||
arc[i - 1] + geo.cartesian3.distance(vertices[i - 1], vertices[i]),
|
||||
);
|
||||
const total = arc.at(-1);
|
||||
if (!(total > 0)) return [];
|
||||
// The point at arc length `at` on segment i - 1 .. i.
|
||||
const pointAt = (i, at) => {
|
||||
const f = Math.max(
|
||||
0,
|
||||
Math.min(1, (at - arc[i - 1]) / (arc[i] - arc[i - 1] || 1)),
|
||||
);
|
||||
const [lon, lat] = path[i - 1];
|
||||
const [nextLon, nextLat] = path[i];
|
||||
const delta = wrapLon(nextLon - lon);
|
||||
return [wrapLon(lon + f * delta), lat + f * (nextLat - lat)];
|
||||
};
|
||||
const cuts = [];
|
||||
for (let cell = 0; cell < ROUTE_DASH_CELLS; cell++) {
|
||||
cuts.push({ at: (total * (cell + ROUTE_DASH_DUTY)) / ROUTE_DASH_CELLS });
|
||||
cuts.push({ at: (total * (cell + 1)) / ROUTE_DASH_CELLS });
|
||||
}
|
||||
const pieces = [];
|
||||
let start = path[0];
|
||||
let i = 1;
|
||||
cuts.forEach(({ at }, index) => {
|
||||
const last = index === cuts.length - 1;
|
||||
const positions = [start];
|
||||
while (i < path.length - 1 && arc[i] < at) positions.push(path[i++]);
|
||||
start = last ? path.at(-1) : pointAt(i, at);
|
||||
positions.push(start);
|
||||
pieces.push({ positions, lit: index % 2 === 0 });
|
||||
});
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** The letters' atlas: one cell per marker, the glyph above its centre. */
|
||||
function letterAtlas() {
|
||||
const document = globalThis.document;
|
||||
@@ -273,21 +222,21 @@ export function createDirectionsDisplay(engine) {
|
||||
if (destroyed) return;
|
||||
route?.destroy();
|
||||
route = null;
|
||||
const pieces = geometry ? routeDashPieces(geometry) : [];
|
||||
if (!pieces.length) return;
|
||||
if (!(geometry?.length >= 2)) return;
|
||||
route = engine.ground.createSet({
|
||||
id: DIRECTIONS_SET_IDS.route,
|
||||
kind: 'corridor',
|
||||
drape: 'both',
|
||||
pickable: false,
|
||||
});
|
||||
route.replace(
|
||||
pieces.map(({ positions, lit }) => ({
|
||||
positions,
|
||||
color: lit ? ROUTE_DASH_COLOR : ROUTE_GAP_COLOR,
|
||||
route.replace([
|
||||
{
|
||||
positions: geometry,
|
||||
color: ROUTE_COLOR,
|
||||
widthPx: ROUTE_WIDTH_PX,
|
||||
})),
|
||||
);
|
||||
dash: ROUTE_DASH,
|
||||
},
|
||||
]);
|
||||
route.commit();
|
||||
route.show(visible);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The Directions display on the engine contract, on the fake engine and on
|
||||
// the Cesium engine (real Cesium point and billboard collections and the
|
||||
// engine's ground batches over fake primitives): the route's dash cells,
|
||||
// width, colours and drape; the maneuver dots' style, anchoring, selection
|
||||
// engine's ground batches over fake primitives): the route's flowing
|
||||
// dashes, width, colour and drape, and the render it holds; the maneuver dots' style, anchoring, selection
|
||||
// and picks; the A and B markers at the surface with their letters; show and
|
||||
// hide through the layer; and what teardown leaves.
|
||||
|
||||
@@ -14,14 +14,10 @@ import { createApplicationDirectionsLayer } from '../../../data/directions.js';
|
||||
import {
|
||||
DIRECTIONS_SET_IDS as IDS,
|
||||
createDirectionsDisplay,
|
||||
routeDashPieces,
|
||||
stepPickId,
|
||||
} from './display.js';
|
||||
import {
|
||||
DIRECTIONS_ROUTE_COLOR,
|
||||
ROUTE_DASH_CELLS,
|
||||
ROUTE_DASH_DUTY,
|
||||
} from './style.js';
|
||||
import { DIRECTIONS_ROUTE_COLOR } from './style.js';
|
||||
import { getRenderGovernorDiagnostics } from '../../../renderGovernor.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
|
||||
const near = (actual, expected, label, tolerance = 1e-6) =>
|
||||
@@ -170,46 +166,10 @@ test('the display needs a globe engine', () => {
|
||||
assert.throws(() => createDirectionsDisplay(null), TypeError);
|
||||
});
|
||||
|
||||
test('the route is cut into lit and dim dash cells that keep every turn', () => {
|
||||
const path = payload.geometry;
|
||||
const pieces = routeDashPieces(path);
|
||||
assert.equal(pieces.length, ROUTE_DASH_CELLS * 2);
|
||||
pieces.forEach((piece, index) =>
|
||||
assert.equal(piece.lit, index % 2 === 0, `piece ${index}`),
|
||||
);
|
||||
assert.deepEqual(pieces[0].positions[0], path[0]);
|
||||
assert.deepEqual(pieces.at(-1).positions.at(-1), path.at(-1));
|
||||
// Pieces join end to end, and the middle vertex is kept.
|
||||
for (let i = 1; i < pieces.length; i++)
|
||||
assert.deepEqual(pieces[i].positions[0], pieces[i - 1].positions.at(-1));
|
||||
assert.ok(
|
||||
pieces.some((piece) =>
|
||||
piece.positions.some(([lon, lat]) => lon === MID.lon && lat === MID.lat),
|
||||
),
|
||||
);
|
||||
// A lit cell is the duty fraction of its cell's length.
|
||||
const length = (positions) =>
|
||||
positions
|
||||
.slice(1)
|
||||
.reduce(
|
||||
(sum, [lon, lat], i) =>
|
||||
sum +
|
||||
geo.cartesian3.distance(
|
||||
geo.cartesian3.fromDegrees(...positions[i]),
|
||||
geo.cartesian3.fromDegrees(lon, lat),
|
||||
),
|
||||
0,
|
||||
);
|
||||
const lit = length(pieces[2].positions);
|
||||
const dim = length(pieces[3].positions);
|
||||
near(lit / (lit + dim), ROUTE_DASH_DUTY, 'duty', 1e-3);
|
||||
assert.deepEqual(routeDashPieces([[0, 0]]), []);
|
||||
});
|
||||
|
||||
for (const makeHarness of GROUND_HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`the route is one 9 px corridor set draped on terrain and tiles, its cells at 0.95 and 0.171 (${name})`, async (t) => {
|
||||
test(`the route is one 9 px corridor draped on terrain and tiles whose dashes flow toward B, holding the render (${name})`, async (t) => {
|
||||
const f = fixture(t, makeHarness);
|
||||
await f.route();
|
||||
const set = f.set(IDS.route);
|
||||
@@ -217,19 +177,24 @@ for (const makeHarness of GROUND_HARNESSES) {
|
||||
assert.equal(set.options.drape, 'both');
|
||||
assert.equal(set.options.pickable, false);
|
||||
assert.equal(set.shown, true);
|
||||
assert.equal(set.items.length, ROUTE_DASH_CELLS * 2);
|
||||
set.items.forEach((item, index) => {
|
||||
assert.equal(item.widthPx, 9);
|
||||
nearAll(
|
||||
item.color,
|
||||
[...ROUTE_RGB, index % 2 === 0 ? 0.95 : 0.95 * 0.18],
|
||||
`colour ${index}`,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(set.items[0].positions[0], payload.geometry[0]);
|
||||
assert.equal(set.items.length, 1);
|
||||
const [item] = set.items;
|
||||
assert.equal(item.widthPx, 9);
|
||||
assert.deepEqual(item.positions, payload.geometry);
|
||||
nearAll(item.color, [...ROUTE_RGB, 0.95], 'colour');
|
||||
// The route flow material: 64 cells lit for 46 %, 18 % between, 0.9
|
||||
// emission, 0.55 cells a second toward the destination.
|
||||
assert.deepEqual(
|
||||
set.items.at(-1).positions.at(-1),
|
||||
payload.geometry.at(-1),
|
||||
{ ...item.dash },
|
||||
{ cells: 64, duty: 0.46, speed: 0.55, gapAlpha: 0.18, emission: 0.9 },
|
||||
);
|
||||
assert.equal(
|
||||
f.engine.ground.list().find(({ id }) => id === IDS.route).animated,
|
||||
true,
|
||||
);
|
||||
assert.ok(
|
||||
getRenderGovernorDiagnostics().holds.includes('directions'),
|
||||
'a drawn route holds continuous render',
|
||||
);
|
||||
if (f.cesium) {
|
||||
const { fills, lines } = f.primitives(IDS.route);
|
||||
@@ -239,7 +204,20 @@ for (const makeHarness of GROUND_HARNESSES) {
|
||||
lines[0].options.classificationType,
|
||||
Cesium.ClassificationType.BOTH,
|
||||
);
|
||||
const { uniforms } = lines[0].options.appearance.material;
|
||||
assert.equal(uniforms.dashCells, 64);
|
||||
near(uniforms.dashDuty, 0.46, 'duty');
|
||||
near(uniforms.dashSpeed, 0.55, 'speed');
|
||||
near(uniforms.dashGap, 0.18, 'gap');
|
||||
near(uniforms.dashEmission, 0.9, 'emission');
|
||||
near(uniforms.color.alpha, 0.95, 'alpha');
|
||||
}
|
||||
f.layer.setParams({ clear: true });
|
||||
assert.equal(f.set(IDS.route), undefined);
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('directions'),
|
||||
'a cleared route releases it',
|
||||
);
|
||||
});
|
||||
|
||||
test(`maneuver dots are 8 px white over the scene with a 2 px route outline, hidden until anchored (${name})`, async (t) => {
|
||||
@@ -381,7 +359,7 @@ test('an engine switch redraws the route, dots and markers on the new engine onl
|
||||
f.layer.resumeCamera();
|
||||
assert.equal(f.engine.movers.list().length, 0);
|
||||
assert.equal(f.sets(), 0);
|
||||
assert.equal(next.set(IDS.route).items.length, ROUTE_DASH_CELLS * 2);
|
||||
assert.equal(next.set(IDS.route).items.length, 1);
|
||||
assert.equal(movers(IDS.steps).count, 1);
|
||||
assert.equal(movers(IDS.markers).rows[0].show, true);
|
||||
assert.equal(movers(IDS.markers).rows[1].show, true);
|
||||
|
||||
@@ -13,12 +13,17 @@ const rgba = (css, alpha) => {
|
||||
|
||||
// The route flow material (src/annotations/worldAnnotationRenderer.js): the
|
||||
// route colour at 0.95, 64 dash cells along the route, each lit for its
|
||||
// first 46 % and at 18 % alpha between.
|
||||
// first 46 % (brightened by 0.9 of its colour) and at 18 % alpha between,
|
||||
// flowing toward the destination at 0.55 cells a second.
|
||||
export const ROUTE_WIDTH_PX = 9;
|
||||
export const ROUTE_DASH_CELLS = 64;
|
||||
export const ROUTE_DASH_DUTY = 0.46;
|
||||
export const ROUTE_DASH_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 0.95);
|
||||
export const ROUTE_GAP_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 0.95 * 0.18);
|
||||
export const ROUTE_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 0.95);
|
||||
export const ROUTE_DASH = Object.freeze({
|
||||
cells: 64,
|
||||
duty: 0.46,
|
||||
speed: 0.55,
|
||||
gapAlpha: 0.18,
|
||||
emission: 0.9,
|
||||
});
|
||||
|
||||
// Maneuver dots: white at 0.95 with a 2 px route-colour outline, 8 px, 13 px
|
||||
// while selected.
|
||||
|
||||
@@ -217,7 +217,14 @@ test('the box preview is one ground cell, hidden with no box', () => {
|
||||
display.setBoxPreview(BOX);
|
||||
assert.deepEqual(
|
||||
{ ...listed(), id: undefined },
|
||||
{ id: undefined, kind: 'cell', count: 1, shown: true, order: 10 },
|
||||
{
|
||||
id: undefined,
|
||||
kind: 'cell',
|
||||
count: 1,
|
||||
shown: true,
|
||||
order: 10,
|
||||
animated: false,
|
||||
},
|
||||
);
|
||||
display.setBoxPreview(null);
|
||||
assert.equal(listed().shown, false);
|
||||
|
||||
@@ -141,6 +141,14 @@ export const HEAT_JAM_BASE_ALPHA = 0.55;
|
||||
|
||||
export const HEAT_JAM_PULSE_ALPHA = 0.2;
|
||||
|
||||
/** @const {number} Ms — jam heat-line pulse period: sin(now / 260 ms), 2π × 260. */
|
||||
|
||||
export const HEAT_JAM_PULSE_PERIOD_MS = 2 * Math.PI * 260;
|
||||
|
||||
/** @const {number} Jam heat-line glow falloff (Cesium PolylineGlow glowPower). */
|
||||
|
||||
export const HEAT_JAM_GLOW_POWER = 0.25;
|
||||
|
||||
/** @const {import('../shared/color.js').Rgba} Jam corridor color (bucket red, alpha pulsed live). */
|
||||
|
||||
export const HEAT_JAM_COLOR = rgbaFromCss('#e05252');
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// distance, eased by ^0.2), and depth-tested against the scene;
|
||||
// - congestion heat lines: two engine.ground corridor sets (jam, then slow),
|
||||
// draped on 3D tiles, as the GroundPolylinePrimitives Cesium drew with
|
||||
// ClassificationType.CESIUM_3D_TILE.
|
||||
// ClassificationType.CESIUM_3D_TILE; a bucket's look (the jam's glow and
|
||||
// pulse) goes to the engine, which animates it on the GPU.
|
||||
// The layer keeps each dot's look as plain data on `dot.point` (position,
|
||||
// pixelSize, color, outlineColor, outlineWidth, show, scaleByDistance,
|
||||
// translucencyByDistance); the display only reads it.
|
||||
@@ -49,7 +50,7 @@ export function nearFarValue(scalar, distance) {
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @returns {{ drawDots: (dots: object[]) => void, show: (visible: boolean) => void,
|
||||
* setHeat: (bucket: 'jam'|'slow', roads: object[], style: { color: number[], widthPx: number }) => void,
|
||||
* setHeat: (bucket: 'jam'|'slow', roads: object[], style: { color: number[], widthPx: number, glow?: object, pulse?: object }) => void,
|
||||
* clearHeat: () => void, excluded: () => object[], destroy: () => void }}
|
||||
*/
|
||||
export function createTrafficDisplay(engine) {
|
||||
@@ -148,8 +149,11 @@ export function createTrafficDisplay(engine) {
|
||||
commit();
|
||||
}
|
||||
},
|
||||
/** One bucket's heat lines: a corridor per road, along its coordinates. */
|
||||
setHeat(bucket, roads, { color: rgba, widthPx }) {
|
||||
/**
|
||||
* One bucket's heat lines: a corridor per road, along its coordinates,
|
||||
* with the bucket's glow and pulse (engine.ground corridor looks).
|
||||
*/
|
||||
setHeat(bucket, roads, { color: rgba, widthPx, glow, pulse }) {
|
||||
if (destroyed) return;
|
||||
dropHeat(bucket);
|
||||
if (!roads.length) return;
|
||||
@@ -161,8 +165,17 @@ export function createTrafficDisplay(engine) {
|
||||
drape: 'tiles',
|
||||
});
|
||||
heat.set(bucket, set);
|
||||
const look = {
|
||||
...(glow ? { glow } : {}),
|
||||
...(pulse ? { pulse } : {}),
|
||||
};
|
||||
set.replace(
|
||||
roads.map((road) => ({ positions: road.coords, color: rgba, widthPx })),
|
||||
roads.map((road) => ({
|
||||
positions: road.coords,
|
||||
color: rgba,
|
||||
widthPx,
|
||||
...look,
|
||||
})),
|
||||
);
|
||||
set.commit();
|
||||
},
|
||||
|
||||
@@ -17,6 +17,9 @@ import { createRendering } from '../rendering.js';
|
||||
import {
|
||||
HEAT_JAM_BASE_ALPHA,
|
||||
HEAT_JAM_COLOR,
|
||||
HEAT_JAM_GLOW_POWER,
|
||||
HEAT_JAM_PULSE_ALPHA,
|
||||
HEAT_JAM_PULSE_PERIOD_MS,
|
||||
HEAT_LINE_JAM_WIDTH,
|
||||
HEAT_LINE_SLOW_WIDTH,
|
||||
HEAT_SLOW_COLOR,
|
||||
@@ -299,7 +302,7 @@ for (const makeHarness of GROUND_HARNESSES) {
|
||||
assert.equal(h.set(IDS.slow), undefined);
|
||||
});
|
||||
|
||||
test(`the layer's heat lines: jam red at its base alpha, slow amber; mono presets in grey (${name})`, (t) => {
|
||||
test(`the layer's heat lines: jam red, glowing and pulsing about its base alpha, slow amber; mono presets in grey (${name})`, (t) => {
|
||||
const { h, display } = fixture(t, makeHarness);
|
||||
const state = createState({ services: {} });
|
||||
Object.assign(state, {
|
||||
@@ -337,10 +340,43 @@ for (const makeHarness of GROUND_HARNESSES) {
|
||||
);
|
||||
nearAll(
|
||||
jam.items[0].color,
|
||||
[...HEAT_JAM_COLOR.rgba().slice(0, 3), HEAT_JAM_BASE_ALPHA],
|
||||
[...HEAT_JAM_COLOR.rgba().slice(0, 3), 1],
|
||||
'jam colour',
|
||||
);
|
||||
assert.equal(jam.items[0].widthPx, HEAT_LINE_JAM_WIDTH);
|
||||
// Cesium's PolylineGlow at glowPower 0.25, its alpha 0.55 ± 0.2 on
|
||||
// sin(now / 260 ms).
|
||||
assert.deepEqual(jam.items[0].glow, { power: HEAT_JAM_GLOW_POWER });
|
||||
assert.equal(HEAT_JAM_GLOW_POWER, 0.25);
|
||||
near(HEAT_JAM_PULSE_PERIOD_MS, 2 * Math.PI * 260, 'pulse period');
|
||||
nearAll(
|
||||
[jam.items[0].pulse.min, jam.items[0].pulse.max],
|
||||
[
|
||||
HEAT_JAM_BASE_ALPHA - HEAT_JAM_PULSE_ALPHA,
|
||||
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA,
|
||||
],
|
||||
'pulse',
|
||||
);
|
||||
assert.equal(jam.items[0].pulse.periodMs, HEAT_JAM_PULSE_PERIOD_MS);
|
||||
assert.equal(slow.items[0].glow, undefined, 'slow lines are flat');
|
||||
assert.equal(slow.items[0].pulse, undefined);
|
||||
assert.equal(
|
||||
h.engine.ground.list().find(({ id }) => id === IDS.jam).animated,
|
||||
true,
|
||||
);
|
||||
if (h.cesium) {
|
||||
// The jam batch draws through the engine's animated material.
|
||||
const [line] = h.primitives(IDS.jam).lines;
|
||||
const { uniforms } = line.options.appearance.material;
|
||||
near(uniforms.glowPower, 0.25, 'glowPower');
|
||||
near(uniforms.pulseMin, 0.35, 'pulse min', 1e-6);
|
||||
near(uniforms.pulseMax, 0.75, 'pulse max', 1e-6);
|
||||
near(uniforms.pulsePeriod, (2 * Math.PI * 260) / 1000, 'pulse period');
|
||||
assert.equal(
|
||||
line.options.classificationType,
|
||||
Cesium.ClassificationType.CESIUM_3D_TILE,
|
||||
);
|
||||
}
|
||||
assert.equal(slow.items.length, 1);
|
||||
nearAll(slow.items[0].color, HEAT_SLOW_COLOR.rgba(), 'slow colour');
|
||||
assert.equal(slow.items[0].widthPx, HEAT_LINE_SLOW_WIDTH);
|
||||
@@ -348,11 +384,7 @@ for (const makeHarness of GROUND_HARNESSES) {
|
||||
// NVG and the other mono presets re-encode in luminance.
|
||||
state._stylePreset = 'surveillance';
|
||||
parts.rendering.rebuildHeatLines(roads);
|
||||
nearAll(
|
||||
h.set(IDS.jam).items[0].color,
|
||||
[1, 1, 1, HEAT_JAM_BASE_ALPHA],
|
||||
'mono jam',
|
||||
);
|
||||
nearAll(h.set(IDS.jam).items[0].color, [1, 1, 1, 1], 'mono jam');
|
||||
nearAll(
|
||||
h.set(IDS.slow).items[0].color,
|
||||
[0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha],
|
||||
|
||||
@@ -30,7 +30,8 @@ test('traffic render demand follows the animated population across empty, disabl
|
||||
assert.deepEqual(calls, [['hold', 'traffic']]);
|
||||
animation.clearDots();
|
||||
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
|
||||
// Heat lines are static ground fills: they alone hold no continuous render.
|
||||
// Heat lines alone hold no continuous render: the jam's pulse keeps frames
|
||||
// coming through engine.ground while it is shown.
|
||||
state._heatLineCount = 3;
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
HEAT_SLOW_COLOR,
|
||||
HEAT_LINE_JAM_WIDTH,
|
||||
HEAT_JAM_BASE_ALPHA,
|
||||
HEAT_JAM_GLOW_POWER,
|
||||
HEAT_JAM_PULSE_ALPHA,
|
||||
HEAT_JAM_PULSE_PERIOD_MS,
|
||||
HEAT_LINE_SLOW_WIDTH,
|
||||
TRAFFIC_TIMING_ENABLED,
|
||||
} from './policy.js';
|
||||
@@ -44,9 +47,9 @@ export function createRendering({
|
||||
|
||||
/**
|
||||
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
|
||||
* slow/jam roads drape a corridor line onto the rendered 3D tiles — red for
|
||||
* jam, faint flat amber for slow — with the dots animating on top. One
|
||||
* display set per bucket. Capped at HEAT_LINE_CAP (jam first, longest
|
||||
* slow/jam roads drape a corridor line onto the rendered 3D tiles — a red
|
||||
* glow pulsing about its base alpha for jam (~1.6 s), faint flat amber for
|
||||
* slow — with the dots animating on top. One display set per bucket. Capped at HEAT_LINE_CAP (jam first, longest
|
||||
* first); overflow is logged. No-op in sim mode, when the heatline mode is
|
||||
* off, or without a display.
|
||||
*
|
||||
@@ -89,8 +92,14 @@ export function createRendering({
|
||||
const roadsFor = (bucket) =>
|
||||
kept.filter((item) => item.bucket === bucket).map((item) => item.road);
|
||||
layerState._display.setHeat('jam', roadsFor('jam'), {
|
||||
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA).rgba(),
|
||||
color: jamLineColor.withAlpha(1).rgba(),
|
||||
widthPx: HEAT_LINE_JAM_WIDTH,
|
||||
glow: { power: HEAT_JAM_GLOW_POWER },
|
||||
pulse: {
|
||||
periodMs: HEAT_JAM_PULSE_PERIOD_MS,
|
||||
min: HEAT_JAM_BASE_ALPHA - HEAT_JAM_PULSE_ALPHA,
|
||||
max: HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA,
|
||||
},
|
||||
});
|
||||
layerState._display.setHeat('slow', roadsFor('slow'), {
|
||||
color: slowLineColor.rgba(),
|
||||
|
||||
Reference in new issue
Block a user