mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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/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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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?.();
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user