Files
gods-eye-view/src/layers/localAdsb/models.js
T
quanticsoul4772 91144e88ad perf(models): aircraft models skip Cesium's dynamic environment map (#1366)
Skip per-aircraft dynamic environment maps while retaining default model lighting.

Contribution by @quanticsoul4772.
2026-10-25 08:08:43 -07:00

285 lines
9.4 KiB
JavaScript

import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
import {
FLEET_DR_INTERVAL_MS,
MODEL_ALL_ADD_M,
MODEL_ALL_KEEP_M,
MODEL_ALT_CEIL_M,
MODEL_HEADING_OFFSET_DEG,
MODEL_MAX,
MODEL_MAX_ALL,
MODEL_MIN_PX,
MODEL_PROX_ADD_M,
MODEL_PROX_KEEP_M,
} from '../flights/policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
const specKey = (spec) => `${spec.url}@${spec.scale}`;
/**
* 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle.
*
* Mirrors the public Flights fleet: models only below the altitude ceiling,
* the same proximity/all caps and add/keep radii, the same visible-first
* eligibility (`selectModelEligible`), the same per-class asset and scale
* (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here
* magenta, as Flights tints military amber). Each model owns its matrix. A
* billboard stays the visual until its model is loaded AND placed; a grounded
* aircraft's model rides the one-shot ground snap plus the class belly
* offset, and without a resolved ground it stays a billboard.
* @param {object} options
* @param {object} options.viewer Cesium viewer.
* @param {Cesium.Color} options.color Model tint.
* @param {(url: string) => string} options.resolveAsset
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
* @param {(options: object) => Promise<object>} [options.loadModel]
* @returns {object}
*/
export function createLocalAdsbModels({
viewer,
color,
resolveAsset,
groundSnap = null,
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
}) {
const collection = new Cesium.PrimitiveCollection();
viewer.scene.primitives.add(collection);
/** @type {Map<string, object>} id -> admitted model */
const models = new Map();
/** @type {Map<string, number>} id -> generation of an in-flight load */
const pending = new Map();
let generation = 0;
let destroyed = false;
let lastEligibilityAt = 0;
let eligible = new Set();
const scratchHpr = new Cesium.HeadingPitchRoll();
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
const scratchCarto = new Cesium.Cartographic();
const scratchGround = new Cesium.Cartesian3();
function release(id) {
pending.delete(id);
const model = models.get(id);
if (!model) return;
models.delete(id);
if (!collection.isDestroyed()) collection.remove(model);
groundSnap?.forget?.(id);
}
function releaseAll() {
for (const id of [...models.keys()]) release(id);
pending.clear();
generation += 1;
}
async function ensure(id, marker, cap) {
const spec = civilAircraftModelSpec(marker.klass);
const key = specKey(spec);
const current = models.get(id);
if (current && current._gevSpecKey === key) return;
if (current) release(id);
if (pending.has(id) || models.size + pending.size >= cap) return;
const loadGeneration = generation;
pending.set(id, loadGeneration);
let model;
try {
model = await loadModel({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color,
colorBlendMode: Cesium.ColorBlendMode.MIX,
colorBlendAmount: spec.blendAmount,
id,
});
} catch {
if (pending.get(id) === loadGeneration) pending.delete(id);
return;
}
const stale =
destroyed ||
pending.get(id) !== loadGeneration ||
generation !== loadGeneration ||
!eligible.has(id) ||
models.has(id) ||
specKey(civilAircraftModelSpec(marker.klass)) !== key;
if (pending.get(id) === loadGeneration) pending.delete(id);
if (stale) {
model.destroy?.();
return;
}
model.id = id;
model._gevSpecKey = key;
model._gevBellyM = spec.bellyM;
// Admitted, not yet the visual: placement turns it on.
model.show = false;
collection.add(model);
models.set(id, model);
}
function regime(preferences) {
if (!preferences?.models3d) return false;
const height = viewer.camera?.positionCartographic?.height ?? Infinity;
return height < MODEL_ALT_CEIL_M;
}
function refreshEligibility(markers, preferences) {
const all = preferences.models3dMode === 'all';
const cap = all ? MODEL_MAX_ALL : MODEL_MAX;
const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
const camera = viewer.camera;
const cameraPosition = camera.positionWC;
const cull = camera.frustum.computeCullingVolume(
cameraPosition,
camera.directionWC,
camera.upWC,
);
const candidates = [];
for (const [id, marker] of markers) {
if (!marker.position) continue;
const distanceSq = Cesium.Cartesian3.distanceSquared(
cameraPosition,
marker.position,
);
if (distanceSq > keepM * keepM) continue;
Cesium.Cartesian3.clone(marker.position, scratchSphere.center);
candidates.push([
id,
distanceSq,
cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE,
]);
}
candidates.sort((a, b) => a[1] - b[1]);
eligible = selectModelEligible(candidates, {
cap,
addDistSq: addM * addM,
isModeled: (id) => models.has(id),
});
for (const id of [...models.keys(), ...pending.keys()])
if (!eligible.has(id)) release(id);
for (const id of eligible) void ensure(id, markers.get(id), cap);
}
function placement(id, marker) {
if (!marker.onGround) return marker.position;
const model = models.get(id);
// The vertical sample must hit the tile skin, not this aircraft's own
// billboard or model.
const height = groundSnap?.heightFor?.(viewer, id, marker.position, () =>
[marker.entity, model].filter(Boolean),
);
if (!Number.isFinite(height)) return null;
const carto = Cesium.Cartographic.fromCartesian(
marker.position,
Cesium.Ellipsoid.WGS84,
scratchCarto,
);
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
height + (model?._gevBellyM || 0),
Cesium.Ellipsoid.WGS84,
scratchGround,
);
}
return {
/** Ids whose model admission is current; the layer enriches these. */
get eligible() {
return eligible;
},
/**
* Per-frame pass: re-derive eligibility at the fleet cadence, then place
* every admitted model and hand each marker's visual to its model only
* once the model is ready and placed.
* @param {Map<string, object>} markers Layer markers (position, courseDeg,
* klass, onGround); `modelOwnsVisual` is written back.
* @param {object} preferences `{ models3d, models3dMode }`.
* @param {number} nowMs
*/
frame(markers, preferences, nowMs) {
if (destroyed) return;
if (!regime(preferences)) {
if (models.size || pending.size) releaseAll();
eligible = new Set();
for (const marker of markers.values()) marker.modelOwnsVisual = false;
return;
}
if (nowMs - lastEligibilityAt >= FLEET_DR_INTERVAL_MS) {
lastEligibilityAt = nowMs;
refreshEligibility(markers, preferences);
}
const occluder = horizonOccluder(viewer.camera);
for (const [id, marker] of markers) {
const model = models.get(id);
marker.modelOwnsVisual = false;
if (!model || !marker.position) continue;
const position = placement(id, marker);
if (!position || !occluder.isPointVisible(marker.position)) {
model.show = false;
continue;
}
scratchHpr.heading = Cesium.Math.toRadians(
(marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG,
);
scratchHpr.pitch = 0;
scratchHpr.roll = 0;
Cesium.Transforms.headingPitchRollToFixedFrame(
position,
scratchHpr,
Cesium.Ellipsoid.WGS84,
undefined,
model.modelMatrix,
);
if (!model.ready) {
model.show = false;
continue;
}
model.show = true;
marker.modelOwnsVisual = true;
}
for (const id of [...models.keys()]) if (!markers.has(id)) release(id);
},
/**
* Drop the model, and any load still in flight, of every aircraft that
* is no longer a marker. The layer calls this whenever markers expire:
* the per-frame pass does not run once the last marker is gone.
* @param {Map<string, object>|Set<string>} live Current marker ids.
*/
retain(live) {
if (destroyed) return;
let dropped = false;
for (const id of [...models.keys(), ...pending.keys()]) {
if (live.has(id)) continue;
release(id);
eligible.delete(id);
dropped = true;
}
if (dropped) eligible = new Set(eligible);
},
/** Drop every model (3D off, layer disabled). */
clear: releaseAll,
/** Number of admitted models (tests and diagnostics). */
get size() {
return models.size;
},
destroy() {
destroyed = true;
releaseAll();
groundSnap?.clear?.();
if (!viewer.isDestroyed?.() && !collection.isDestroyed())
viewer.scene.primitives.remove(collection);
},
};
}