mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
577 lines
19 KiB
JavaScript
577 lines
19 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
|
|
/**
|
|
* Focus-aware sprite treatment shared by the live contact layers.
|
|
*
|
|
* The selected target is protected by changing ambient sprite alpha, never by
|
|
* collection ordering: Cesium's translucent distance sort is not stable for
|
|
* collections with similar bounding volumes. This is a narrow amendment to
|
|
* the normal always-visible contact rule. Ambient contacts remain present and
|
|
* never fall below `dimFloor`; only sprites competing with the tracked target
|
|
* are smoothly de-emphasized.
|
|
*
|
|
* The production consumers sample this service every 80 ms. The default
|
|
* 300 ms attack and 600 ms release deliberately span several consumer
|
|
* quanta, so a contact receives at least four distinct rendered emphasis
|
|
* values during attack instead of crossing the range in one or two ticks.
|
|
*/
|
|
|
|
export const DEFAULT_FOCUS_DEEMPHASIS_PARAMS = Object.freeze({
|
|
paddingPx: 18,
|
|
dimFloor: 0.25,
|
|
nearerBehavior: 'allow',
|
|
hysteresisPx: 6,
|
|
distanceHysteresisRatio: 0.08,
|
|
attackMs: 300,
|
|
releaseMs: 600,
|
|
writeEpsilon: 0.005,
|
|
});
|
|
|
|
let _params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS };
|
|
let _focusTarget = null;
|
|
const _spriteStates = new WeakMap();
|
|
const _scratchScreen = new Cesium.Cartesian2();
|
|
const _advanceResult = {
|
|
factor: 1,
|
|
changed: false,
|
|
transitioning: false,
|
|
active: false,
|
|
desired: 1,
|
|
};
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function resolvedParams(overrides) {
|
|
if (!overrides || overrides === _params) return _params;
|
|
return { ..._params, ...overrides };
|
|
}
|
|
|
|
function focusTargetEmphasisResolved(
|
|
spriteScreenPosition,
|
|
spriteCameraDistance,
|
|
target,
|
|
tuning,
|
|
previouslyCompeting,
|
|
spriteHalfWidthPx,
|
|
spriteHalfHeightPx,
|
|
previouslyFarther,
|
|
) {
|
|
if (!target?.screenRect || !spriteScreenPosition) return 1;
|
|
if (
|
|
!Number.isFinite(spriteScreenPosition.x) ||
|
|
!Number.isFinite(spriteScreenPosition.y)
|
|
)
|
|
return 1;
|
|
|
|
// Published rects already contain their publication-time padding. The
|
|
// delta keeps direct pure-function calls and runtime A/B overrides honest.
|
|
// The ambient extent makes overlap mean visual overlap, not center-point
|
|
// containment: a class-sized icon whose edge intrudes into the focus rect
|
|
// yields even when its center remains just outside it.
|
|
const paddingDelta =
|
|
tuning.paddingPx -
|
|
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
|
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
|
|
const expansionX =
|
|
paddingDelta +
|
|
hysteresis +
|
|
Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
|
const expansionY =
|
|
paddingDelta +
|
|
hysteresis +
|
|
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
|
const rect = target.screenRect;
|
|
const inside =
|
|
spriteScreenPosition.x >= rect.left - expansionX &&
|
|
spriteScreenPosition.x <= rect.right + expansionX &&
|
|
spriteScreenPosition.y >= rect.top - expansionY &&
|
|
spriteScreenPosition.y <= rect.bottom + expansionY;
|
|
if (!inside) return 1;
|
|
|
|
const floor = clamp(tuning.dimFloor, 0.01, 1);
|
|
if (tuning.nearerBehavior === 'dim') return floor;
|
|
|
|
const distanceBand = Number.isFinite(target.cameraDistance)
|
|
? Math.max(0, target.cameraDistance * tuning.distanceHysteresisRatio)
|
|
: 0;
|
|
const fartherThreshold = Number.isFinite(target.cameraDistance)
|
|
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
|
|
: Number.NaN;
|
|
const farther =
|
|
!Number.isFinite(spriteCameraDistance) ||
|
|
!Number.isFinite(fartherThreshold) ||
|
|
spriteCameraDistance >= fartherThreshold;
|
|
if (farther) return floor;
|
|
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
|
|
return 1;
|
|
}
|
|
|
|
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
|
const from = clamp(
|
|
Number.isFinite(current) ? current : 1,
|
|
tuning.dimFloor,
|
|
1,
|
|
);
|
|
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
|
|
if (Math.abs(to - from) < Number.EPSILON) return to;
|
|
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
|
|
if (duration <= 0) return to;
|
|
const t = clamp(
|
|
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
|
|
0,
|
|
1,
|
|
);
|
|
// Cubic ease-out reaches the exact endpoint at the configured duration and
|
|
// stays continuous when a moving contact reverses direction mid-transition.
|
|
const eased = 1 - (1 - t) ** 3;
|
|
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
|
|
}
|
|
|
|
/**
|
|
* Replace the runtime tuning values used by every focus consumer.
|
|
* Unknown keys are ignored so evidence-harness overrides cannot accidentally
|
|
* grow an undocumented rendering contract.
|
|
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [patch]
|
|
* @returns {typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS} Current normalized values.
|
|
*/
|
|
export function setFocusDeemphasisParams(patch = {}) {
|
|
const next = { ..._params };
|
|
if (Number.isFinite(patch.paddingPx))
|
|
next.paddingPx = Math.max(0, patch.paddingPx);
|
|
if (Number.isFinite(patch.dimFloor))
|
|
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
|
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
|
|
next.nearerBehavior = patch.nearerBehavior;
|
|
}
|
|
if (Number.isFinite(patch.hysteresisPx))
|
|
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
|
if (Number.isFinite(patch.distanceHysteresisRatio)) {
|
|
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
|
|
}
|
|
if (Number.isFinite(patch.attackMs))
|
|
next.attackMs = Math.max(0, patch.attackMs);
|
|
if (Number.isFinite(patch.releaseMs))
|
|
next.releaseMs = Math.max(0, patch.releaseMs);
|
|
if (Number.isFinite(patch.writeEpsilon))
|
|
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
|
_params = next;
|
|
return { ..._params };
|
|
}
|
|
|
|
/** @returns {typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS} Current runtime tuning. */
|
|
export function getFocusDeemphasisParams() {
|
|
return _params;
|
|
}
|
|
|
|
/** Restore production defaults. Primarily useful for deterministic QA. */
|
|
export function resetFocusDeemphasisParams() {
|
|
_params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS };
|
|
}
|
|
|
|
/**
|
|
* Publish a focus target from the exact world position already cached by its
|
|
* tracking/render path. Callers must never re-derive a position here: a second
|
|
* propagation/dead-reckoning sample at a different frame phase recreates the
|
|
* tracked-target jitter class this service is designed to avoid.
|
|
*
|
|
* @param {object} input
|
|
* @param {string} input.ownerLayer
|
|
* @param {string|number} input.id
|
|
* @param {Cesium.Scene} input.scene
|
|
* @param {Cesium.Camera} input.camera
|
|
* @param {Cesium.Cartesian3} input.displayPosition Cached display position.
|
|
* @param {number} [input.widthPx=24] Projected visual width before padding.
|
|
* @param {number} [input.heightPx=24] Projected visual height before padding.
|
|
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [input.params]
|
|
* @returns {object|null} Published target, or null when it cannot be projected.
|
|
*/
|
|
export function publishFocusTargetFromCachedPosition({
|
|
ownerLayer,
|
|
id,
|
|
scene,
|
|
camera,
|
|
displayPosition,
|
|
widthPx = 24,
|
|
heightPx = 24,
|
|
params,
|
|
}) {
|
|
if (
|
|
!ownerLayer ||
|
|
id === null ||
|
|
id === undefined ||
|
|
!scene ||
|
|
!camera ||
|
|
!displayPosition
|
|
) {
|
|
clearFocusTarget(ownerLayer, id);
|
|
return null;
|
|
}
|
|
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
|
scene,
|
|
displayPosition,
|
|
_scratchScreen,
|
|
);
|
|
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
|
|
clearFocusTarget(ownerLayer, id);
|
|
return null;
|
|
}
|
|
const tuning = resolvedParams(params);
|
|
const padding = tuning.paddingPx;
|
|
const halfWidth = Math.max(1, widthPx) / 2;
|
|
const halfHeight = Math.max(1, heightPx) / 2;
|
|
const appeared = !_focusTarget;
|
|
_focusTarget = {
|
|
ownerLayer,
|
|
id,
|
|
screenRect: {
|
|
left: screen.x - halfWidth - padding,
|
|
top: screen.y - halfHeight - padding,
|
|
right: screen.x + halfWidth + padding,
|
|
bottom: screen.y + halfHeight + padding,
|
|
},
|
|
paddingPx: padding,
|
|
cameraDistance: Cesium.Cartesian3.distance(
|
|
camera.positionWC,
|
|
displayPosition,
|
|
),
|
|
frameNumber: scene.frameState?.frameNumber ?? -1,
|
|
};
|
|
if (appeared) {
|
|
for (const listener of _focusAppearListeners) {
|
|
try {
|
|
listener();
|
|
} catch (e) {
|
|
console.warn('[focus] appear listener error:', e);
|
|
}
|
|
}
|
|
}
|
|
return _focusTarget;
|
|
}
|
|
|
|
const _focusAppearListeners = new Set();
|
|
|
|
/**
|
|
* Notify on the null→set edge of the shared focus target. Consumers that
|
|
* self-suspend their per-frame focus work (e.g. CCTV's projection loop) use
|
|
* this single edge to re-arm; the set→null edge needs no event because the
|
|
* consumer's own loop is still running through the relax tail and
|
|
* self-stops via focusPassIsNeeded. (perf wave 1)
|
|
* @param {() => void} listener Called when a focus target is published after none existed.
|
|
* @returns {() => void} Unsubscribe.
|
|
*/
|
|
export function onFocusTargetAppear(listener) {
|
|
_focusAppearListeners.add(listener);
|
|
return () => _focusAppearListeners.delete(listener);
|
|
}
|
|
|
|
/**
|
|
* Clear the focus only when the caller still owns it. This prevents idle
|
|
* tracked-layer ticks from erasing a target just published by another layer.
|
|
* @param {string} [ownerLayer]
|
|
* @param {string|number|null} [id]
|
|
*/
|
|
export function clearFocusTarget(ownerLayer, id = null) {
|
|
if (!_focusTarget) return;
|
|
if (ownerLayer && _focusTarget.ownerLayer !== ownerLayer) return;
|
|
if (id !== null && id !== undefined && _focusTarget.id !== id) return;
|
|
_focusTarget = null;
|
|
}
|
|
|
|
/** @returns {object|null} The current shared focus target. */
|
|
export function getFocusTarget() {
|
|
return _focusTarget;
|
|
}
|
|
|
|
/**
|
|
* Pure focus decision for one ambient sprite.
|
|
*
|
|
* `previouslyCompeting` is state, not tuning: it expands the exit edge by
|
|
* `hysteresisPx` without moving the entry edge. Farther sprites dim fully;
|
|
* nearer treatment deliberately remains a runtime tuning choice.
|
|
*
|
|
* @param {{x:number,y:number}|null} spriteScreenPosition
|
|
* @param {number} spriteCameraDistance
|
|
* @param {object|null} target
|
|
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
|
* @param {boolean} [previouslyCompeting=false]
|
|
* @param {number} [spriteHalfWidthPx=0] Ambient visual half-width.
|
|
* @param {number} [spriteHalfHeightPx=spriteHalfWidthPx] Ambient visual half-height.
|
|
* @param {boolean} [previouslyFarther=previouslyCompeting] Prior distance-side state.
|
|
* @returns {number} Target emphasis in [dimFloor, 1].
|
|
*/
|
|
export function focusTargetEmphasis(
|
|
spriteScreenPosition,
|
|
spriteCameraDistance,
|
|
target,
|
|
params,
|
|
previouslyCompeting = false,
|
|
spriteHalfWidthPx = 0,
|
|
spriteHalfHeightPx = spriteHalfWidthPx,
|
|
previouslyFarther = previouslyCompeting,
|
|
) {
|
|
const tuning = resolvedParams(params);
|
|
return focusTargetEmphasisResolved(
|
|
spriteScreenPosition,
|
|
spriteCameraDistance,
|
|
target,
|
|
tuning,
|
|
previouslyCompeting,
|
|
spriteHalfWidthPx,
|
|
spriteHalfHeightPx,
|
|
previouslyFarther,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pure time-based easing step. Attack means motion toward de-emphasis;
|
|
* release means restoration toward full emphasis.
|
|
* @param {number} current
|
|
* @param {number} target
|
|
* @param {number} elapsedMs
|
|
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
|
* @returns {number}
|
|
*/
|
|
export function smoothFocusEmphasis(current, target, elapsedMs, params) {
|
|
const tuning = resolvedParams(params);
|
|
return smoothFocusEmphasisResolved(current, target, elapsedMs, tuning);
|
|
}
|
|
|
|
/**
|
|
* Advance one sprite's persistent focus state. The caller owns the eventual
|
|
* color write so focus can compose with freshness/horizon alpha at one site.
|
|
* @param {object} sprite Stable billboard/point primitive identity.
|
|
* @param {object} input
|
|
* @param {{x:number,y:number}|null} input.screenPosition
|
|
* @param {number} input.cameraDistance
|
|
* @param {number} input.nowMs
|
|
* @param {object|null} [input.target]
|
|
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [input.params]
|
|
* @param {number} [input.spriteHalfWidthPx=0]
|
|
* @param {number} [input.spriteHalfHeightPx=input.spriteHalfWidthPx]
|
|
*
|
|
* IMPORTANT: the returned object is a module-owned mutable singleton. Consume
|
|
* every needed property before the next call; never retain it as a snapshot.
|
|
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
|
*/
|
|
export function advanceSpriteFocus(
|
|
sprite,
|
|
{
|
|
screenPosition,
|
|
cameraDistance,
|
|
nowMs,
|
|
target = _focusTarget,
|
|
params,
|
|
spriteHalfWidthPx = 0,
|
|
spriteHalfHeightPx = spriteHalfWidthPx,
|
|
},
|
|
) {
|
|
const tuning = resolvedParams(params);
|
|
let state = _spriteStates.get(sprite);
|
|
if (!state) {
|
|
if (!target) {
|
|
_advanceResult.factor = 1;
|
|
_advanceResult.changed = false;
|
|
_advanceResult.transitioning = false;
|
|
_advanceResult.active = false;
|
|
_advanceResult.desired = 1;
|
|
return _advanceResult;
|
|
}
|
|
state = {
|
|
factor: 1,
|
|
desired: 1,
|
|
transitionFrom: 1,
|
|
transitionStartMs: nowMs,
|
|
wasFarther: false,
|
|
};
|
|
_spriteStates.set(sprite, state);
|
|
}
|
|
|
|
// First sample the OLD transition at this timestamp. If the desired state
|
|
// changes, the new transition must begin at the value already on screen,
|
|
// not the prior tick's stale anchor. This preserves progress during a
|
|
// retarget and, paired with range hysteresis, prevents boundary chatter
|
|
// from continually restarting at 1.0.
|
|
const oldElapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
|
const current = smoothFocusEmphasisResolved(
|
|
state.transitionFrom,
|
|
state.desired,
|
|
oldElapsedMs,
|
|
tuning,
|
|
);
|
|
const desired = focusTargetEmphasisResolved(
|
|
screenPosition,
|
|
cameraDistance,
|
|
target,
|
|
tuning,
|
|
state.desired < 1,
|
|
spriteHalfWidthPx,
|
|
spriteHalfHeightPx,
|
|
state.wasFarther,
|
|
);
|
|
if (desired !== state.desired) {
|
|
state.transitionFrom = current;
|
|
state.transitionStartMs = nowMs;
|
|
state.desired = desired;
|
|
}
|
|
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
|
const next = smoothFocusEmphasisResolved(
|
|
state.transitionFrom,
|
|
desired,
|
|
elapsedMs,
|
|
tuning,
|
|
);
|
|
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
|
|
state.factor = next;
|
|
state.desired = desired;
|
|
state.wasFarther =
|
|
tuning.nearerBehavior !== 'dim' &&
|
|
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
|
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
|
|
const active = Math.abs(next - 1) > tuning.writeEpsilon;
|
|
|
|
if (!target && !transitioning && next === 1) _spriteStates.delete(sprite);
|
|
_advanceResult.factor = next;
|
|
_advanceResult.changed = changed;
|
|
_advanceResult.transitioning = transitioning;
|
|
_advanceResult.active = active;
|
|
_advanceResult.desired = desired;
|
|
return _advanceResult;
|
|
}
|
|
|
|
/**
|
|
* Project and advance one world-anchored sprite using an owning layer's
|
|
* existing render pass.
|
|
* @param {object} sprite Stable primitive identity.
|
|
* @param {Cesium.Cartesian3} position Current cached/rendered world position.
|
|
* @param {Cesium.Scene} scene
|
|
* @param {Cesium.Camera} camera
|
|
* @param {number} nowMs
|
|
* @param {object|null} [target]
|
|
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
|
* @param {number} [spriteHalfWidthPx=0]
|
|
* @param {number} [spriteHalfHeightPx=spriteHalfWidthPx]
|
|
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
|
*/
|
|
export function advanceProjectedSpriteFocus(
|
|
sprite,
|
|
position,
|
|
scene,
|
|
camera,
|
|
nowMs,
|
|
target = _focusTarget,
|
|
params,
|
|
spriteHalfWidthPx = 0,
|
|
spriteHalfHeightPx = spriteHalfWidthPx,
|
|
) {
|
|
// Common case: nothing tracked and this sprite holds no dimming state —
|
|
// advanceSpriteFocus would return identity without reading the projection,
|
|
// so skip the worldToWindowCoordinates round trip entirely (it was N
|
|
// wasted projections per fleet tick with nothing tracked). Sprites still
|
|
// relaxing keep their state entry until fully settled (see the delete at
|
|
// the transition tail), so they keep projecting until done. (perf item 8)
|
|
if (!target && !_spriteStates.has(sprite)) {
|
|
_advanceResult.factor = 1;
|
|
_advanceResult.changed = false;
|
|
_advanceResult.transitioning = false;
|
|
_advanceResult.active = false;
|
|
_advanceResult.desired = 1;
|
|
return _advanceResult;
|
|
}
|
|
const screen =
|
|
position && scene
|
|
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
|
scene,
|
|
position,
|
|
_scratchScreen,
|
|
)
|
|
: null;
|
|
const cameraDistance =
|
|
position && camera?.positionWC
|
|
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
|
: Number.NaN;
|
|
return advanceSpriteFocus(sprite, {
|
|
screenPosition: screen,
|
|
cameraDistance,
|
|
nowMs,
|
|
target,
|
|
params,
|
|
spriteHalfWidthPx,
|
|
spriteHalfHeightPx,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A gated consumer must keep ticking until every previously dimmed sprite has
|
|
* returned to identity, even when no target remains.
|
|
* @param {object|null} target
|
|
* @param {number} activeCount Count of emphasis factors outside the 1.0 deadband.
|
|
* @returns {boolean}
|
|
*/
|
|
export function focusPassIsNeeded(target, activeCount) {
|
|
return (
|
|
(target !== null && target !== undefined) ||
|
|
(Number.isFinite(activeCount) && activeCount > 0)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Match Cesium's shader-side NearFarScalar interpolation for rendered extents.
|
|
* Cesium interpolates against squared camera distance with a 0.2 exponent.
|
|
* @param {{near:number,nearValue:number,far:number,farValue:number}|null} scalar
|
|
* @param {number} cameraDistanceM
|
|
* @returns {number}
|
|
*/
|
|
export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
|
|
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
|
|
const nearSq = scalar.near * scalar.near;
|
|
const farSq = scalar.far * scalar.far;
|
|
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
|
|
return 1;
|
|
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
|
|
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
|
|
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
|
|
}
|
|
|
|
/** Shared virtual time used only by the DEV evidence seam. */
|
|
let _focusEvidenceNowMs = null;
|
|
|
|
/** Resolve production time unless deterministic evidence capture owns it. */
|
|
export function focusNowMs(productionNowMs) {
|
|
return _focusEvidenceNowMs ?? productionNowMs;
|
|
}
|
|
|
|
/** Set deterministic evidence time; called only through development seams. */
|
|
export function setFocusEvidenceNowMs(nowMs) {
|
|
_focusEvidenceNowMs = Number.isFinite(nowMs) ? nowMs : null;
|
|
return _focusEvidenceNowMs;
|
|
}
|
|
|
|
/** Advance deterministic evidence time without consulting a wall clock. */
|
|
export function advanceFocusEvidenceNowMs(deltaMs) {
|
|
if (_focusEvidenceNowMs === null) return null;
|
|
_focusEvidenceNowMs += Math.max(0, Number.isFinite(deltaMs) ? deltaMs : 0);
|
|
return _focusEvidenceNowMs;
|
|
}
|
|
|
|
/** Forget state when a primitive is removed from its owning collection. */
|
|
export function forgetSpriteFocus(sprite) {
|
|
if (sprite) _spriteStates.delete(sprite);
|
|
}
|
|
|
|
/**
|
|
* Deadband predicate shared by consumers' final alpha write sites.
|
|
* @param {number} currentAlpha
|
|
* @param {number} nextAlpha
|
|
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
|
* @returns {boolean}
|
|
*/
|
|
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
|
|
const tuning = resolvedParams(params);
|
|
return (
|
|
!Number.isFinite(currentAlpha) ||
|
|
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
|
|
);
|
|
}
|