Files
gods-eye-view/src/layers/flights/rendering.js
T

1196 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { isEmergency } from '../../data/aircraftEmergency.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
cameraPoseSignature,
horizonOccluder,
screenProjectedRotation,
} from '../../data/iconOrientation.js';
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import {
MIL_TINT,
EMERGENCY_TINT,
GROUND_SCALE,
COCKPIT_CONTACT_SIZE_PX,
COCKPIT_CIVILIAN_COLOR,
MODEL_ALT_CEIL_M,
MODEL_MAX_ALL,
MODEL_MAX,
COCKPIT_MODEL_MAX,
MODEL_ALL_ADD_M,
MODEL_PROX_ADD_M,
MODEL_ALL_KEEP_M,
MODEL_PROX_KEEP_M,
MODEL_HEADING_OFFSET_DEG,
IR_RELOAD_BATCH,
MODEL_MIN_PX,
TRACKED_MODEL_MIN_PX,
TRACKED_MODEL_MAX_PX,
FLEET_DR_INTERVAL_MS,
RENDER_DELAY_SEC,
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js';
export function createRendering({
flightState,
services,
parts,
layer,
resolveAsset,
}) {
const { isMilitaryIcao } = services.militaryRegistry;
const { tr3bIconKind, isTr3b } = services.aircraftPresentation;
const { trackedModelScaleForPixelCap } = services.camera;
const {
focusNowMs,
getFocusTarget,
nearFarScalarValueAtDistance,
advanceProjectedSpriteFocus,
} = services.focus;
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
services.recession;
/** True while this contact's latest poll broadcast an emergency code. */
function _isEmergency(icao24, info = flightState.records.data.get(icao24)) {
return isEmergency(info?.emergency);
}
/** Fleet (untracked) billboard tint: red while broadcasting an emergency,
* amber for known-military, white otherwise.
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
function _fleetBillboardColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
}
/** Cockpit far-contact dot tint: the emergency red outranks the category tint. */
function _cockpitContactColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR;
}
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
function _fleetBillboardScale(icao24, klass) {
return (
(CLASS_SCALE_2D[klass] || 1) *
(flightState.records.data.get(icao24)?.onGround ? GROUND_SCALE : 1)
);
}
/** Depth-test policy for aircraft billboards. Round 5 (owner directive
* 2026-07-06: "I just want the planes and their lines to ALWAYS be
* visible... evenly applied"): EVERY contact renders depth-test-free at
* every distance — grounded, low, and airborne alike. The photoreal mesh
* writes depth and residual baro/floor error will always leave some sprite
* geometry at or below it; a uniform rule beats the grounded-only /
* low-AGL-only conditions that kept leaving classes of contacts buried
* (2026-07-03 Van Nuys grounded case; 2026-07-06 Austin QNH-below-field
* case). Far-side planes are still removed by the fleet tick's horizon
* occluder, which never depended on depth. Kept as a function so the
* callers' restyle sites stay diff-stable. */
function _groundDepthDistance() {
return Number.POSITIVE_INFINITY;
}
function _modelSpec(klass) {
const cached = flightState._specCache.get(klass);
if (cached) return cached;
const spec = civilAircraftModelSpec(klass);
flightState._specCache.set(klass, spec);
return spec;
}
function _normalBillboardScaleByDistance() {
// Preserve the established close-range 3× scale. Any smaller owner-visible
// default belongs in a separate evidence-backed proposal.
return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5);
}
function _cockpitBillboardScaleByDistance() {
return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65);
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
* the ones the operator converted into a TR-3B (Easter egg), which draw the
* black-triangle glyph — its thermal-reactive variant while an IR style owns
* the scene. Routing EVERY `aircraftIcon()` call through this is what makes a
* conversion survive the poll reconciler and the two-tier raster swap. */
const _iconKind = (icao24, klass) =>
tr3bIconKind(icao24, klass, { hot: flightState._irBoost });
/** Apply the current normal/cockpit visual contract to one owned fleet billboard. */
function _applyFleetBillboardPresentation(icao24, bb) {
if (!bb) return;
const limbScale = flightState._billboardLimbScale.get(bb) ?? 1;
const isCockpitContact =
flightState._cockpitContactMode && icao24 !== flightState._trackedIcao;
const isCockpitNear =
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
if (isCockpitContact && !isCockpitNear) {
const freshnessAlpha = cyberSonarBaseAlpha(bb);
bb.image = cockpitContactDotImage();
bb.width = COCKPIT_CONTACT_SIZE_PX;
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
const meta = flightState.records.data.get(icao24);
bb.image = aircraftIcon(
_iconKind(icao24, meta?.klass),
bb._gevIconLarge ? TRACKED_ICON_PX : undefined,
);
bb.width = icao24 === flightState._trackedIcao ? 24 : 20;
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb));
}
/**
* Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard,
* horizon-culls billboards beyond the limb (no far-side depth with the globe
* hidden), and refreshes screen-projected icon rotations whenever the camera
* pose changed (plus a 1s drift catch-up while idle). Driven by
* scene.preRender — NOT camera.changed, whose granularity is globally
* degraded by other layers mutating camera.percentageChanged.
* @returns {void}
*/
/** Model tint, mirroring the billboard color rules. */
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
return _fleetBillboardColor(icao24);
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
* view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms
* `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED
* contact does not route through here: it is one model, it is what the camera is aimed at,
* and it takes its own default-on, hysteretic zoom regime
* (`_trackedModelRegimeActive`). */
function _modelRegimeActive() {
if (!flightState._models3dEnabled) return false;
const h =
flightState._viewer?.camera?.positionCartographic?.height ?? Infinity;
return h < MODEL_ALT_CEIL_M;
}
/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the
* SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses
* at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */
function _modelCap() {
const mapCap =
flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX;
// `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is
// already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal
// retraversal, the cloud pass) and every model is its own draw call.
return flightState._cockpitContactMode
? Math.min(COCKPIT_MODEL_MAX, mapCap)
: mapCap;
}
/** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */
function _modelAddDistM() {
return flightState._models3dMode === 'all'
? MODEL_ALL_ADD_M
: MODEL_PROX_ADD_M;
}
/** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */
function _modelKeepDistM() {
return flightState._models3dMode === 'all'
? MODEL_ALL_KEEP_M
: MODEL_PROX_KEEP_M;
}
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
* `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one
* mutable matrix object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium
* clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at
* the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the
* scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */
function _modelMatrix(
pos,
headingDeg,
result = flightState._scratchModelMtx,
) {
flightState._scratchModelHpr.heading = Cesium.Math.toRadians(
(headingDeg || 0) + MODEL_HEADING_OFFSET_DEG,
);
flightState._scratchModelHpr.pitch = 0;
flightState._scratchModelHpr.roll = 0;
return Cesium.Transforms.headingPitchRollToFixedFrame(
pos,
flightState._scratchModelHpr,
Cesium.Ellipsoid.WGS84,
undefined,
result,
);
}
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
* vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked
* neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion
* matches picked-object IDs, and every billboard AND model in this layer carries its
* icao as `id`, so the icao strings cover both; the tracked entity is excluded as the
* object itself. Built lazily — only when a sample actually fires (one-shot). */
function _groundSampleExclusions() {
const out = [...flightState._billboards.keys()];
if (flightState._trackedEntity) out.push(flightState._trackedEntity);
return out;
}
/** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position
* verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near
* the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports,
* hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested
* model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides
* a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js,
* CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a
* bounded last-known and resamples), plus
* the belly offset so it sits on its gear rather than sinking to the model-origin
* fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss)
* there is no safe depth-tested placement at all: this returns null, the caller
* keeps the 2D billboard visible and the model hidden, and it retries later. Once
* a contact has resolved once, a later outage holds that measurement inside
* groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D
* because a resample is mid-backoff. */
function _modelDisplayPosition(icao24, pos, result) {
const meta = flightState.records.data.get(icao24);
if (!meta || !meta.onGround) return pos;
const h = flightState._groundSnap.heightFor(
flightState._viewer,
icao24,
pos,
_groundSampleExclusions,
);
if (h == null) return null;
const carto = Cesium.Cartographic.fromCartesian(
pos,
Cesium.Ellipsoid.WGS84,
flightState._scratchGroundCarto,
);
carto.height = h + _modelSpec(meta.klass).bellyM;
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
carto.height,
Cesium.Ellipsoid.WGS84,
result,
);
}
/** Atomically hand one fleet contact from its billboard to a safely placed,
* ready 3D model. Missing/loading models and unresolved terrain always leave
* the billboard owning the visual, so no render frame can hide both.
* `beforeShow` runs only on the committing path — the per-tick model treatment
* belongs to a model that is about to draw, not to one still waiting. */
function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) {
if (!model) {
bb.show = true;
return false;
}
const displayPos = _modelDisplayPosition(
icao24,
pos,
flightState._scratchGroundPos,
);
if (!displayPos) {
model.show = false; // no ground evidence → nothing safe to place a depth-tested model at
bb.show = true;
return false;
}
// The matrix is written BEFORE the readiness test on purpose: a model can flip
// `ready` during scene update after this tick, and a first rendered frame on a
// stale load-start matrix is the one-frame jump this ordering prevents.
_modelMatrix(displayPos, course, model.modelMatrix);
if (!model.ready) {
model.show = false; // not loaded yet → keep the 2D icon, no half-model flash
bb.show = true;
return false;
}
beforeShow?.();
if (!model.show) model.show = true;
if (bb.show) bb.show = false; // hand off ONLY once the model renders
return true;
}
/**
* Whether a 3D model — not the billboard — is what the user actually SEES for
* this contact, and therefore whether the display floor should stand aside.
*
* Model EXISTENCE is not ownership. A fleet model that is still loading, or has
* no resolved ground to stand on, stays hidden while `bb.show` stays true (the
* gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model
* is retained but hidden whenever the model regime is off — 3D disabled, camera
* zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore
* suppressed the clamp in exactly the states where the BILLBOARD is the visual,
* putting the burial straight back.
*
* Cesium's own default `show === true` is the reason admission clears it
* explicitly: a fleet model registered in `_models` the instant its glTF resolves
* would otherwise claim ownership — at the identity matrix — for the frames
* between admission and the next fleet tick, while the BILLBOARD was still the
* visual. Ownership means actually rendering, and every site that sets `show`
* true does so only after committing a matrix.
*
* The two halves:
* - Fleet: the rendering test alone. Safe for the ground snap either way,
* because a fleet model is positioned from the RAW dead-reckon, not from
* the billboard.
* - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked
* model is fed from `_trackedDisplayPosition`, so once it is live the clamp
* must stand aside: two different chains would otherwise be deciding one
* contact's ground, and the billboard's is the one the operator is not
* looking at (T7). The regime check is what makes a regime
* flip take effect on the same frame rather than waiting for
* `_updateTrackedModel` to clear `show`; the rendering test is what keeps a
* null or still-loading tracked model from claiming a visual it is not
* drawing yet.
* @param {string} icao24
* @param {Cesium.Model|null} [model] Resolved fleet model; null means absent.
* @returns {boolean}
*/
function _modelOwnsVisual(icao24, model) {
if (icao24 === flightState._trackedIcao) {
return (
parts.tracking._trackedModelRegimeActive() &&
_modelIsRendering(flightState._trackedModel)
);
}
return _modelIsRendering(
model === undefined ? flightState._models.get(icao24) : model,
);
}
/** A model is the visual only once it actually draws — loaded AND shown.
*
* `show` is sufficient evidence of a safe placement because only ONE site ever
* sets it true for a fleet model (`_driveFleetModelHandoff`, after the matrix is
* committed) and one for the tracked model (`_updateTrackedModel`, likewise);
* everything else — admission, an unresolved ground, a not-yet-ready glTF, the
* limb cull, a regime exit — only ever clears it. */
function _modelIsRendering(model) {
return !!model && model.ready === true && model.show === true;
}
/** Spec identity for a LOADED model: URL and scale together (same-URL classes
* differ by scale — airliner vs quadjet both ship airplane.glb). */
const _specKeyFor = (klass) => {
const spec = _modelSpec(klass);
return `${spec.url}@${spec.scale}`;
};
/** Class-change model sync (enrichment AND poll-path klass updates): when the
* aircraft's live model or in-flight load no longer matches its class's spec,
* drop it so the eligibility pass reloads the right asset at the right scale.
* Gap-proof: the fleet billboard is re-shown BEFORE the release so the
* contact never goes invisible for the tick gap; _releaseModel's generation
* bump also invalidates any pending load. The tracked standalone model gets
* the same rule (its billboard entity is always the fallback visual). */
function _syncModelToClass(icao24) {
const key = _specKeyFor(flightState.records.data.get(icao24)?.klass);
const current = flightState._models.get(icao24);
if (
(current && current._gevSpecKey !== key) ||
(!current && flightState._modelPending.has(icao24))
) {
const bb = flightState._billboards.get(icao24);
if (bb && icao24 !== flightState._trackedIcao) bb.show = true;
_releaseModel(icao24);
}
if (
icao24 === flightState._trackedIcao &&
flightState._trackedModel &&
flightState._trackedModel._gevSpecKey !== key
) {
_releaseTrackedModel();
}
}
function _reloadModelsForIrBoost() {
flightState._irReloadQueue = [...flightState._models.keys()];
for (const icao of flightState._modelPending) {
if (!flightState._models.has(icao))
flightState._modelGen.set(
icao,
(flightState._modelGen.get(icao) || 0) + 1,
);
}
_releaseTrackedModel();
}
function _drainIrReloadQueue() {
if (!flightState._irReloadQueue) return;
const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH);
for (const icao of batch) {
const model = flightState._models.get(icao);
if (!model || model._gevIrBoost === flightState._irBoost) continue; // already right state
const bb = flightState._billboards.get(icao);
if (bb && icao !== flightState._trackedIcao) bb.show = true;
_releaseModel(icao);
}
if (flightState._irReloadQueue.length === 0)
flightState._irReloadQueue = null;
}
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
async function _ensureModel(icao24) {
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
// tick skips it, so a model here would be orphaned + double-rendered.
if (icao24 === flightState._trackedIcao) return;
parts.enrichment._requestTypeEnrichment(icao24, true); // model-eligible: about to render in 3D — jump the ambient backlog
if (
flightState._models.has(icao24) ||
flightState._modelPending.has(icao24)
)
return;
// Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads
// (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL.
if (
flightState._models.size + flightState._modelPending.size >=
_modelCap()
)
return;
const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead
const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale
flightState._modelPending.add(icao24);
let model = null;
// Spec identity captured at load START — if enrichment reclassifies the
// aircraft mid-load, the post-await admission below rejects the stale asset.
// Boost state likewise: the creation options bake it in, so a mid-load
// toggle must reject too (the reload queue only covers ADMITTED models).
const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass);
const loadIrBoost = flightState._irBoost;
try {
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
colorBlendMode: Cesium.ColorBlendMode.MIX,
// Launch presentation keeps the code-side tint dominant for every approved
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
customShader: flightState._irBoost
? flightState._IR_UNLIT_SHADER
: undefined,
id: icao24, // so scene.pick returns the icao for click-to-track
});
} catch {
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
// (a destroy/re-init may have swapped the globals while this load was in flight).
if (epoch === flightState._modelEpoch) {
flightState._modelPending.delete(icao24);
_cleanupModelGen(icao24);
}
return;
}
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
if (epoch !== flightState._modelEpoch) {
try {
model.destroy();
} catch {
/* gone */
}
return;
}
flightState._modelPending.delete(icao24);
// Post-await admission: reject (and DESTROY the loaded model) if anything changed during the
// load — a release bumped the generation (track/untrack/remove), the layer toggled off / was
// torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled.
// Recheck the shared Display 3D toggle and altitude ceiling after the async
// load. Cockpit uses the same OFF / Proximity / All contract as map Display.
const stale =
(flightState._modelGen.get(icao24) || 0) !== gen ||
!_modelRegimeActive() ||
!flightState._modelCollection ||
flightState._modelCollection.isDestroyed() ||
!flightState.records.data.has(icao24) ||
icao24 === flightState._trackedIcao ||
flightState._models.has(icao24) ||
flightState._models.size >= _modelCap() ||
// Class reclassified mid-load → this GLB/scale is for the OLD class.
_specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey ||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
flightState._irBoost !== loadIrBoost;
if (stale) {
try {
model.destroy();
} catch {
/* already gone */
}
_cleanupModelGen(icao24); // bound the map
return;
}
// Keep the pick identity explicit on the resolved primitive. This also
// protects injected/custom loaders that do not copy the creation option.
model.id = icao24;
model._gevSpecKey = specKey; // class-change sync compares against this
model._gevIrBoost = loadIrBoost; // boost-flip reload queue compares against this
// Admitted, not yet the visual. Cesium's default is show=true, which would let
// an unplaced primitive claim ownership from the billboard for the frames
// between admission and the next fleet tick (and draw at the identity matrix,
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
model.show = false;
flightState._modelCollection.add(model);
flightState._models.set(icao24, model);
flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
}
/** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff).
* Bumps the load generation so any in-flight load for this icao is rejected on completion. */
function _releaseModel(icao24) {
const m = flightState._models.get(icao24);
const pending = flightState._modelPending.has(icao24);
// Only bump the generation when there's something to invalidate (an in-flight load or a
// live model) — so removing never-modeled aircraft doesn't grow _modelGen.
if (m || pending) {
flightState._modelGen.set(
icao24,
(flightState._modelGen.get(icao24) || 0) + 1,
);
}
if (m) {
if (
flightState._modelCollection &&
!flightState._modelCollection.isDestroyed()
) {
try {
flightState._modelCollection.remove(m);
} catch {
/* gone */
}
}
flightState._models.delete(icao24);
}
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
// the resolving load's gen-check still rejects it.
_cleanupModelGen(icao24);
}
/** Drop an icao's generation entry once nothing references it (no live model, no in-flight
* load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */
function _cleanupModelGen(icao24) {
if (
!flightState._modelPending.has(icao24) &&
!flightState._models.has(icao24)
)
flightState._modelGen.delete(icao24);
}
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
function _releaseModels() {
flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
// Invalidate in-flight loads so a completion after this bulk release can't add a model.
for (const icao of flightState._modelPending)
flightState._modelGen.set(
icao,
(flightState._modelGen.get(icao) || 0) + 1,
);
if (
flightState._modelCollection &&
!flightState._modelCollection.isDestroyed()
) {
for (const m of flightState._models.values()) {
try {
flightState._modelCollection.remove(m);
} catch {
/* gone */
}
}
}
flightState._models.clear();
}
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
function _releaseTrackedModel() {
flightState._trackedModelGen++;
flightState._trackedModelLoading = false;
if (flightState._trackedModel) {
if (
flightState._modelCollection &&
!flightState._modelCollection.isDestroyed()
) {
try {
flightState._modelCollection.remove(flightState._trackedModel);
} catch {
/* gone */
}
}
flightState._trackedModel = null;
}
}
/** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet
* cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
function _updateTrackedModel() {
const active =
flightState._trackedIcao &&
parts.tracking._trackedModelRegimeActive() &&
flightState._modelCollection &&
!flightState._modelCollection.isDestroyed();
if (!active) {
if (flightState._trackedModel) flightState._trackedModel.show = false;
return;
}
// Ask the frame-cached source directly. If the entity callback already ran,
// this is a no-op; if model loading completed between phases, it establishes
// this frame's single sample before the model renders. Camera, detection, and
// readout consumers then reuse that exact cached position.
const pos =
parts.motion._trackedDisplayPosition(flightState._trackedIcao) ||
flightState._billboards.get(flightState._trackedIcao)?.position;
if (!pos) {
if (flightState._trackedModel) flightState._trackedModel.show = false;
return;
}
if (
!flightState._trackedModel &&
!flightState._trackedModelLoading &&
parts.tracking._trackedModelLoadAllowed()
) {
flightState._trackedModelLoading = true;
const gen = flightState._trackedModelGen;
const trackedSpec = _modelSpec(
flightState.records.data.get(flightState._trackedIcao)?.klass,
);
const trackedKey = _specKeyFor(
flightState.records.data.get(flightState._trackedIcao)?.klass,
);
const trackedIrBoost = flightState._irBoost;
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
colorBlendMode: Cesium.ColorBlendMode.MIX,
// The tracked aircraft uses the same dominant light tint as the fleet;
// IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
customShader: flightState._irBoost
? flightState._IR_UNLIT_SHADER
: undefined,
// Pick id (H1): without it, clicking the very plane being tracked read as
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
// deselect. With the icao, the click handler recognizes it as ours.
id: flightState._trackedIcao,
})
.then((m) => {
// Untracked / re-tracked / torn down during the load → drop it.
if (
gen !== flightState._trackedModelGen ||
!flightState._modelCollection ||
flightState._modelCollection.isDestroyed()
) {
try {
m.destroy();
} catch {
/* gone */
}
return;
}
// Class reclassified OR boost flipped mid-load (no release ran —
// _trackedModel was still null): drop the stale asset; driver reloads.
if (
_specKeyFor(
flightState.records.data.get(flightState._trackedIcao)?.klass,
) !== trackedKey ||
flightState._irBoost !== trackedIrBoost
) {
try {
m.destroy();
} catch {
/* gone */
}
flightState._trackedModelLoading = false;
return;
}
// Assign after resolution as well as in the creation options so the
// standalone primitive always exposes the tracked aircraft pick id.
m.id = flightState._trackedIcao;
m._gevSpecKey = trackedKey; // class-change sync compares against this
m.show = false; // admitted, not yet the visual — the driver shows it once placed
// Seed the world transform before the primitive enters the scene. A model
// can become ready+shown between render phases; leaving Cesium's identity
// default here produces a one-frame jump to the Earth's center.
const currentPos =
parts.motion._trackedDisplayCached() ||
flightState._billboards.get(flightState._trackedIcao)?.position;
if (currentPos) {
const displayPos = _modelDisplayPosition(
flightState._trackedIcao,
currentPos,
flightState._scratchGroundPos,
);
if (displayPos)
_modelMatrix(
displayPos,
parts.motion._trackedDisplayCourse(),
m.modelMatrix,
);
}
flightState._trackedModel = m;
flightState._trackedModelLoading = false;
// A good load retires this selection's failure budget — a contact that
// recovers after a transient blip is not one attempt from giving up.
flightState._trackedModelFailIcao = null;
flightState._trackedModelFailCount = 0;
flightState._trackedModelRetryAtMs = 0;
flightState._modelCollection.add(m);
flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
})
.catch((err) => {
if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure
flightState._trackedModelLoading = false;
parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err);
});
return; // billboard carries the visual until the model is ready
}
if (flightState._trackedModel) {
// Keep the transform current while GPU resources are still loading. Cesium
// can flip ready during scene update after this callback; waiting for ready
// here would let that first rendered frame use a stale load-start matrix.
const displayPos = _modelDisplayPosition(
flightState._trackedIcao,
pos,
flightState._scratchGroundPos,
);
if (!displayPos) {
flightState._trackedModel.show = false; // no ground evidence → the billboard carries it
return;
}
_modelMatrix(
displayPos,
parts.motion._trackedDisplayCourse(),
flightState._trackedModel.modelMatrix,
);
if (!flightState._trackedModel.ready) return;
const spec = _modelSpec(
flightState.records.data.get(flightState._trackedIcao)?.klass,
);
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
baseScale: spec.scale,
nativeRadiusM: spec.nativeRadiusM,
rangeM: Cesium.Cartesian3.distance(
flightState._viewer.camera.positionWC,
displayPos,
),
viewportHeightPx: flightState._viewer.scene.canvas.clientHeight,
fovyRad: flightState._viewer.camera.frustum.fovy,
maximumPixelSize: TRACKED_MODEL_MAX_PX,
});
flightState._trackedModel.show = true;
}
}
function _fleetTick() {
if (
!flightState._viewer ||
!flightState._billboardCollection ||
!flightState._billboardCollection.show
)
return;
const scene = flightState._viewer.scene;
const camera = flightState._viewer.camera;
const nowMs = focusNowMs(Date.now());
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
// primitive rebuild needed here anymore.)
if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return;
const tickDtSec = flightState._lastFleetTickMs
? Math.min(
COURSE_SLEW_DT_MAX_SEC,
(nowMs - flightState._lastFleetTickMs) / 1000,
)
: 0.08;
flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime,
);
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (flightState._cockpitContactMode)
parts.tracking._refreshCockpitNearContacts();
const poseSig = cameraPoseSignature(camera);
// Only the nearby Cockpit silhouettes need projected course; far dots are
// rotation-free. The per-contact gate below keeps the pip path cheap.
const doRotations =
poseSig !== flightState._lastCamPoseSig ||
nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS;
if (doRotations) {
flightState._lastCamPoseSig = poseSig;
flightState._lastRotPassMs = nowMs;
}
const occluder = horizonOccluder(camera);
const focusTarget = getFocusTarget();
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
// Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume.
const useModels = _modelRegimeActive();
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
if (
!useModels &&
(flightState._models.size || flightState._modelPending.size)
)
_releaseModels();
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the
// cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen
// already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius,
// nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots
// (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint).
// Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
// filtering dropped near off-screen planes entirely. This does both right.
let modelEligible = null;
if (useModels) {
const cap = _modelCap();
const camPos = camera.positionWC;
const addM = _modelAddDistM();
const addDistSq = addM * addM;
const keepM = _modelKeepDistM();
const keepDistSq = keepM * keepM;
const cull = camera.frustum.computeCullingVolume(
camPos,
camera.directionWC,
camera.upWC,
);
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
const cand = [];
for (const [icao, bb] of flightState._billboards) {
if (icao === flightState._trackedIcao) continue;
// A converted TR-3B renders as a billboard and can never take a model, so
// it must not occupy a CAP SLOT either — excluded here at selection time,
// not just at the handoff below, or accumulated conversions would starve
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
if (isTr3b(icao)) continue;
// Ground planes compete for model slots like everyone else (owner decision
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
// ground or in the air — no distinction"). The cap + nearest-first ordering
// below already bound airport clusters; grounded placement is handled by the
// one-shot ground snap in _modelDisplayPosition.
const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position);
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
Cesium.Cartesian3.clone(
bb.position,
flightState._scratchModelBS.center,
);
cand.push([
icao,
d2,
cull.computeVisibility(flightState._scratchModelBS) !==
Cesium.Intersect.OUTSIDE,
]);
}
cand.sort((a, b) => a[1] - b[1]); // nearest first
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
console.warn(
`[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`,
);
flightState._lastModelCapWarnMs = nowMs;
}
modelEligible = selectModelEligible(cand, {
cap,
addDistSq,
isModeled: (icao) => flightState._models.has(icao),
});
const toRelease = [];
for (const icao of flightState._models.keys()) {
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
toRelease.push(icao);
}
for (const icao of toRelease) _releaseModel(icao);
}
for (const [icao24, bb] of flightState._billboards) {
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(
icao24,
flightState._scratchFleetPos,
renderTime,
);
// The dead-reckoned point drifts away from the fix whose cell supplied the
// height, so a grounded contact's sprite ends up under the mesh it taxied
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
// never while a 3D model owns the visual (T7).
const display = parts.motion._floorGroundedDisplayPosition(
icao24,
info,
dr,
_modelOwnsVisual(icao24),
nowMs,
);
// Gate the write — assigning Billboard.position dirties the whole
// collection's vertex buffer, so skip sub-meter moves.
if (
display &&
Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0
) {
bb.position = display;
}
// Round 6: occlusion-test a LIFTED point for contacts rendering below
// (or within a wingspan of) the ellipsoid — EllipsoidalOccluder judges a
// sub-ellipsoid point near the limb "beyond the horizon" and the fleet
// pass would hide a plane that is really just low over high-N terrain
// waiting for its floor to warm (ATL grounded contacts at geoid −31 m).
const beyondHorizon = !occluder.isPointVisible(
flightState._cullPositions.get(icao24) || bb.position,
);
// A billboard flipping INTO view (horizon reveal while the camera idles)
// gets its rotation refreshed THIS tick even without a pose change —
// otherwise it reappears wearing its stale (often creation-north) nose for
// up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false
// here; harmless — the model branch below `continue`s past the rotation.)
const revealed = !beyondHorizon && !bb.show;
if (bb.show === beyondHorizon) bb.show = !beyondHorizon;
if (beyondHorizon) {
// Also hide any 3D model — otherwise a model that crossed the limb would keep
// rendering through the hidden globe at its last matrix.
const m = flightState._models.get(icao24);
if (m && m.show) m.show = false;
continue;
}
// One order-independent write site composes freshness × focus × limb haze
// for alpha, and base class/ground scale × limb taper for scale. Cesium's
// locked NearFarScalar remains a separate multiplicative stage. This
// narrowly amends always-visible rendering without count culling or zero
// alpha; nearer focus behavior remains tunable rather than universal.
const cameraDistanceM = Cesium.Cartesian3.distance(
camera.positionWC,
bb.position,
);
const distanceScale = nearFarScalarValueAtDistance(
bb.scaleByDistance,
cameraDistanceM,
);
const focus = advanceProjectedSpriteFocus(
bb,
bb.position,
scene,
camera,
nowMs,
focusTarget,
undefined,
(bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5,
(bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5,
);
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const layerBaseColor =
flightState._cockpitContactMode && !isCockpitNear
? _cockpitContactColor(icao24, info)
: _fleetBillboardColor(icao24, info);
const baseColor = layerBaseColor;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
flightState._cockpitContactMode && !isCockpitNear
? 1
: _fleetBillboardScale(icao24, info?.klass),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
cameraHeightM: camera.positionCartographic?.height,
});
flightState._billboardLimbScale.set(bb, treatment.factors.scale);
// Two-tier glyph raster (owner playtest 2026-08-16): the billboard atlas
// has no mipmaps, so no single texture stays crisp across the ~25–150
// device-px range scaleByDistance produces. Swap between the 64 px fleet
// raster and the 192 px close raster on the billboard's ACTUAL on-screen
// size — post-treatment bb.scale, so focus/limb recession counts — with
// hysteresis so zoom oscillation never thrashes the atlas.
if (!flightState._cockpitContactMode || isCockpitNear) {
const glyphDevPx =
(bb.width || 20) *
(bb.scale || 1) *
distanceScale *
(globalThis.devicePixelRatio || 1);
const wantLarge = bb._gevIconLarge ? glyphDevPx > 56 : glyphDevPx > 76;
if (wantLarge !== !!bb._gevIconLarge) {
bb._gevIconLarge = wantLarge;
bb.image = aircraftIcon(
_iconKind(icao24, info?.klass),
wantLarge ? TRACKED_ICON_PX : undefined,
);
}
}
// Smoothed display course: the path direction _deadReckon just reported
// for THIS aircraft (nothing else calls _deadReckon in between), rate-
// limited so segment-boundary course steps glide instead of snapping.
// The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering
// aircraft (hold flag) keeps its previous nose direction outright.
const rawCourse =
flightState._drCourseDeg != null
? flightState._drCourseDeg
: (info && info.true_track) || 0;
const prevCourse = flightState._displayCourse.get(icao24);
const course =
flightState._drCourseHold && prevCourse != null
? prevCourse
: limitCourseStep(
prevCourse,
rawCourse,
courseSlewCapDps(
flightState._drSpeedMps != null
? flightState._drSpeedMps
: ((info && info.velocity) ?? NaN),
COURSE_MAX_DPS,
),
tickDtSec,
);
flightState._displayCourse.set(icao24, course);
// 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the
// billboard stays shown until the model is actually READY to render, so a plane is never both
// iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every
// tick regardless so it's framed the instant it becomes ready.
// Converted TR-3Bs stay 2D on purpose: the Easter egg IS the triangle, and
// there is no GLB for it, so the model handoff is suppressed rather than
// fed a stand-in mesh. The billboard keeps rendering (and keeps satisfying
// the getNearby/getDetectableObjects `bb.show` visibility guards), so a
// converted contact still works in Contacts and Cockpit.
if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) {
_ensureModel(icao24);
const model = flightState._models.get(icao24);
const ownsVisual = _driveFleetModelHandoff(
icao24,
model,
bb,
dr,
course,
() => {
applyAircraftModelTreatment({
model,
// IR boost must survive the per-tick treatment write — otherwise
// any alpha change would repaint the ordinary tint over the hot
// white. Boosted models also skip the recession fade: hot targets
// stay full-strength at any range (billboards keep their normal
// fade — full-opacity glyph walls read as overwhelming).
baseColor: flightState._irBoost
? Cesium.Color.WHITE
: _modelColor(icao24),
alpha: flightState._irBoost ? 1 : treatment.alpha,
});
},
);
if (ownsVisual) continue; // skip billboard rotation
}
if (
(!flightState._cockpitContactMode || isCockpitNear) &&
(doRotations || revealed)
) {
const rot = screenProjectedRotation(
scene,
bb.position,
course,
bb.rotation,
);
if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) {
bb.rotation = rot;
}
}
}
}
return {
_fleetBillboardColor,
_fleetBillboardScale,
_groundDepthDistance,
_modelSpec,
_normalBillboardScaleByDistance,
_cockpitBillboardScaleByDistance,
_iconKind,
_applyFleetBillboardPresentation,
_modelColor,
_modelRegimeActive,
_modelCap,
_modelAddDistM,
_modelKeepDistM,
_modelMatrix,
_groundSampleExclusions,
_modelDisplayPosition,
_driveFleetModelHandoff,
_modelOwnsVisual,
_modelIsRendering,
_specKeyFor,
_syncModelToClass,
_reloadModelsForIrBoost,
_drainIrReloadQueue,
_ensureModel,
_releaseModel,
_cleanupModelGen,
_releaseModels,
_releaseTrackedModel,
_updateTrackedModel,
_fleetTick,
};
}