mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(navara): render transit vehicles and ground trails
This commit is contained in:
1 parent
b8560e5b26
commit
5a8bc33fbd
11 files changed
+1254
No files matched your search
@@ -0,0 +1,100 @@
|
|||||||
|
export const TRANSIT_EPOCH = 1_793_232_000_000;
|
||||||
|
const routes = ['1', 'Green-B', 'Red', 'CR-Fitchburg', 'Boat-F1', ''];
|
||||||
|
export function transitSnapshot(step = 8) {
|
||||||
|
const fetchedAt = TRANSIT_EPOCH - 135000 + step * 15000;
|
||||||
|
return {
|
||||||
|
fetchedAt,
|
||||||
|
vehicles: [
|
||||||
|
...Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
id: `w2-${i}`,
|
||||||
|
routeId: routes[i % 6],
|
||||||
|
tripId: `trip-${i}`,
|
||||||
|
lat:
|
||||||
|
42.355 +
|
||||||
|
(Math.floor(i / 4) - 1) * 0.0017 +
|
||||||
|
(i < 6 ? step * 0.00012 : 0),
|
||||||
|
lon: -71.065 + ((i % 4) - 1.5) * 0.0017,
|
||||||
|
timestamp: fetchedAt / 1000,
|
||||||
|
timestampSource: 'vehicle',
|
||||||
|
bearing: 0,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: 'stale',
|
||||||
|
routeId: '1',
|
||||||
|
tripId: 'old',
|
||||||
|
lat: 42.355,
|
||||||
|
lon: -71.065,
|
||||||
|
timestamp: (TRANSIT_EPOCH - 900000) / 1000,
|
||||||
|
timestampSource: 'vehicle',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function installTransitFixture(page) {
|
||||||
|
let step = 0,
|
||||||
|
disposed = false;
|
||||||
|
const requests = [];
|
||||||
|
const session = await page.createCDPSession();
|
||||||
|
await page.evaluateOnNewDocument((epoch) => {
|
||||||
|
Date.now = () => epoch + performance.now();
|
||||||
|
}, TRANSIT_EPOCH);
|
||||||
|
await session.send('Fetch.enable', {
|
||||||
|
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
|
||||||
|
[
|
||||||
|
'/api/transit/*',
|
||||||
|
'/api/flights*',
|
||||||
|
'/api/military*',
|
||||||
|
'/api/vessels*',
|
||||||
|
].map((route) => ({ urlPattern: `http://${host}:*${route}` })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
session.on('Fetch.requestPaused', async (event) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
requests.push(url.pathname);
|
||||||
|
let body;
|
||||||
|
if (url.pathname.startsWith('/api/transit/vehicles/'))
|
||||||
|
body = url.pathname.endsWith('/mbta')
|
||||||
|
? transitSnapshot(step)
|
||||||
|
: { fetchedAt: TRANSIT_EPOCH, vehicles: [] };
|
||||||
|
else if (url.pathname.startsWith('/api/transit/trail/')) {
|
||||||
|
const vehicleId = decodeURIComponent(url.pathname.split('/').at(-1));
|
||||||
|
const record = transitSnapshot().vehicles.find(
|
||||||
|
(row) => row.id === vehicleId,
|
||||||
|
);
|
||||||
|
body = {
|
||||||
|
version: 1,
|
||||||
|
feedId: 'mbta',
|
||||||
|
vehicleId,
|
||||||
|
truncated: false,
|
||||||
|
epochs: [
|
||||||
|
{ id: 1, trip: record.tripId, route: record.routeId, mode: 'bus' },
|
||||||
|
],
|
||||||
|
fixes: Array.from({ length: 9 }, (_, i) => {
|
||||||
|
const snapshot = transitSnapshot(i),
|
||||||
|
row = snapshot.vehicles.find((v) => v.id === vehicleId);
|
||||||
|
return [snapshot.fetchedAt, row.lat, row.lon, 0, 1];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else body = { states: [], ac: [], vessels: [], feeds: [] };
|
||||||
|
await session.send('Fetch.fulfillRequest', {
|
||||||
|
requestId: event.requestId,
|
||||||
|
responseCode: 200,
|
||||||
|
responseHeaders: [{ name: 'Content-Type', value: 'application/json' }],
|
||||||
|
body: Buffer.from(JSON.stringify(body)).toString('base64'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
setStep(value) {
|
||||||
|
step = value;
|
||||||
|
},
|
||||||
|
async dispose() {
|
||||||
|
disposed = true;
|
||||||
|
await session.detach();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -830,6 +830,13 @@
|
|||||||
"src/layers/transit/rendering.js",
|
"src/layers/transit/rendering.js",
|
||||||
"src/layers/transit/render/cesium.js",
|
"src/layers/transit/render/cesium.js",
|
||||||
"src/layers/transit/render/cesium.test.mjs",
|
"src/layers/transit/render/cesium.test.mjs",
|
||||||
|
"src/layers/transit/render/navara.js",
|
||||||
|
"src/layers/transit/render/navaraView.js",
|
||||||
|
"src/layers/transit/render/navaraSprites.js",
|
||||||
|
"src/layers/transit/render/navaraTrail.js",
|
||||||
|
"src/layers/transit/render/navara.test.mjs",
|
||||||
|
"scripts/fixtures/w2/transit.mjs",
|
||||||
|
"scripts/qa-w2-transit.mjs",
|
||||||
"src/layers/transit/selection.js",
|
"src/layers/transit/selection.js",
|
||||||
"src/layers/transit/state.js",
|
"src/layers/transit/state.js",
|
||||||
"src/layers/transit/testing.js",
|
"src/layers/transit/testing.js",
|
||||||
|
|||||||
@@ -2706,6 +2706,10 @@
|
|||||||
"src/layers/radio/render/cesium.js",
|
"src/layers/radio/render/cesium.js",
|
||||||
"src/layers/firms/render/navara.js",
|
"src/layers/firms/render/navara.js",
|
||||||
"src/layers/radio/render/navara.js",
|
"src/layers/radio/render/navara.js",
|
||||||
|
"src/layers/transit/render/navara.js",
|
||||||
|
"src/layers/transit/render/navaraSprites.js",
|
||||||
|
"src/layers/transit/render/navaraView.js",
|
||||||
|
"src/layers/transit/render/navaraTrail.js",
|
||||||
"src/layers/cctv/render/navara.js",
|
"src/layers/cctv/render/navara.js",
|
||||||
"src/layers/cctv/render/navaraMeshes.js",
|
"src/layers/cctv/render/navaraMeshes.js",
|
||||||
"src/layers/cctv/render/navaraGizmo.js",
|
"src/layers/cctv/render/navaraGizmo.js",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'radio',
|
'radio',
|
||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
'cctv',
|
'cctv',
|
||||||
|
'transit',
|
||||||
])
|
])
|
||||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -47,6 +48,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
'cctv',
|
'cctv',
|
||||||
'radio',
|
'radio',
|
||||||
|
'transit',
|
||||||
'bikeshare',
|
'bikeshare',
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ const NAVARA_CREDIT_KEYS = new Set([
|
|||||||
*/
|
*/
|
||||||
export async function registerNavaraLayerRenderers() {
|
export async function registerNavaraLayerRenderers() {
|
||||||
const loaders = {
|
const loaders = {
|
||||||
|
transit: async () => {
|
||||||
|
const { createTransitNavaraRenderer } =
|
||||||
|
await import('../layers/transit/render/navara.js');
|
||||||
|
return (engine, options) =>
|
||||||
|
createTransitNavaraRenderer(engine, {
|
||||||
|
...options,
|
||||||
|
...navaraViews.get(engine),
|
||||||
|
});
|
||||||
|
},
|
||||||
cctv: async () => {
|
cctv: async () => {
|
||||||
const { createCctvNavaraRenderer } =
|
const { createCctvNavaraRenderer } =
|
||||||
await import('../layers/cctv/render/navara.js');
|
await import('../layers/cctv/render/navara.js');
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import {
|
||||||
|
claimCameraSensitivity,
|
||||||
|
releaseCameraSensitivity,
|
||||||
|
} from '../../../data/cameraSensitivity.js';
|
||||||
|
import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js';
|
||||||
|
import { createTransitSpriteBanks } from './navaraSprites.js';
|
||||||
|
import { createTransitNavaraView } from './navaraView.js';
|
||||||
|
import { createTransitNavaraTrail } from './navaraTrail.js';
|
||||||
|
|
||||||
|
/** Navara resources only; ingestion, playback and selection stay shared. */
|
||||||
|
export function createTransitNavaraRenderer(
|
||||||
|
engine,
|
||||||
|
{ state, services, drawAtlas, ...native } = {},
|
||||||
|
) {
|
||||||
|
let destroyed = false,
|
||||||
|
attached = false,
|
||||||
|
sprites = null;
|
||||||
|
const removers = new Set(),
|
||||||
|
credits = new Map(),
|
||||||
|
trails = new Set();
|
||||||
|
const view = createTransitNavaraView(engine);
|
||||||
|
const own = (remove) => {
|
||||||
|
const release = () => {
|
||||||
|
if (removers.delete(release)) remove();
|
||||||
|
};
|
||||||
|
removers.add(release);
|
||||||
|
return release;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
attach() {
|
||||||
|
attached = true;
|
||||||
|
},
|
||||||
|
get ready() {
|
||||||
|
return attached && !destroyed;
|
||||||
|
},
|
||||||
|
createMarkers() {
|
||||||
|
sprites = createTransitSpriteBanks(engine, { drawAtlas });
|
||||||
|
return sprites.collections;
|
||||||
|
},
|
||||||
|
whenReady: () => sprites?.ready,
|
||||||
|
restoreOrder() {},
|
||||||
|
cameraCartographic: view.cameraCartographic,
|
||||||
|
cameraSignature: view.cameraSignature,
|
||||||
|
rotation: view.rotation,
|
||||||
|
visibilityTester: view.visibilityTester,
|
||||||
|
viewRectangle: () => engine.camera.viewRectangle(),
|
||||||
|
claimCamera: () =>
|
||||||
|
claimCameraSensitivity(
|
||||||
|
engine.camera,
|
||||||
|
'transit',
|
||||||
|
CAMERA_PERCENTAGE_CHANGED,
|
||||||
|
),
|
||||||
|
releaseCamera: () => releaseCameraSensitivity(engine.camera, 'transit'),
|
||||||
|
onCameraChanged: (fn) => own(engine.camera.changed.addEventListener(fn)),
|
||||||
|
onPreRender: (fn) =>
|
||||||
|
own(
|
||||||
|
engine.frames.preRender.addEventListener((...args) => {
|
||||||
|
view.refresh();
|
||||||
|
sprites.inFrame(() => fn(...args));
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onPostRender: (fn) => own(engine.frames.postRender.addEventListener(fn)),
|
||||||
|
terrainHeight(lat, lon) {
|
||||||
|
// Mesh sampling keeps the application's existing budgets and cache.
|
||||||
|
if (engine.maps.current().photoreal) return undefined;
|
||||||
|
return engine.surface.terrainHeight(
|
||||||
|
geo.cartographic.fromDegrees(lon, lat),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sampleMeshFloorCells: (points, options) =>
|
||||||
|
services.mesh?.sampleMeshFloorCells?.(engine, points, options),
|
||||||
|
addCredit(feed) {
|
||||||
|
if (credits.has(feed.id)) return;
|
||||||
|
credits.set(
|
||||||
|
feed.id,
|
||||||
|
engine.credits.addStatic(services.credits.transitFeedCredit(feed).html),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createTrailRenderer() {
|
||||||
|
const trail = createTransitNavaraTrail(engine, native);
|
||||||
|
trails.add(trail);
|
||||||
|
const destroy = trail.destroy;
|
||||||
|
trail.destroy = () => {
|
||||||
|
trails.delete(trail);
|
||||||
|
destroy();
|
||||||
|
};
|
||||||
|
return trail;
|
||||||
|
},
|
||||||
|
bindClick(listener, pointerFree) {
|
||||||
|
const remove = own(
|
||||||
|
engine.input.on('click', (event) => {
|
||||||
|
if (!pointerFree()) return;
|
||||||
|
const picked = engine.picker.pick(event.position);
|
||||||
|
const id = typeof picked?.id === 'string' ? picked.id : null;
|
||||||
|
const entry = state._vehicles.get(id);
|
||||||
|
listener({
|
||||||
|
id,
|
||||||
|
primitiveId: null,
|
||||||
|
x: event.position.x,
|
||||||
|
y: event.position.y,
|
||||||
|
collection: entry
|
||||||
|
? entry.markerCollection === state._animatedMarkers
|
||||||
|
? 'animated'
|
||||||
|
: 'stationary'
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { destroy: remove };
|
||||||
|
},
|
||||||
|
frameProbe() {
|
||||||
|
// Public engine API has no per-layer GPU upload hook. Count all scene
|
||||||
|
// uploads as an explicit upper bound, never misreport unavailable as zero.
|
||||||
|
return {
|
||||||
|
canvas: engine.host.canvas,
|
||||||
|
gl: engine.host.canvas.getContext('webgl2'),
|
||||||
|
uploads: [],
|
||||||
|
allUploads: true,
|
||||||
|
uploadScope: 'scene-upper-bound',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
for (const remove of [...removers]) remove();
|
||||||
|
this.releaseCamera();
|
||||||
|
for (const trail of [...trails]) trail.destroy();
|
||||||
|
sprites?.destroy();
|
||||||
|
for (const remove of credits.values()) remove();
|
||||||
|
credits.clear();
|
||||||
|
attached = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import { createTransitSpriteBanks } from './navaraSprites.js';
|
||||||
|
import { createTransitNavaraView } from './navaraView.js';
|
||||||
|
import { perspectiveProjectedRotation } from '../../../data/iconOrientation.js';
|
||||||
|
import { createTransitNavaraTrail, transitTrailFrame } from './navaraTrail.js';
|
||||||
|
|
||||||
|
const atlas = { canvas: {}, cells: { bus: [0, 0, 1, 1], tram: [0, 0, 1, 1] } };
|
||||||
|
function moverEngine() {
|
||||||
|
const sets = [];
|
||||||
|
return {
|
||||||
|
sets,
|
||||||
|
movers: {
|
||||||
|
createSet(options) {
|
||||||
|
const set = {
|
||||||
|
options,
|
||||||
|
rows: [],
|
||||||
|
commits: 0,
|
||||||
|
disposed: 0,
|
||||||
|
set(i, fields) {
|
||||||
|
this.rows[i] ??= {};
|
||||||
|
for (const [key, value] of Object.entries(fields))
|
||||||
|
if (value !== undefined)
|
||||||
|
this.rows[i][key] = structuredClone(value);
|
||||||
|
},
|
||||||
|
setCount(value) {
|
||||||
|
this.count = value;
|
||||||
|
},
|
||||||
|
commit() {
|
||||||
|
this.commits++;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
this.disposed++;
|
||||||
|
},
|
||||||
|
ready: () => true,
|
||||||
|
};
|
||||||
|
sets.push(set);
|
||||||
|
return set;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const spec = (id) => ({
|
||||||
|
id,
|
||||||
|
image: 'bus',
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
position: { x: 1, y: 2, z: 3 },
|
||||||
|
});
|
||||||
|
test('moving bank commits do not upload the static fleet; equal setters are idle', async () => {
|
||||||
|
const engine = moverEngine(),
|
||||||
|
banks = createTransitSpriteBanks(engine, { drawAtlas: async () => atlas });
|
||||||
|
await banks.ready;
|
||||||
|
const [stationary, moving] = banks.collections;
|
||||||
|
stationary.show = moving.show = true;
|
||||||
|
for (let i = 0; i < 800; i++) stationary.add(spec(`static-${i}`));
|
||||||
|
const source = spec('moving'),
|
||||||
|
marker = moving.add(source);
|
||||||
|
banks.flush();
|
||||||
|
const [staticSet, movingSet] = engine.sets;
|
||||||
|
const initial = staticSet.commits;
|
||||||
|
source.position.x = 99;
|
||||||
|
assert.equal(marker.position.x, 1, 'creation copies mutable coordinates');
|
||||||
|
const scratch = { x: 4, y: 5, z: 6 };
|
||||||
|
banks.inFrame(() => {
|
||||||
|
marker.position = scratch;
|
||||||
|
marker.rotation = 0.3;
|
||||||
|
});
|
||||||
|
scratch.x = 100;
|
||||||
|
assert.equal(marker.position.x, 4, 'assignment copies mutable coordinates');
|
||||||
|
assert.equal(staticSet.commits, initial);
|
||||||
|
const count = movingSet.commits;
|
||||||
|
banks.inFrame(() => {
|
||||||
|
marker.position = { x: 4, y: 5, z: 6 };
|
||||||
|
marker.rotation = 0.3;
|
||||||
|
marker.show = true;
|
||||||
|
marker.width = 20;
|
||||||
|
marker.color = marker.color.clone();
|
||||||
|
});
|
||||||
|
assert.equal(movingSet.commits, count);
|
||||||
|
assert.equal(staticSet.count, 800);
|
||||||
|
assert.equal(movingSet.count, 1);
|
||||||
|
const migrated = stationary.add({
|
||||||
|
...spec('moving'),
|
||||||
|
position: marker.position,
|
||||||
|
rotation: marker.rotation,
|
||||||
|
});
|
||||||
|
moving.remove(marker);
|
||||||
|
banks.flush();
|
||||||
|
assert.equal(migrated.id, marker.id);
|
||||||
|
assert.deepEqual(migrated.position, marker.position);
|
||||||
|
assert.equal(movingSet.count, 0);
|
||||||
|
banks.destroy();
|
||||||
|
banks.destroy();
|
||||||
|
assert.ok(engine.sets.every((set) => set.disposed === 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late atlas cannot resurrect destroyed transit banks', async () => {
|
||||||
|
let resolve;
|
||||||
|
const engine = moverEngine();
|
||||||
|
const banks = createTransitSpriteBanks(engine, {
|
||||||
|
drawAtlas: () =>
|
||||||
|
new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
banks.collections[0].add(spec('late'));
|
||||||
|
await Promise.resolve();
|
||||||
|
banks.destroy();
|
||||||
|
resolve(atlas);
|
||||||
|
await banks.ready;
|
||||||
|
assert.equal(engine.sets.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navara course projection agrees with native across near, far and oblique cameras', () => {
|
||||||
|
const canvas = {
|
||||||
|
clientWidth: 1280,
|
||||||
|
clientHeight: 800,
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
};
|
||||||
|
const camera = new Cesium.Camera({
|
||||||
|
canvas,
|
||||||
|
drawingBufferWidth: 1280,
|
||||||
|
drawingBufferHeight: 800,
|
||||||
|
mapProjection: new Cesium.GeographicProjection(),
|
||||||
|
mode: Cesium.SceneMode.SCENE3D,
|
||||||
|
});
|
||||||
|
const scene = {
|
||||||
|
camera,
|
||||||
|
canvas,
|
||||||
|
drawingBufferWidth: 1280,
|
||||||
|
drawingBufferHeight: 800,
|
||||||
|
mapProjection: new Cesium.GeographicProjection(),
|
||||||
|
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||||
|
};
|
||||||
|
const engine = {
|
||||||
|
camera: {
|
||||||
|
pose: () => ({
|
||||||
|
position: camera.positionWC,
|
||||||
|
cartographic: camera.positionCartographic,
|
||||||
|
direction: camera.directionWC,
|
||||||
|
right: camera.rightWC,
|
||||||
|
up: camera.upWC,
|
||||||
|
heading: camera.heading,
|
||||||
|
pitch: camera.pitch,
|
||||||
|
roll: camera.roll,
|
||||||
|
fovy: camera.frustum.fovy,
|
||||||
|
aspectRatio: camera.frustum.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
projector: {
|
||||||
|
worldToWindow: (point, out) =>
|
||||||
|
Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, out),
|
||||||
|
horizonVisible: () => true,
|
||||||
|
},
|
||||||
|
host: { size: () => ({ width: 1280, height: 800 }) },
|
||||||
|
};
|
||||||
|
for (const height of [600, 20000, 1000000])
|
||||||
|
for (const heading of [0, 1.2, 3.1]) {
|
||||||
|
camera.setView({
|
||||||
|
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, height),
|
||||||
|
orientation: { heading, pitch: -0.8, roll: 0 },
|
||||||
|
});
|
||||||
|
const view = createTransitNavaraView(engine);
|
||||||
|
for (const course of [0, 90, 180, 270]) {
|
||||||
|
const position = Cesium.Cartesian3.fromDegrees(-97.7401, 30.2682, 10);
|
||||||
|
const expected = perspectiveProjectedRotation(
|
||||||
|
scene,
|
||||||
|
position,
|
||||||
|
course,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(view.rotation(position, course, 0) - expected) < 1e-10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ground trail updates clip uniforms without replacing corridor textures and releases them', () => {
|
||||||
|
const created = [],
|
||||||
|
requests = [];
|
||||||
|
const view = {
|
||||||
|
registerEffect() {},
|
||||||
|
addEffect(config) {
|
||||||
|
const handle = {
|
||||||
|
config,
|
||||||
|
updates: [],
|
||||||
|
removed: 0,
|
||||||
|
update(v) {
|
||||||
|
this.updates.push(v);
|
||||||
|
},
|
||||||
|
delete() {
|
||||||
|
this.removed++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
created.push(handle);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const engine = {
|
||||||
|
host: { canvas: { width: 1280 }, size: () => ({ width: 1280 }) },
|
||||||
|
render: { request: (reason) => requests.push(reason) },
|
||||||
|
};
|
||||||
|
const trail = createTransitNavaraTrail(engine, {
|
||||||
|
view,
|
||||||
|
EffectDesc: class {},
|
||||||
|
NavaraEffect: class {},
|
||||||
|
});
|
||||||
|
const segment = {
|
||||||
|
fromSeq: 1,
|
||||||
|
toSeq: 2,
|
||||||
|
fromT: 0,
|
||||||
|
toT: 15000,
|
||||||
|
positions: [
|
||||||
|
geo.cartesian3.fromDegrees(-97.7431, 30.2672, 10),
|
||||||
|
geo.cartesian3.fromDegrees(-97.7431, 30.2674, 30),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const frame = transitTrailFrame(segment);
|
||||||
|
assert.ok(frame.length > 20 && frame.length < 25);
|
||||||
|
assert.ok(Math.abs(geo.cartesian3.dot(frame.up, frame.east)) < 1e-9);
|
||||||
|
trail.replaceHistory({ revision: 1, segments: [segment] });
|
||||||
|
const owner = created[0].config.gevTransitGround.owner;
|
||||||
|
const texture = owner.state.packed.texture;
|
||||||
|
let disposed = 0;
|
||||||
|
texture.addEventListener('dispose', () => disposed++);
|
||||||
|
for (let i = 0; i < 100; i++)
|
||||||
|
trail.setDisplaySample(
|
||||||
|
{ displayT: i * 100, fromSeq: 1, toSeq: 2, fraction: i / 150 },
|
||||||
|
segment.positions[0],
|
||||||
|
);
|
||||||
|
assert.equal(owner.state.packed.texture, texture);
|
||||||
|
assert.equal(trail.diagnostics().rebuilds, 1);
|
||||||
|
assert.equal(owner.active, 0);
|
||||||
|
trail.setDisplaySample(
|
||||||
|
{ displayT: 16000, fromSeq: 2, toSeq: 2, fraction: 1 },
|
||||||
|
segment.positions[1],
|
||||||
|
);
|
||||||
|
assert.equal(owner.active, -1);
|
||||||
|
trail.destroy();
|
||||||
|
trail.destroy();
|
||||||
|
assert.equal(disposed, 1);
|
||||||
|
assert.equal(created[0].removed, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import { Rgba } from '../../shared/color.js';
|
||||||
|
import { createMoverBank } from '../../aircraft/render/navara.js';
|
||||||
|
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||||
|
import { TRANSIT_ICON_KINDS, transitIcon } from '../../../data/transitIcons.js';
|
||||||
|
|
||||||
|
/** The same bounded mode/selection/style rasters used by native billboards. */
|
||||||
|
export async function drawTransitAtlas() {
|
||||||
|
const urls = [
|
||||||
|
...new Set(
|
||||||
|
TRANSIT_ICON_KINDS.flatMap((mode) =>
|
||||||
|
['normal', 'thermal', 'retro'].flatMap((style) =>
|
||||||
|
[48, 96].map((px) => transitIcon(mode, px, { style })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const images = await Promise.all(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = url;
|
||||||
|
await image.decode();
|
||||||
|
return image;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cell = Math.max(...images.map((image) => image.naturalWidth)) + 4;
|
||||||
|
const columns = Math.ceil(Math.sqrt(images.length));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = columns * cell;
|
||||||
|
canvas.height = Math.ceil(images.length / columns) * cell;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) throw new Error('Transit atlas requires a canvas');
|
||||||
|
const cells = {};
|
||||||
|
images.forEach((image, i) => {
|
||||||
|
const x = (i % columns) * cell + 2,
|
||||||
|
y = Math.floor(i / columns) * cell + 2;
|
||||||
|
context.drawImage(image, x, y);
|
||||||
|
cells[urls[i]] = [
|
||||||
|
x / canvas.width,
|
||||||
|
y / canvas.height,
|
||||||
|
(x + image.naturalWidth) / canvas.width,
|
||||||
|
(y + image.naturalHeight) / canvas.height,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return { canvas, cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two independently dirty banks: moving contacts never upload the static fleet. */
|
||||||
|
export function createTransitSpriteBanks(
|
||||||
|
engine,
|
||||||
|
{ drawAtlas = drawTransitAtlas } = {},
|
||||||
|
) {
|
||||||
|
let destroyed = false,
|
||||||
|
atlas = null,
|
||||||
|
queued = false,
|
||||||
|
inFrame = 0;
|
||||||
|
const pending = new Set();
|
||||||
|
const owners = [];
|
||||||
|
const api = {
|
||||||
|
flush() {
|
||||||
|
queued = false;
|
||||||
|
if (destroyed) return;
|
||||||
|
for (const bank of pending) bank.commit();
|
||||||
|
pending.clear();
|
||||||
|
},
|
||||||
|
inFrame(run) {
|
||||||
|
inFrame++;
|
||||||
|
try {
|
||||||
|
return run();
|
||||||
|
} finally {
|
||||||
|
if (--inFrame === 0) api.flush();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
for (const owner of owners) owner.destroy();
|
||||||
|
pending.clear();
|
||||||
|
atlas = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const schedule = (bank) => {
|
||||||
|
pending.add(bank);
|
||||||
|
if (!queued && !inFrame && !destroyed) {
|
||||||
|
queued = true;
|
||||||
|
queueMicrotask(() => api.flush());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function collection(id) {
|
||||||
|
const handles = new Set();
|
||||||
|
let bank = null,
|
||||||
|
visible = false;
|
||||||
|
const rgba = [1, 1, 1, 1];
|
||||||
|
const colorValues = (color) => {
|
||||||
|
rgba[0] = color.red;
|
||||||
|
rgba[1] = color.green;
|
||||||
|
rgba[2] = color.blue;
|
||||||
|
rgba[3] = color.alpha;
|
||||||
|
return rgba;
|
||||||
|
};
|
||||||
|
const fields = {
|
||||||
|
position: null,
|
||||||
|
rotation: undefined,
|
||||||
|
scale: undefined,
|
||||||
|
color: undefined,
|
||||||
|
show: undefined,
|
||||||
|
cell: undefined,
|
||||||
|
};
|
||||||
|
const update = (handle, key, value) => {
|
||||||
|
if (!bank || destroyed || !handles.has(handle)) return;
|
||||||
|
fields[key] = value;
|
||||||
|
bank.update(handle, fields);
|
||||||
|
fields[key] = undefined;
|
||||||
|
};
|
||||||
|
fields.position = undefined;
|
||||||
|
const ensure = () => {
|
||||||
|
if (bank || !atlas || destroyed) return;
|
||||||
|
bank = createMoverBank(engine, {
|
||||||
|
options: (capacity, generation) => ({
|
||||||
|
id: `${id}-${generation}`,
|
||||||
|
kind: 'sprite',
|
||||||
|
capacity,
|
||||||
|
order: SPRITE_LAYER_ORDER.indexOf(id),
|
||||||
|
depthTest: 'always',
|
||||||
|
pickable: true,
|
||||||
|
sprite: { atlas, sizePx: 20 },
|
||||||
|
}),
|
||||||
|
write: (h, set, index) =>
|
||||||
|
set.set(index, {
|
||||||
|
position: h.position,
|
||||||
|
rotation: h.rotation,
|
||||||
|
scale: h.width / 20,
|
||||||
|
color: colorValues(h.color),
|
||||||
|
cell: h.image,
|
||||||
|
show: h.show,
|
||||||
|
pickId: h.id,
|
||||||
|
}),
|
||||||
|
onCommit: schedule,
|
||||||
|
});
|
||||||
|
bank.setVisible(visible);
|
||||||
|
for (const handle of handles) bank.add(handle);
|
||||||
|
};
|
||||||
|
const owner = {
|
||||||
|
ensure,
|
||||||
|
destroy() {
|
||||||
|
handles.clear();
|
||||||
|
bank?.destroy();
|
||||||
|
bank = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
owners.push(owner);
|
||||||
|
return {
|
||||||
|
get show() {
|
||||||
|
return visible;
|
||||||
|
},
|
||||||
|
set show(value) {
|
||||||
|
const next = Boolean(value);
|
||||||
|
if (destroyed || next === visible) return;
|
||||||
|
visible = next;
|
||||||
|
bank?.setVisible(next);
|
||||||
|
},
|
||||||
|
get length() {
|
||||||
|
return handles.size;
|
||||||
|
},
|
||||||
|
get: (index) => bank?.handles[index] ?? [...handles][index],
|
||||||
|
contains: (handle) => handles.has(handle),
|
||||||
|
add(spec) {
|
||||||
|
if (destroyed) throw new Error('Transit sprite banks are destroyed');
|
||||||
|
let position = geo.cartesian3.clone(spec.position),
|
||||||
|
image = spec.image,
|
||||||
|
show = spec.show !== false,
|
||||||
|
rotation = spec.rotation ?? 0,
|
||||||
|
width = spec.width ?? 20,
|
||||||
|
height = spec.height ?? width;
|
||||||
|
let color = new Rgba(
|
||||||
|
spec.color?.red,
|
||||||
|
spec.color?.green,
|
||||||
|
spec.color?.blue,
|
||||||
|
spec.color?.alpha,
|
||||||
|
);
|
||||||
|
const handle = {
|
||||||
|
id: spec.id,
|
||||||
|
gevBank: null,
|
||||||
|
gevIndex: -1,
|
||||||
|
alignedAxis: geo.cartesian3.ZERO,
|
||||||
|
disableDepthTestDistance: Infinity,
|
||||||
|
get position() {
|
||||||
|
return position;
|
||||||
|
},
|
||||||
|
set position(value) {
|
||||||
|
if (destroyed || geo.cartesian3.equals(position, value)) return;
|
||||||
|
geo.cartesian3.clone(value, position);
|
||||||
|
update(handle, 'position', position);
|
||||||
|
},
|
||||||
|
get image() {
|
||||||
|
return image;
|
||||||
|
},
|
||||||
|
set image(value) {
|
||||||
|
if (destroyed || image === value) return;
|
||||||
|
image = value;
|
||||||
|
update(handle, 'cell', image);
|
||||||
|
},
|
||||||
|
get show() {
|
||||||
|
return show;
|
||||||
|
},
|
||||||
|
set show(value) {
|
||||||
|
const next = Boolean(value);
|
||||||
|
if (destroyed || show === next) return;
|
||||||
|
show = next;
|
||||||
|
update(handle, 'show', show);
|
||||||
|
},
|
||||||
|
get rotation() {
|
||||||
|
return rotation;
|
||||||
|
},
|
||||||
|
set rotation(value) {
|
||||||
|
if (destroyed || rotation === value) return;
|
||||||
|
rotation = value;
|
||||||
|
update(handle, 'rotation', rotation);
|
||||||
|
},
|
||||||
|
get width() {
|
||||||
|
return width;
|
||||||
|
},
|
||||||
|
set width(value) {
|
||||||
|
if (destroyed || width === value) return;
|
||||||
|
width = value;
|
||||||
|
update(handle, 'scale', width / 20);
|
||||||
|
},
|
||||||
|
get height() {
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
set height(value) {
|
||||||
|
height = value;
|
||||||
|
},
|
||||||
|
get color() {
|
||||||
|
return color;
|
||||||
|
},
|
||||||
|
set color(value) {
|
||||||
|
if (destroyed || color.equals(value)) return;
|
||||||
|
color = new Rgba(value.red, value.green, value.blue, value.alpha);
|
||||||
|
update(handle, 'color', colorValues(color));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handles.add(handle);
|
||||||
|
ensure();
|
||||||
|
bank?.add(handle);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
remove(handle) {
|
||||||
|
if (!handles.delete(handle)) return false;
|
||||||
|
bank?.remove(handle);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
removeAll() {
|
||||||
|
for (const handle of handles) bank?.remove(handle);
|
||||||
|
handles.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
api.collections = [collection('transit'), collection('transit-motion')];
|
||||||
|
api.ready = Promise.resolve()
|
||||||
|
.then(drawAtlas)
|
||||||
|
.then((drawn) => {
|
||||||
|
if (destroyed) return;
|
||||||
|
atlas = drawn;
|
||||||
|
for (const owner of owners) owner.ensure();
|
||||||
|
api.flush();
|
||||||
|
});
|
||||||
|
// Keep an early decode failure observed even before a caller awaits ready.
|
||||||
|
api.ready.catch(() => {
|
||||||
|
if (!destroyed) console.error('Transit sprite atlas failed');
|
||||||
|
});
|
||||||
|
return api;
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { Effect, EffectAttribute } from 'postprocessing';
|
||||||
|
import { Uniform } from 'three';
|
||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import { rgbaFromCss } from '../../shared/color.js';
|
||||||
|
import {
|
||||||
|
createGroundStorage,
|
||||||
|
createGroundTexture,
|
||||||
|
groundSurfaceUniforms,
|
||||||
|
updateGroundSurface,
|
||||||
|
} from '../../shared/render/navaraGroundSurface.js';
|
||||||
|
import {
|
||||||
|
createGroundGrid,
|
||||||
|
GRID_COLS,
|
||||||
|
GRID_ROWS,
|
||||||
|
} from '../../shared/render/navaraGroundGrid.js';
|
||||||
|
import {
|
||||||
|
viewFrame,
|
||||||
|
groundClipBounds,
|
||||||
|
} from '../../shared/render/navaraGroundGeometry.js';
|
||||||
|
|
||||||
|
const KEY = 'gevTransitGround',
|
||||||
|
registered = new WeakSet();
|
||||||
|
const shader = /* glsl */ `
|
||||||
|
uniform sampler2D tAllDepth,tSurfaceDepth,tSegments,tGrid,tCandidates;
|
||||||
|
uniform vec2 uSegmentSize,uCandidateSize;
|
||||||
|
uniform mat4 uInverseProjection;
|
||||||
|
uniform float uLogDepthFC,uDisplayT,uActive,uPixelRatio;
|
||||||
|
uniform vec3 uColor;
|
||||||
|
vec4 slot(sampler2D image,vec2 size,float index) {
|
||||||
|
return texture2D(image,(vec2(mod(index,size.x),floor(index/size.x))+0.5)/size);
|
||||||
|
}
|
||||||
|
vec3 displayColor(vec3 c) {
|
||||||
|
return mix(c*12.92,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
|
||||||
|
}
|
||||||
|
vec3 linearColor(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));
|
||||||
|
}
|
||||||
|
float ageAlpha(float age) {
|
||||||
|
if(age<120000.0) return mix(0.8,0.45,clamp(age/120000.0,0.0,1.0));
|
||||||
|
if(age<600000.0) return mix(0.45,0.2,(age-120000.0)/480000.0);
|
||||||
|
if(age<900000.0) return mix(0.2,0.08,(age-600000.0)/300000.0);
|
||||||
|
return 0.08;
|
||||||
|
}
|
||||||
|
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
|
||||||
|
outputColor=inputColor;
|
||||||
|
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
|
||||||
|
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
|
||||||
|
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
|
||||||
|
vec3 point=ray.xyz*(distance/-ray.z);
|
||||||
|
vec3 dx=dFdx(point),dy=dFdy(point);
|
||||||
|
float onSurface=1.0-step(1.0,depth);
|
||||||
|
float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface)));
|
||||||
|
if(depth>=1.0 || derivativeMask<0.5) return;
|
||||||
|
if(depth<texture2D(tSurfaceDepth,uv).r-2e-7) return;
|
||||||
|
vec2 tile=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0));
|
||||||
|
vec2 range=texture2D(tGrid,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy;
|
||||||
|
vec3 color=displayColor(inputColor.rgb);
|
||||||
|
for(int k=0;k<int(range.y);k++) {
|
||||||
|
float id=slot(tCandidates,uCandidateSize,range.x+float(k)).x;
|
||||||
|
vec4 center=slot(tSegments,uSegmentSize,id*4.0);
|
||||||
|
vec4 along=slot(tSegments,uSegmentSize,id*4.0+1.0);
|
||||||
|
vec4 across=slot(tSegments,uSegmentSize,id*4.0+2.0);
|
||||||
|
vec4 times=slot(tSegments,uSegmentSize,id*4.0+3.0);
|
||||||
|
bool completed=times.y<=uDisplayT;
|
||||||
|
bool isHead=abs(id-uActive)<0.5;
|
||||||
|
if(!completed&&!isHead) continue;
|
||||||
|
float fraction=completed?1.0:clamp((uDisplayT-times.x)/(times.y-times.x),0.0,1.0);
|
||||||
|
vec3 delta=point-center.xyz;
|
||||||
|
float s=dot(delta,along.xyz)+center.w;
|
||||||
|
if(s<0.0||s>center.w*2.0*fraction) continue;
|
||||||
|
float metresPerPixel=length(vec2(dot(across.xyz,dx),dot(across.xyz,dy)));
|
||||||
|
float lateral=abs(dot(delta,across.xyz))/max(metresPerPixel,1e-6);
|
||||||
|
float coverage=clamp(2.5*uPixelRatio+0.5-lateral,0.0,1.0);
|
||||||
|
if(coverage<=0.0) continue;
|
||||||
|
float core=clamp(1.5*uPixelRatio+0.5-lateral,0.0,1.0);
|
||||||
|
float alpha=isHead?0.8:ageAlpha(uDisplayT-times.y);
|
||||||
|
color=mix(color,mix(vec3(0.02,0.03,0.05),uColor,core),alpha*coverage);
|
||||||
|
}
|
||||||
|
outputColor=vec4(linearColor(color),inputColor.a);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Short prepared corridors are extruded along the geodetic normal. */
|
||||||
|
export function transitTrailFrame(segment) {
|
||||||
|
const [a, b] = segment.positions;
|
||||||
|
const center = geo.cartesian3.midpoint(a, b, geo.cartesian3.create());
|
||||||
|
const up = geo.ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||||
|
center,
|
||||||
|
geo.cartesian3.create(),
|
||||||
|
);
|
||||||
|
const delta = geo.cartesian3.subtract(b, a, geo.cartesian3.create());
|
||||||
|
const vertical = geo.cartesian3.dot(delta, up);
|
||||||
|
delta.x -= up.x * vertical;
|
||||||
|
delta.y -= up.y * vertical;
|
||||||
|
delta.z -= up.z * vertical;
|
||||||
|
const length = geo.cartesian3.magnitude(delta);
|
||||||
|
const east = geo.cartesian3.normalize(delta, delta);
|
||||||
|
const north = geo.cartesian3.cross(up, east, geo.cartesian3.create());
|
||||||
|
return {
|
||||||
|
center,
|
||||||
|
east,
|
||||||
|
north,
|
||||||
|
up,
|
||||||
|
length,
|
||||||
|
radius: length / 2,
|
||||||
|
floor: -12000,
|
||||||
|
ceiling: 20000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storage(segments) {
|
||||||
|
const frames = segments.map(transitTrailFrame);
|
||||||
|
const packed = createGroundStorage(segments.length * 4);
|
||||||
|
const grid = createGroundGrid(segments.length);
|
||||||
|
const gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
|
||||||
|
const gridTexture = createGroundTexture(gridData, GRID_COLS, GRID_ROWS);
|
||||||
|
let candidates = createGroundStorage(1);
|
||||||
|
const matrix = new Float64Array(32).fill(NaN),
|
||||||
|
scratch = new Float64Array(12),
|
||||||
|
bounds = new Float64Array(4);
|
||||||
|
let width = 0,
|
||||||
|
height = 0;
|
||||||
|
const epoch = segments[0]?.fromT ?? 0;
|
||||||
|
return {
|
||||||
|
packed,
|
||||||
|
gridTexture,
|
||||||
|
epoch,
|
||||||
|
get candidates() {
|
||||||
|
return candidates;
|
||||||
|
},
|
||||||
|
update(camera, context) {
|
||||||
|
const input = context.getInputBuffer(),
|
||||||
|
v = camera.matrixWorldInverse.elements,
|
||||||
|
p = camera.projectionMatrix.elements;
|
||||||
|
let changed = width !== input.width || height !== input.height;
|
||||||
|
for (let i = 0; i < 16 && !changed; i++)
|
||||||
|
changed = matrix[i] !== v[i] || matrix[i + 16] !== p[i];
|
||||||
|
if (!changed) return;
|
||||||
|
matrix.set(v);
|
||||||
|
matrix.set(p, 16);
|
||||||
|
width = input.width;
|
||||||
|
height = input.height;
|
||||||
|
frames.forEach((frame, i) => {
|
||||||
|
viewFrame(frame, camera.matrixWorldInverse, scratch);
|
||||||
|
const offset = i * 16,
|
||||||
|
segment = segments[i];
|
||||||
|
packed.data.set(
|
||||||
|
[
|
||||||
|
scratch[0],
|
||||||
|
scratch[1],
|
||||||
|
scratch[2],
|
||||||
|
frame.length / 2,
|
||||||
|
scratch[3],
|
||||||
|
scratch[4],
|
||||||
|
scratch[5],
|
||||||
|
0,
|
||||||
|
scratch[6],
|
||||||
|
scratch[7],
|
||||||
|
scratch[8],
|
||||||
|
0,
|
||||||
|
segment.fromT - epoch,
|
||||||
|
segment.toT - epoch,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
// Bound all possible 5 CSS-pixel widths over the height extrusion.
|
||||||
|
const radius = frame.radius;
|
||||||
|
frame.radius +=
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
(Math.abs(scratch[2]) + 32000) /
|
||||||
|
Math.max(1, Math.abs(p[5]) * height),
|
||||||
|
) * 32;
|
||||||
|
groundClipBounds(frame, scratch, camera.projectionMatrix, bounds);
|
||||||
|
frame.radius = radius;
|
||||||
|
grid.bounds.set(bounds, i * 4);
|
||||||
|
});
|
||||||
|
packed.texture.needsUpdate = true;
|
||||||
|
const count = grid.pack();
|
||||||
|
if (candidates.data.length / 4 < count) {
|
||||||
|
candidates.texture.dispose();
|
||||||
|
candidates = createGroundStorage(count);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < count; i++) candidates.data[i * 4] = grid.indices[i];
|
||||||
|
for (let i = 0; i < grid.counts.length; i++) {
|
||||||
|
gridData[i * 4] = grid.offsets[i];
|
||||||
|
gridData[i * 4 + 1] = grid.counts[i];
|
||||||
|
}
|
||||||
|
candidates.texture.needsUpdate = gridTexture.needsUpdate = true;
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
packed.texture.dispose();
|
||||||
|
gridTexture.dispose();
|
||||||
|
candidates.texture.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptor(EffectDesc, NavaraEffect) {
|
||||||
|
return class TransitGroundEffect extends EffectDesc {
|
||||||
|
static key = KEY;
|
||||||
|
static insertAfter = ['toneMapping'];
|
||||||
|
static insertBefore = [
|
||||||
|
'gevSharpen',
|
||||||
|
'smaa',
|
||||||
|
'fxaa',
|
||||||
|
'gevPreSharpenOverlay',
|
||||||
|
'gevOverlay',
|
||||||
|
'final',
|
||||||
|
];
|
||||||
|
constructor(view, context, config) {
|
||||||
|
super(view, context, config);
|
||||||
|
this.owner = config[KEY].owner;
|
||||||
|
}
|
||||||
|
createPass() {
|
||||||
|
this.effect = new Effect('GevTransitGround', shader, {
|
||||||
|
attributes: EffectAttribute.CONVOLUTION,
|
||||||
|
uniforms: new Map([
|
||||||
|
...groundSurfaceUniforms(),
|
||||||
|
['tSegments', new Uniform(null)],
|
||||||
|
['tGrid', new Uniform(null)],
|
||||||
|
['tCandidates', new Uniform(null)],
|
||||||
|
['uSegmentSize', new Uniform([1, 1])],
|
||||||
|
['uCandidateSize', new Uniform([1, 1])],
|
||||||
|
['uDisplayT', new Uniform(0)],
|
||||||
|
['uActive', new Uniform(-1)],
|
||||||
|
['uColor', new Uniform([1, 1, 1])],
|
||||||
|
['uPixelRatio', new Uniform(1)],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
return new NavaraEffect(this.view.camera.raw, this.effect);
|
||||||
|
}
|
||||||
|
update() {
|
||||||
|
if (!this.effect) return;
|
||||||
|
const { state, displayT, active, color, pixelRatio } = this.owner;
|
||||||
|
const camera = this.view.camera.raw;
|
||||||
|
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
|
||||||
|
state.update(camera, this.ctx);
|
||||||
|
const u = this.effect.uniforms;
|
||||||
|
for (const [name, data] of [
|
||||||
|
['Segments', state.packed],
|
||||||
|
['Candidates', state.candidates],
|
||||||
|
]) {
|
||||||
|
u.get(`t${name}`).value = data.texture;
|
||||||
|
const size = u.get(
|
||||||
|
name === 'Segments' ? 'uSegmentSize' : 'uCandidateSize',
|
||||||
|
).value;
|
||||||
|
size[0] = data.width;
|
||||||
|
size[1] = data.height;
|
||||||
|
}
|
||||||
|
u.get('tGrid').value = state.gridTexture;
|
||||||
|
u.get('uDisplayT').value = displayT - state.epoch;
|
||||||
|
u.get('uActive').value = active;
|
||||||
|
u.get('uColor').value = color;
|
||||||
|
u.get('uPixelRatio').value = pixelRatio();
|
||||||
|
}
|
||||||
|
onDestroy() {
|
||||||
|
this.effect?.dispose();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A static corridor texture; playback only updates time and active uniforms. */
|
||||||
|
export function createTransitNavaraTrail(
|
||||||
|
engine,
|
||||||
|
{ view, EffectDesc, NavaraEffect },
|
||||||
|
) {
|
||||||
|
if (!view || !EffectDesc || !NavaraEffect)
|
||||||
|
throw new TypeError('Transit trails require the Navara ground effect API');
|
||||||
|
if (!registered.has(view)) {
|
||||||
|
view.registerEffect(KEY, descriptor(EffectDesc, NavaraEffect));
|
||||||
|
registered.add(view);
|
||||||
|
}
|
||||||
|
let segments = [],
|
||||||
|
revision = -1,
|
||||||
|
rebuilds = 0,
|
||||||
|
visible = true,
|
||||||
|
destroyed = false,
|
||||||
|
handle = null;
|
||||||
|
const owner = {
|
||||||
|
state: storage([]),
|
||||||
|
displayT: 0,
|
||||||
|
active: -1,
|
||||||
|
color: [0.37, 0.94, 0.54],
|
||||||
|
pixelRatio: () =>
|
||||||
|
engine.host.canvas.width / Math.max(1, engine.host.size().width),
|
||||||
|
};
|
||||||
|
const body = { show: true, ready: true },
|
||||||
|
head = { show: false, positions: [], width: 3 },
|
||||||
|
backing = { show: false, positions: [], width: 5 };
|
||||||
|
const endpoint = geo.cartesian3.create(),
|
||||||
|
headPositions = [null, endpoint];
|
||||||
|
const sync = () => {
|
||||||
|
const show = visible && segments.length > 0;
|
||||||
|
if (show && !handle)
|
||||||
|
handle = view.addEffect({ [KEY]: { owner }, visible: true });
|
||||||
|
else handle?.update({ visible: show });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
replaceHistory(next) {
|
||||||
|
if (destroyed || next.revision === revision) return;
|
||||||
|
const nextState = storage(next.segments);
|
||||||
|
const previous = owner.state;
|
||||||
|
owner.state = nextState;
|
||||||
|
segments = next.segments;
|
||||||
|
revision = next.revision;
|
||||||
|
owner.active = -1;
|
||||||
|
head.show = backing.show = false;
|
||||||
|
rebuilds++;
|
||||||
|
sync();
|
||||||
|
previous.dispose();
|
||||||
|
engine.render.request('transit-trail-history');
|
||||||
|
},
|
||||||
|
setDisplaySample(sample, renderedPosition) {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (!sample || !renderedPosition) {
|
||||||
|
owner.active = -1;
|
||||||
|
head.show = backing.show = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lo = 0,
|
||||||
|
hi = segments.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
const segment = segments[lo];
|
||||||
|
const active =
|
||||||
|
segment &&
|
||||||
|
segment.fromT <= sample.displayT &&
|
||||||
|
segment.fromSeq === sample.fromSeq &&
|
||||||
|
segment.toSeq === sample.toSeq &&
|
||||||
|
sample.fraction < 1
|
||||||
|
? lo
|
||||||
|
: -1;
|
||||||
|
if (owner.displayT === sample.displayT && owner.active === active) return;
|
||||||
|
owner.displayT = sample.displayT;
|
||||||
|
owner.active = active;
|
||||||
|
head.show = backing.show = visible && active >= 0;
|
||||||
|
if (active >= 0) {
|
||||||
|
headPositions[0] = segment.positions[0];
|
||||||
|
geo.cartesian3.clone(renderedPosition, endpoint);
|
||||||
|
head.positions = backing.positions = headPositions;
|
||||||
|
}
|
||||||
|
engine.render.request('transit-trail-playback');
|
||||||
|
},
|
||||||
|
setStyle(css) {
|
||||||
|
const color = rgbaFromCss(css).rgba().slice(0, 3);
|
||||||
|
if (color.every((value, i) => value === owner.color[i])) return;
|
||||||
|
owner.color = color;
|
||||||
|
engine.render.request('transit-trail-style');
|
||||||
|
},
|
||||||
|
setVisible(next) {
|
||||||
|
if (destroyed || visible === next) return;
|
||||||
|
visible = next;
|
||||||
|
body.show = next;
|
||||||
|
head.show = backing.show = next && owner.active >= 0;
|
||||||
|
sync();
|
||||||
|
engine.render.request('transit-trail-visible');
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
handle?.delete();
|
||||||
|
handle = null;
|
||||||
|
owner.state.dispose();
|
||||||
|
segments = [];
|
||||||
|
engine.render.request('transit-trail-destroy');
|
||||||
|
},
|
||||||
|
diagnostics: () => ({
|
||||||
|
revision,
|
||||||
|
rebuilds,
|
||||||
|
segments: segments.length,
|
||||||
|
body: segments.length ? body : null,
|
||||||
|
head,
|
||||||
|
backing,
|
||||||
|
headPrimitive: null,
|
||||||
|
ground: true,
|
||||||
|
headStrategy: 'surface-depth corridor with time clip',
|
||||||
|
headRebuilds: 0,
|
||||||
|
entitiesAdded: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
|
||||||
|
/** Exact 30 m perspective probe, with the native 0.5–3 px basis blend. */
|
||||||
|
export function createTransitNavaraView(engine) {
|
||||||
|
const enu = geo.matrix4.create(),
|
||||||
|
forward = geo.cartesian3.create(),
|
||||||
|
world = geo.cartesian3.create(),
|
||||||
|
probe = geo.cartesian3.create(),
|
||||||
|
at = geo.cartesian2.create(),
|
||||||
|
ahead = geo.cartesian2.create();
|
||||||
|
let pose = engine.camera.pose();
|
||||||
|
const fresh = () => (pose = engine.camera.pose());
|
||||||
|
return {
|
||||||
|
cameraCartographic: () => fresh().cartographic,
|
||||||
|
cameraSignature() {
|
||||||
|
fresh();
|
||||||
|
const p = pose.position;
|
||||||
|
return (
|
||||||
|
`${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
|
||||||
|
`${pose.heading.toFixed(3)}:${pose.pitch.toFixed(3)}:${pose.roll.toFixed(3)}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
rotation(position, course, previous) {
|
||||||
|
if (!position || !Number.isFinite(course)) return previous;
|
||||||
|
const angle = geo.math.toRadians(course);
|
||||||
|
geo.cartesian3.fromElements(
|
||||||
|
Math.sin(angle) * 30,
|
||||||
|
Math.cos(angle) * 30,
|
||||||
|
0,
|
||||||
|
forward,
|
||||||
|
);
|
||||||
|
geo.transforms.eastNorthUpToFixedFrame(
|
||||||
|
position,
|
||||||
|
geo.ellipsoid.WGS84,
|
||||||
|
enu,
|
||||||
|
);
|
||||||
|
geo.matrix4.multiplyByPointAsVector(enu, forward, world);
|
||||||
|
geo.cartesian3.add(position, world, probe);
|
||||||
|
// Match the native 2 km basis probe's degeneracy threshold.
|
||||||
|
const dx = geo.cartesian3.dot(world, pose.right) * (2000 / 30);
|
||||||
|
const dy = -geo.cartesian3.dot(world, pose.up) * (2000 / 30);
|
||||||
|
const basis = dx * dx + dy * dy < 0.25 ? previous : Math.atan2(-dx, -dy);
|
||||||
|
const a = engine.projector.worldToWindow(position, at);
|
||||||
|
const b = engine.projector.worldToWindow(probe, ahead);
|
||||||
|
if (!a || !b) return basis;
|
||||||
|
const x = b.x - a.x,
|
||||||
|
y = b.y - a.y,
|
||||||
|
separation = Math.hypot(x, y);
|
||||||
|
const exact = Math.atan2(-x, -y);
|
||||||
|
if (separation >= 3 || !Number.isFinite(basis)) return exact;
|
||||||
|
if (separation <= 0.5) return basis;
|
||||||
|
const delta = Math.atan2(
|
||||||
|
Math.sin(exact - basis),
|
||||||
|
Math.cos(exact - basis),
|
||||||
|
);
|
||||||
|
return basis + delta * ((separation - 0.5) / 2.5);
|
||||||
|
},
|
||||||
|
visibilityTester() {
|
||||||
|
const camera = fresh();
|
||||||
|
const tanY = Math.tan(camera.fovy / 2);
|
||||||
|
const size = engine.host.size();
|
||||||
|
const tanX = tanY * (camera.aspectRatio ?? size.width / size.height);
|
||||||
|
const xMargin = 5 * Math.hypot(1, tanX),
|
||||||
|
yMargin = 5 * Math.hypot(1, tanY);
|
||||||
|
const delta = geo.cartesian3.create();
|
||||||
|
return {
|
||||||
|
hasFrustum: true,
|
||||||
|
test(point) {
|
||||||
|
geo.cartesian3.subtract(point, camera.position, delta);
|
||||||
|
const z = geo.cartesian3.dot(delta, camera.direction);
|
||||||
|
return {
|
||||||
|
horizon: engine.projector.horizonVisible(point),
|
||||||
|
inFrustum:
|
||||||
|
z >= (camera.near ?? 0) - 5 &&
|
||||||
|
z <= (camera.far ?? Infinity) + 5 &&
|
||||||
|
Math.abs(geo.cartesian3.dot(delta, camera.right)) <=
|
||||||
|
z * tanX + xMargin &&
|
||||||
|
Math.abs(geo.cartesian3.dot(delta, camera.up)) <=
|
||||||
|
z * tanY + yMargin,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
refresh: fresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -212,6 +212,7 @@ export function createTesting({ state, parts }) {
|
|||||||
state._preRenderRemove?.();
|
state._preRenderRemove?.();
|
||||||
const removePre = parts.renderer.onPreRender(() => {
|
const removePre = parts.renderer.onPreRender(() => {
|
||||||
upload = 0;
|
upload = 0;
|
||||||
|
active = probe.allUploads === true;
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
parts.rendering.onPreRender();
|
parts.rendering.onPreRender();
|
||||||
cpu = performance.now() - start;
|
cpu = performance.now() - start;
|
||||||
@@ -246,6 +247,7 @@ export function createTesting({ state, parts }) {
|
|||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
rows,
|
rows,
|
||||||
|
uploadScope: probe.uploadScope ?? 'transit-marker-banks',
|
||||||
dpr: devicePixelRatio,
|
dpr: devicePixelRatio,
|
||||||
width: probe.canvas.clientWidth,
|
width: probe.canvas.clientWidth,
|
||||||
height: probe.canvas.clientHeight,
|
height: probe.canvas.clientHeight,
|
||||||
|
|||||||
Reference in new issue
Block a user