feat(navara): render transit vehicles and ground trails

This commit is contained in:
German Khokhlov committed 2026-10-29 17:51:17 +04:00
1 parent b8560e5b26
commit 5a8bc33fbd
11 files changed
+1254

No files matched your search

+100
View File
@@ -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();
},
};
}
+7
View File
@@ -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",
+4
View File
@@ -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",
+2
View File
@@ -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',
+9
View File
@@ -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');
+135
View File
@@ -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;
},
};
}
+248
View File
@@ -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);
});
+273
View File
@@ -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;
}
+388
View File
@@ -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,
}),
};
}
+86
View File
@@ -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,
};
}
+2
View File
@@ -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,