From 5a8bc33fbde7e32ba7d95e531ea7e83e85a42837 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:51:17 +0400 Subject: [PATCH] feat(navara): render transit vehicles and ground trails --- scripts/fixtures/w2/transit.mjs | 100 ++++++ scripts/format-scope.json | 7 + scripts/package-boundaries.json | 4 + src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 9 + src/layers/transit/render/navara.js | 135 +++++++ src/layers/transit/render/navara.test.mjs | 248 +++++++++++++ src/layers/transit/render/navaraSprites.js | 273 +++++++++++++++ src/layers/transit/render/navaraTrail.js | 388 +++++++++++++++++++++ src/layers/transit/render/navaraView.js | 86 +++++ src/layers/transit/testing.js | 2 + 11 files changed, 1254 insertions(+) create mode 100644 scripts/fixtures/w2/transit.mjs create mode 100644 src/layers/transit/render/navara.js create mode 100644 src/layers/transit/render/navara.test.mjs create mode 100644 src/layers/transit/render/navaraSprites.js create mode 100644 src/layers/transit/render/navaraTrail.js create mode 100644 src/layers/transit/render/navaraView.js diff --git a/scripts/fixtures/w2/transit.mjs b/scripts/fixtures/w2/transit.mjs new file mode 100644 index 000000000..8f4768b61 --- /dev/null +++ b/scripts/fixtures/w2/transit.mjs @@ -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(); + }, + }; +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cda156d71..267995200 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -830,6 +830,13 @@ "src/layers/transit/rendering.js", "src/layers/transit/render/cesium.js", "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/state.js", "src/layers/transit/testing.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 50315291d..b4b357781 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2706,6 +2706,10 @@ "src/layers/radio/render/cesium.js", "src/layers/firms/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/navaraMeshes.js", "src/layers/cctv/render/navaraGizmo.js", diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 8675c0bfd..aa489132a 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -31,6 +31,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'radio', 'alpr-cameras', 'cctv', + 'transit', ]) assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.deepEqual( @@ -47,6 +48,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'rocket-launches', 'cctv', 'radio', + 'transit', 'bikeshare', 'ais-live-vessels', 'military-installations', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 8fdb1b235..6dff1081e 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -65,6 +65,15 @@ const NAVARA_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { + transit: async () => { + const { createTransitNavaraRenderer } = + await import('../layers/transit/render/navara.js'); + return (engine, options) => + createTransitNavaraRenderer(engine, { + ...options, + ...navaraViews.get(engine), + }); + }, cctv: async () => { const { createCctvNavaraRenderer } = await import('../layers/cctv/render/navara.js'); diff --git a/src/layers/transit/render/navara.js b/src/layers/transit/render/navara.js new file mode 100644 index 000000000..839f94ce7 --- /dev/null +++ b/src/layers/transit/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; + }, + }; +} diff --git a/src/layers/transit/render/navara.test.mjs b/src/layers/transit/render/navara.test.mjs new file mode 100644 index 000000000..6dc57cd8a --- /dev/null +++ b/src/layers/transit/render/navara.test.mjs @@ -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); +}); diff --git a/src/layers/transit/render/navaraSprites.js b/src/layers/transit/render/navaraSprites.js new file mode 100644 index 000000000..37f821373 --- /dev/null +++ b/src/layers/transit/render/navaraSprites.js @@ -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; +} diff --git a/src/layers/transit/render/navaraTrail.js b/src/layers/transit/render/navaraTrail.js new file mode 100644 index 000000000..bd3e72d76 --- /dev/null +++ b/src/layers/transit/render/navaraTrail.js @@ -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(depthcenter.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, + }), + }; +} diff --git a/src/layers/transit/render/navaraView.js b/src/layers/transit/render/navaraView.js new file mode 100644 index 000000000..bb8428a8c --- /dev/null +++ b/src/layers/transit/render/navaraView.js @@ -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, + }; +} diff --git a/src/layers/transit/testing.js b/src/layers/transit/testing.js index 0eca6d5ad..9f9cfab10 100644 --- a/src/layers/transit/testing.js +++ b/src/layers/transit/testing.js @@ -212,6 +212,7 @@ export function createTesting({ state, parts }) { state._preRenderRemove?.(); const removePre = parts.renderer.onPreRender(() => { upload = 0; + active = probe.allUploads === true; const start = performance.now(); parts.rendering.onPreRender(); cpu = performance.now() - start; @@ -246,6 +247,7 @@ export function createTesting({ state, parts }) { } resolve({ rows, + uploadScope: probe.uploadScope ?? 'transit-marker-banks', dpr: devicePixelRatio, width: probe.canvas.clientWidth, height: probe.canvas.clientHeight,