diff --git a/src/data/fleetModelInstances.js b/src/data/fleetModelInstances.js index fbd710193..e3147c812 100644 --- a/src/data/fleetModelInstances.js +++ b/src/data/fleetModelInstances.js @@ -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} [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; }, diff --git a/src/data/fleetModelInstances.test.mjs b/src/data/fleetModelInstances.test.mjs index 298ef56df..1892e1751 100644 --- a/src/data/fleetModelInstances.test.mjs +++ b/src/data/fleetModelInstances.test.mjs @@ -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', + ); +}); diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index 908b45001..e11bd0783 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -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)); +}); diff --git a/src/layers/flights/modelSpec.js b/src/layers/flights/modelSpec.js index f063d8827..b9aa95379 100644 --- a/src/layers/flights/modelSpec.js +++ b/src/layers/flights/modelSpec.js @@ -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>} 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 diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index 15d2bb742..db124b752 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -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(); diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 4f4564bc6..8a4dc3a87 100644 --- a/src/layers/rail/selection.js +++ b/src/layers/rail/selection.js @@ -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; }; } diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 7a9f2e074..32633e775 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -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; diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 7ba55c47f..0888945a4 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -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; diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index fd066ca55..d557b1b99 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -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) && diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index d927def6d..d204bfadb 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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({ diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index a5c27d637..45be14033 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -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); }