fix(vessels, transit, rail): fleet models keep their cards and detection, stand on the floor, clip ships at the waterline and follow NVG/FLIR

- a vehicle drawn as a model keeps its selected card and stays in the detection overlay
- transit and rail models drop the 1.5 m floor lift their icons carry
- vessel models are clipped below the waterline
- models switch to an unlit white look under NVG/FLIR and back
- unknown transit modes keep their icon
- aircraft model specs are cached per set; no key-array copies per frame
This commit is contained in:
Sameh Khamis committed 2026-11-13 07:16:25 -08:00
1 parent 058ed0ffeb
commit a44c42eb94
11 files changed
+342 -89

No files matched your search

+59 -12
View File
@@ -46,6 +46,11 @@ export function createFleetModel(
const tintShaders = new Map();
/** Models under the NVG/FLIR styles render unlit, as aircraft models do. */
const IR_UNLIT_SHADER = new Cesium.CustomShader({
lightingModel: Cesium.LightingModel.UNLIT,
});
/**
* The custom shader that paints a route colour where a transit model's
* `_TINT` vertex attribute marks it: drawn colour = vertex colour ×
@@ -131,23 +136,34 @@ const scratchScale = new Cesium.Cartesian3();
* `modelOwnsVisual` back from each item and hides that icon.
*
* Each frame the layer passes its vehicles as items:
* `{position, headingDeg, spec, tint, pickId, show}`, `position` the point
* the model's origin stands on (the waterline, the road or rail top; the
* spec's `elevationM` is added here), `tint` a Cesium.Color for a tinted
* model's route colour (or null), `show` false to keep it hidden.
* `{position, headingDeg, spec, tint, pickId, show, groundLiftM}`,
* `position` the point the model's origin stands on (the waterline, the road
* or rail top; the spec's `elevationM` is added here), `groundLiftM` any lift
* the layer's icon carries above that point (taken off again), `tint` a
* Cesium.Color for a tinted model's route colour (or null), `show` false to
* keep it hidden.
*
* Under the luminance-mapped NVG/FLIR styles (`irBoost`) models render
* unlit flat white, as aircraft models do. With `waterline`, each model is
* clipped at its origin's horizontal plane and only the part above it is
* drawn: a ship's hull below the waterline would otherwise show through a
* plain globe, which Cesium does not depth-test models against.
* @param {object} options
* @param {object} options.viewer Cesium viewer.
* @param {() => object|null} [options.display] `{models3d, models3dMode}`.
* @param {() => object|null} [options.display] `{models3d, models3dMode,
* irBoost}`.
* @param {object} options.policy `{altitudeCeilingM, minimumPixelSize,
* cap, capAll, addM, addAllM, keepM, keepAllM, intervalMs}`.
* @param {(url: string) => string} [options.resolveAsset]
* @param {(spec: object, options: object) => Promise<object>} [options.createModel]
* @param {boolean} [options.waterline] Clip each model at its waterline.
* @returns {object}
*/
export function createFleetModelLayer({
viewer,
display = () => null,
policy,
waterline = false,
resolveAsset = (url) => url,
createModel = (spec, options) =>
createFleetModel(spec, options, { resolveAsset }),
@@ -163,6 +179,8 @@ export function createFleetModelLayer({
let lastEligibilityAt = -Infinity;
let eligible = new Set();
let settling = 0;
/** Whether the models drawn now were built for the NVG/FLIR styles. */
let irBoost = false;
const scratchHpr = new Cesium.HeadingPitchRoll();
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
const scratchCarto = new Cesium.Cartographic();
@@ -177,7 +195,7 @@ export function createFleetModelLayer({
}
function releaseAll() {
for (const id of [...models.keys()]) release(id);
for (const id of models.keys()) release(id);
pending.clear();
generation += 1;
}
@@ -193,11 +211,23 @@ export function createFleetModelLayer({
}
function shaderFor(item) {
if (irBoost) return IR_UNLIT_SHADER;
return item.spec.tinted && item.tint
? fleetTintShader(item.tint)
: undefined;
}
/** The clipping that keeps a model's part above its waterline. */
function waterlineClipping() {
return new Cesium.ClippingPlaneCollection({
// The planes sit in the model matrix's frame: its origin is the
// waterline and its Z axis the local vertical, so the plane z = 0 with
// its normal up keeps the hull above the water.
planes: [new Cesium.ClippingPlane(Cesium.Cartesian3.UNIT_Z, 0)],
edgeWidth: 0,
});
}
async function ensure(id, item, cap) {
const key = item.spec.key;
const current = models.get(id);
@@ -213,6 +243,14 @@ export function createFleetModelLayer({
minimumPixelSize: policy.minimumPixelSize,
scale: item.spec.scale,
customShader: shaderFor(item),
...(irBoost
? {
color: Cesium.Color.WHITE,
colorBlendMode: Cesium.ColorBlendMode.MIX,
colorBlendAmount: 1,
}
: {}),
...(waterline ? { clippingPlanes: waterlineClipping() } : {}),
id: item.pickId ?? id,
});
} catch {
@@ -232,7 +270,8 @@ export function createFleetModelLayer({
}
model.id = item.pickId ?? id;
model._gevSpecKey = key;
model._gevTint = item.spec.tinted ? item.tint : null;
model._gevTint = item.spec.tinted && !irBoost ? item.tint : null;
model._gevIrBoost = irBoost;
// Admitted, not yet the visual: placement turns it on.
model.show = false;
collection.add(model);
@@ -272,14 +311,14 @@ export function createFleetModelLayer({
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 models.keys()) if (!eligible.has(id)) release(id);
for (const id of pending.keys()) if (!eligible.has(id)) release(id);
for (const id of eligible) void ensure(id, items.get(id), cap);
}
/** Where the model's origin stands: the item's point raised by the spec's elevation. */
function placement(item) {
const lift = item.spec.elevationM || 0;
const lift = (item.spec.elevationM || 0) - (item.groundLiftM || 0);
if (!lift) return item.position;
const carto = Cesium.Cartographic.fromCartesian(
item.position,
@@ -320,6 +359,14 @@ export function createFleetModelLayer({
for (const item of items.values()) item.modelOwnsVisual = false;
return false;
}
// A model's look under NVG/FLIR is set when it is built (a custom
// shader on a ready model does not take), so a style change rebuilds.
const boost = prefs.irBoost === true;
if (boost !== irBoost) {
releaseAll();
irBoost = boost;
lastEligibilityAt = -Infinity;
}
if (nowMs - lastEligibilityAt >= policy.intervalMs) {
lastEligibilityAt = nowMs;
refreshEligibility(items, prefs);
@@ -345,7 +392,7 @@ export function createFleetModelLayer({
lastEligibilityAt = -Infinity;
continue;
}
const tint = item.spec.tinted ? item.tint : null;
const tint = item.spec.tinted && !irBoost ? item.tint : null;
if (tint && !Cesium.Color.equals(tint, model._gevTint)) {
model.customShader = fleetTintShader(tint);
model._gevTint = tint;
@@ -366,7 +413,7 @@ export function createFleetModelLayer({
item.modelOwnsVisual = true;
drawn = true;
}
for (const id of [...models.keys()]) if (!items.has(id)) release(id);
for (const id of models.keys()) if (!items.has(id)) release(id);
return drawn;
},
+127
View File
@@ -358,3 +358,130 @@ test('Vessels, Transit and Rail draw fleet models under the shared 3D toggle', (
'Local ADS-B, Vessels, Transit and Rail follow the Flights 3D toggle',
);
});
/** A layer whose models are all ready as soon as they are created. */
function readyLayer({ display, waterline = false } = {}) {
const viewer = fakeViewer();
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => display,
policy: POLICY,
waterline,
createModel: async (spec, options) => {
const model = fakeModel(options);
model.ready = true;
created.push(model);
return model;
},
});
return { layer, created };
}
test('a model stands on the floor its icon is lifted above', async () => {
const { layer, created } = readyLayer({ display: { models3d: true } });
const tram = transitModelSpec(set, { mode: 'tram' });
const item = itemAt(10, tram, { pickId: 't', groundLiftM: 1.5 });
const items = new Map([['t', item]]);
layer.frame(items, 0);
await flush();
layer.frame(items, 100);
const origin = Cesium.Matrix4.getTranslation(
created[0].modelMatrix,
new Cesium.Cartesian3(),
);
const drop =
Cesium.Cartographic.fromCartesian(item.position).height -
Cesium.Cartographic.fromCartesian(origin).height;
assert.ok(Math.abs(drop - 1.5) < 1e-3, `dropped ${drop} m`);
layer.destroy();
});
test('under NVG/FLIR models are rebuilt unlit white, and rebuilt again on exit', async () => {
const display = { models3d: true, irBoost: false };
const { layer, created } = readyLayer({ display });
const tram = transitModelSpec(set, { mode: 'tram' });
const items = new Map([
['t', itemAt(10, tram, { pickId: 't', tint: Cesium.Color.RED })],
]);
layer.frame(items, 0);
await flush();
assert.equal(created[0].customShader, fleetTintShader(Cesium.Color.RED));
display.irBoost = true;
layer.frame(items, 100);
assert.ok(created[0].destroyed, 'the lit model is released');
await flush();
const boosted = created[1];
assert.equal(boosted.customShader.lightingModel, Cesium.LightingModel.UNLIT);
assert.ok(Cesium.Color.equals(boosted.color, Cesium.Color.WHITE));
assert.equal(boosted.colorBlendAmount, 1);
display.irBoost = false;
layer.frame(items, 200);
await flush();
assert.ok(boosted.destroyed);
assert.equal(created[2].customShader, fleetTintShader(Cesium.Color.RED));
layer.destroy();
});
test('a waterline layer clips each model at its origin, keeping the part above', async () => {
const { layer, created } = readyLayer({
display: { models3d: true },
waterline: true,
});
const ship = vesselModelSpec(set, { type: '70', length: 200, beam: 32 });
layer.frame(new Map([['s', itemAt(10, ship, { pickId: 's' })]]), 0);
await flush();
const planes = created[0].clippingPlanes;
assert.ok(planes instanceof Cesium.ClippingPlaneCollection);
assert.equal(planes.length, 1);
const plane = planes.get(0);
assert.equal(plane.distance, 0);
assert.ok(Cesium.Cartesian3.equals(plane.normal, Cesium.Cartesian3.UNIT_Z));
assert.equal(planes.unionClippingRegions, false);
// Cesium keeps a point where normal · p + distance >= 0: above the waterline.
const above = Cesium.Cartesian3.dot(
plane.normal,
new Cesium.Cartesian3(0, 0, 3),
);
const below = Cesium.Cartesian3.dot(
plane.normal,
new Cesium.Cartesian3(0, 0, -3),
);
assert.ok(above + plane.distance > 0 && below + plane.distance < 0);
layer.destroy();
const plain = readyLayer({ display: { models3d: true } });
plain.layer.frame(
new Map([['t', itemAt(10, transitModelSpec(set, { mode: 'tram' }))]]),
0,
);
await flush();
assert.equal(plain.created[0].clippingPlanes, undefined, 'others unclipped');
plain.layer.destroy();
});
test('a vehicle drawn as its model keeps its card and its detection entry', () => {
const read = (file) =>
fs.readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
assert.match(
read('src/layers/transit/selection.js'),
/marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/,
);
assert.match(
read('src/layers/rail/selection.js'),
/entry\.marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/,
);
assert.match(
read('src/layers/transit/queries.js'),
/marker\.show === false && entry\.modelOwnsVisual !== true/,
);
assert.match(
read('src/layers/vessels/queries.js'),
/!visual\.billboard\.show &&\s*!visual\.modelOwnsVisual/,
);
assert.match(
read('src/layers/transit/rendering.js'),
/entry\.mode === 'unknown'/,
'a vehicle of unknown mode keeps its icon',
);
});
+8
View File
@@ -288,3 +288,11 @@ test('civilian and military globe-view aircraft retain the established 3.0 near
);
}
});
test('a layer asking every tick gets one cached spec per type and category', async () => {
const { aircraftLayerModelSpec } = await import('../layers/flights/modelSpec.js');
const a = aircraftLayerModelSpec({ typeCode: 'A320' }, set);
assert.equal(aircraftLayerModelSpec({ typeCode: 'A320' }, set), a);
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
assert.ok(Object.isFrozen(a));
});
+21 -9
View File
@@ -37,17 +37,29 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
*/
export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
if (!set) return UNLOADED_AIRCRAFT_MODEL_SPEC;
const spec = aircraftModelSpec(set, {
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
category: aircraft?.category ?? null,
});
return Object.freeze({
...spec,
bellyM: spec.clearanceM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
});
// Layers ask on every tick; each type and category is resolved once per set.
let cache = specCaches.get(set);
if (!cache) specCaches.set(set, (cache = new Map()));
const key = aircraftModelSpecCacheKey(aircraft);
let layerSpec = cache.get(key);
if (!layerSpec) {
const spec = aircraftModelSpec(set, {
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
category: aircraft?.category ?? null,
});
layerSpec = Object.freeze({
...spec,
bellyM: spec.clearanceM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
});
cache.set(key, layerSpec);
}
return layerSpec;
}
/** @type {WeakMap<object, Map<string, object>>} set -> cache key -> spec */
const specCaches = new WeakMap();
/**
* Cache key of an aircraft's model spec: what selects its model.
* @param {object|null|undefined} aircraft
+16 -5
View File
@@ -696,7 +696,9 @@ export function createRendering({ state, services, parts }) {
// aircraft layers' icon-to-model rules (services.fleet). The fleet set
// loads once, up front, when the layer starts.
let fleetModels = null;
/** Items drawn last frame, and the map the next frame's are gathered into. */
let modelItems = new Map();
let spareItems = new Map();
let modelCandidatesAtMs = -Infinity;
/** Load the fleet set and create the layer's models (on enable). */
@@ -724,7 +726,13 @@ export function createRendering({ state, services, parts }) {
function modelItem(entry, set) {
let item = modelItems.get(entry.key);
if (!item || item.entry !== entry)
item = { entry, pickId: entry.key, modelOwnsVisual: false };
item = {
entry,
pickId: entry.key,
modelOwnsVisual: false,
// Markers are drawn this far above the track; a model stands on it.
groundLiftM: GROUND_FLOOR_LIFT_M,
};
const specKey = `${entry.category}|${entry.railMode}`;
if (item.specKey !== specKey) {
item.specKey = specKey;
@@ -744,15 +752,16 @@ export function createRendering({ state, services, parts }) {
if (!fleetModels) return;
if (!fleetModels.active() || state._trains.size === 0) {
if (modelItems.size) {
fleetModels.frame(new Map(), performance.now());
for (const item of modelItems.values()) handBack(item.entry);
modelItems = new Map();
modelItems.clear();
fleetModels.frame(modelItems, performance.now());
}
return;
}
const set = fleetModelSet();
const now = performance.now();
const next = new Map();
const next = spareItems;
next.clear();
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
modelCandidatesAtMs = now;
const camera = state._viewer?.camera;
@@ -781,6 +790,7 @@ export function createRendering({ state, services, parts }) {
}
for (const [key, item] of modelItems)
if (next.get(key) !== item) handBack(item.entry);
spareItems = modelItems;
modelItems = next;
fleetModels.frame(modelItems, now);
for (const item of modelItems.values()) {
@@ -796,7 +806,8 @@ export function createRendering({ state, services, parts }) {
/** Drop every model (layer disabled); `destroy` also removes the collection. */
function stopModels(destroy = false) {
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
modelItems = new Map();
modelItems.clear();
spareItems.clear();
modelCandidatesAtMs = -Infinity;
if (destroy) {
fleetModels?.destroy();
+7 -2
View File
@@ -19,10 +19,15 @@ import { t } from '../../i18n/index.js';
export function createSelection({ state, services, parts }) {
const { governorRequestRender } = services.render;
/** The card's anchor: the marker's current position, read on every paint. */
/**
* The card's anchor: the marker's current position, read on every paint,
* whether the train is drawn as its marker or as its 3D model.
*/
function anchorFor(entry) {
return () => {
const p = entry.marker?.show !== false ? entry.marker?.position : null;
const drawn =
entry.marker?.show !== false || entry.modelOwnsVisual === true;
const p = drawn ? entry.marker?.position : null;
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
};
}
+3 -1
View File
@@ -129,7 +129,9 @@ export function createQueries({ state, parts }) {
const visible = [];
for (const entry of state._visible) {
const marker = entry.marker;
if (!marker?.position || marker.show === false) continue;
// A vehicle drawn as its 3D model is present though its marker is hidden.
if (!marker?.position) continue;
if (marker.show === false && entry.modelOwnsVisual !== true) continue;
visible.push(entry);
}
let chosen = visible;
+20 -6
View File
@@ -468,7 +468,11 @@ export function createRendering({ state, services, parts }) {
// icon-to-model rules (services.fleet). The fleet set loads once, up front,
// when the layer starts.
let fleetModels = null;
/** Items drawn last frame, and the map the next frame's are gathered into. */
let modelItems = new Map();
let spareItems = new Map();
/** The lift icons are drawn with above the floor; a model stands on the floor. */
const groundLiftM = services.ground?.GROUND_FLOOR_LIFT_M || 0;
/** Load the fleet set and create the layer's models (on enable). */
function startModels() {
@@ -500,19 +504,27 @@ export function createRendering({ state, services, parts }) {
if (!fleetModels) return;
if (!fleetModels.active() || state._vehicles.size === 0) {
if (modelItems.size) {
fleetModels.frame(new Map(), performance.now());
for (const item of modelItems.values()) handBack(item.entry);
modelItems = new Map();
modelItems.clear();
fleetModels.frame(modelItems, performance.now());
}
return;
}
const set = fleetModelSet();
const next = new Map();
const next = spareItems;
next.clear();
for (const entry of state._visible) {
if (!entry.marker || !entry.hasRendered) continue;
// A vehicle of no known mode keeps its icon: no model says what it is.
if (!entry.marker || !entry.hasRendered || entry.mode === 'unknown')
continue;
let item = modelItems.get(entry.key);
if (!item || item.entry !== entry) {
item = { entry, pickId: entry.key, modelOwnsVisual: false };
item = {
entry,
pickId: entry.key,
modelOwnsVisual: false,
groundLiftM,
};
}
if (item.mode !== entry.mode) {
item.mode = entry.mode;
@@ -527,6 +539,7 @@ export function createRendering({ state, services, parts }) {
}
for (const [key, item] of modelItems)
if (next.get(key) !== item) handBack(item.entry);
spareItems = modelItems;
modelItems = next;
fleetModels.frame(modelItems, performance.now());
for (const item of modelItems.values()) {
@@ -542,7 +555,8 @@ export function createRendering({ state, services, parts }) {
/** Drop every model (layer disabled); `destroy` also removes the collection. */
function stopModels(destroy = false) {
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
modelItems = new Map();
modelItems.clear();
spareItems.clear();
if (destroy) {
fleetModels?.destroy();
fleetModels = null;
+4 -2
View File
@@ -24,14 +24,16 @@ export function createSelection({ state, services, parts }) {
* (the tracked-aircraft readout uses the same), so the anchor moves with the
* sprite at frame rate, through poll arrivals, settling, a late floor and a
* reset alike, with no per-frame work here. Null when there is nothing to
* anchor to: a hidden or removed marker draws no card.
* anchor to: a hidden or removed marker draws no card, unless the vehicle's
* 3D model is drawing it in the marker's place.
* @param {object} entry
* @returns {() => Cesium.Cartesian3|null}
*/
function anchorFor(entry) {
return () => {
const marker = entry.marker;
const p = marker?.show !== false ? marker?.position : null;
const drawn = marker?.show !== false || entry.modelOwnsVisual === true;
const p = drawn ? marker?.position : null;
return p &&
Number.isFinite(p.x) &&
Number.isFinite(p.y) &&
+7 -1
View File
@@ -664,7 +664,13 @@ export function createQueries({
if ((idx - start) % stride !== 0) continue;
const record = records[idx];
const visual = components.rendering.getVisual(record);
if (visual.billboard && !visual.billboard.show) continue;
// A vessel drawn as its model is present though its chevron is hidden.
if (
visual.billboard &&
!visual.billboard.show &&
!visual.modelOwnsVisual
)
continue;
const position = visual.billboard?.position || visual.position;
if (!position) continue;
result.push({
+70 -51
View File
@@ -224,8 +224,11 @@ export function createRendering({
// on the aircraft layers' icon-to-model rules (services.fleet). The fleet
// set loads once, up front, when the layer starts.
let fleetModels = null;
/** Items drawn last frame, and the map the next frame's are gathered into. */
let modelItems = new Map();
let spareItems = new Map();
let modelCandidatesAtMs = -Infinity;
const modelOccluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
function ensureModels(viewer) {
const fleet = services.fleet;
@@ -238,96 +241,109 @@ export function createRendering({
display: fleet.display,
policy: fleet.policy,
resolveAsset: fleet.resolveAsset,
// A hull below the waterline would show through a plain globe.
waterline: true,
});
}
/**
* Whether a vessel's chevron is drawn: on the camera's side of the Earth,
* not the bridge camera's own vessel, and not drawn by its model. The
* visibility pass and the model handoff both decide by this.
*/
function billboardShown(record, occluder) {
const visual = getVisual(record);
return (
isVisible(visual.surfacePosition, occluder) &&
record.id !== state.cockpitSubjectId &&
!visual.modelOwnsVisual
);
}
/** The model item for `record`, its spec rebuilt only when its type or size changes. */
function modelItem(record, set) {
let item = modelItems.get(record.id);
if (!item || item.record !== record)
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`;
if (!item) {
item = { pickId: record, tint: null, modelOwnsVisual: false };
modelItems.set(record.id, item);
}
if (item.specKey !== specKey) {
item.specKey = specKey;
item.spec = vesselModelSpec(set, record);
}
const visual = getVisual(record);
item.record = record;
item.position = visual.waterline;
item.position = getVisual(record).waterline;
item.headingDeg = vesselCourseDeg(record);
item.show = record.id !== state.cockpitSubjectId;
return item;
}
/** Gather the vessels within the models' keep radius of the camera. */
function gatherModelItems(set, prefs) {
/** Gather the vessels within the models' keep radius of the camera into `into`. */
function gatherModelItems(set, into) {
const camera = state.viewer?.camera;
if (!camera?.positionWC) return;
const policy = services.fleet.policy;
const keepM =
prefs?.models3dMode === 'all' ? policy.keepAllM : policy.keepM;
const next = new Map();
if (camera?.positionWC) {
const limitSq = keepM * keepM * 1.44;
for (const record of state.records.all) {
const visual = getVisual(record);
if (!visual.waterline) continue;
if (
Cesium.Cartesian3.distanceSquared(
camera.positionWC,
visual.waterline,
) > limitSq
)
continue;
next.set(record.id, modelItem(record, set));
}
services.fleet.display?.()?.models3dMode === 'all'
? policy.keepAllM
: policy.keepM;
const limitSq = keepM * keepM * 1.44;
for (const record of state.records.all) {
const waterline = getVisual(record).waterline;
if (!waterline) continue;
if (
Cesium.Cartesian3.distanceSquared(camera.positionWC, waterline) >
limitSq
)
continue;
into.set(record.id, modelItem(record, set));
}
for (const [id, item] of modelItems)
if (!next.has(id)) handBackVisual(item);
modelItems = next;
}
/** Give a vessel's icon back when its model no longer draws it. */
function handBackVisual(item) {
if (!item.ownedBillboard) return;
item.ownedBillboard = false;
const billboard = getVisual(item.record).billboard;
if (billboard)
billboard.show =
isVisible(getVisual(item.record).surfacePosition, makeOccluder()) &&
item.record.id !== state.cockpitSubjectId;
/** Give a vessel's chevron back when its model no longer draws it. */
function handBackVisual(record, occluder) {
const visual = getVisual(record);
if (!visual.modelOwnsVisual) return;
visual.modelOwnsVisual = false;
if (visual.billboard)
visual.billboard.show = billboardShown(record, occluder);
}
function updateModels() {
if (!fleetModels) return;
const camera = state.viewer?.camera;
const occluder = camera?.positionWC ? modelOccluder : null;
if (occluder) occluder.cameraPosition = camera.positionWC;
if (!fleetModels.active()) {
if (modelItems.size) {
modelItems.clear();
fleetModels.frame(modelItems, performance.now());
for (const item of modelItems.values()) handBackVisual(item);
modelItems = new Map();
for (const record of state.records.all)
handBackVisual(record, occluder);
}
return;
}
const set = fleetModelSet();
const now = performance.now();
const next = spareItems;
next.clear();
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
modelCandidatesAtMs = now;
gatherModelItems(set, services.fleet.display?.());
gatherModelItems(set, next);
} else {
for (const [id, item] of modelItems) {
for (const [id, item] of modelItems)
if (state.records.byId.get(id) === item.record)
modelItem(item.record, set);
else modelItems.delete(id);
}
next.set(id, modelItem(item.record, set));
}
for (const [id, item] of modelItems)
if (next.get(id) !== item) handBackVisual(item.record, occluder);
spareItems = modelItems;
modelItems = next;
fleetModels.frame(modelItems, now);
for (const item of modelItems.values()) {
const billboard = getVisual(item.record).billboard;
const visual = getVisual(item.record);
if (item.modelOwnsVisual) {
item.ownedBillboard = true;
if (billboard) billboard.show = false;
} else handBackVisual(item);
visual.modelOwnsVisual = true;
if (visual.billboard) visual.billboard.show = false;
} else handBackVisual(item.record, occluder);
}
if (fleetModels.settling) governorRequestRender('ais-vessels-models');
}
@@ -336,6 +352,7 @@ export function createRendering({
fleetModels?.destroy();
fleetModels = null;
modelItems = new Map();
spareItems = new Map();
modelCandidatesAtMs = -Infinity;
}
@@ -443,10 +460,11 @@ export function createRendering({
const visible = isVisible(visual.surfacePosition, occluder);
if (visual.billboard) {
// The bridge camera sits on this sprite; showing it would fill the view.
// The same rule as billboardShown, its horizon test already made.
visual.billboard.show =
visible &&
record.id !== state.cockpitSubjectId &&
!modelItems.get(record.id)?.ownedBillboard;
!visual.modelOwnsVisual;
if (visible && doRotations && scene) {
const rot = screenProjectedRotation(
scene,
@@ -724,8 +742,9 @@ export function createRendering({
fleetModels?.setVisible(show);
if (!show && fleetModels) {
fleetModels.clear();
for (const item of modelItems.values()) item.ownedBillboard = false;
modelItems = new Map();
for (const item of modelItems.values())
getVisual(item.record).modelOwnsVisual = false;
modelItems.clear();
}
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
}