mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
197 lines
8.9 KiB
JavaScript
197 lines
8.9 KiB
JavaScript
/**
|
|
* Detection's render demand — when the overlay actually needs another frame.
|
|
*
|
|
* Detection used to take an unconditional continuous-render hold for as long as
|
|
* it was on (`holdContinuousRender('detection')`). That was honest when it was
|
|
* written: the overlay repainted from scratch every frame, so "on" really did
|
|
* mean "per-frame work". It stopped being honest once the world-overlay host
|
|
* became demand-driven — `invalidateHost()` already requests a frame on every
|
|
* content change, and Cesium's `requestRenderMode` already renders on camera
|
|
* input and tile loads, so a parked scene with detection on was paying 60 fps
|
|
* for pixels that do not change.
|
|
*
|
|
* Nobody noticed because detection was OFF by default. Turning it ON by default
|
|
* (2026-08-22) would have shipped that hot loop to every idle first-run tab —
|
|
* defeating the render governor, whose whole reason for existing is the ~60% GPU
|
|
* and ~54% of a core a parked scene used to burn (`src/renderGovernor.js`).
|
|
*
|
|
* So detection no longer holds. It is driven the way the rest of the host is:
|
|
* repaint on CHANGE, and — for work that spans frames — ask for exactly one more
|
|
* frame while that work is outstanding. That is the pattern `drawWorldOverlay`
|
|
* already uses for its own fades, and the same shape as the earthquake-disc fix
|
|
* (static geometry, hold removed) that this follows.
|
|
*
|
|
* ── THE ONE RULE ─────────────────────────────────────────────────────────────
|
|
*
|
|
* Every input below must be able to reach a state where it stops asking. A
|
|
* predicate that can stay true forever is the old hold under another name, and
|
|
* that is the property the unit pins defend hardest.
|
|
*
|
|
* Three kinds of outstanding work qualify, and each terminates:
|
|
*
|
|
* 1. the fade-in when detection (re)activates — a bounded window;
|
|
* 2. label fades, IN as well as out — the arbiter drives every one of them to
|
|
* a settled alpha;
|
|
* 3. a label solve the frame could not run — the throttle defers it by at most
|
|
* one interval, a skipped paint by exactly one frame.
|
|
*
|
|
* (2) said "out" only, once. A newly selected label fading IN is `selected`, so
|
|
* counting the fade-OUT tail missed it entirely: on a parked scene a label could
|
|
* be selected and then never painted past its first frame — invisible until some
|
|
* unrelated frame happened along. Fade-in and fade-out are the same animation
|
|
* seen from two ends, with the same termination. Both count.
|
|
*
|
|
* ── ONE CLOCK ────────────────────────────────────────────────────────────────
|
|
*
|
|
* The timestamp is the host frame's own `frame.timestamp` — `performance.now()`,
|
|
* sampled ONCE per frame by `drawWorldOverlay` — and the SAME value must reach
|
|
* both the paint and this policy.
|
|
*
|
|
* Two samples of a clock taken a millisecond apart inside one frame is a real
|
|
* defect, not pedantry: paint at age 219 ms draws alpha 0.99545, and a policy
|
|
* re-sampling at 220 ms answers "done", so the frame that would have painted the
|
|
* settled alpha is never requested and the fade visibly stops a hair short.
|
|
* Sharing one timestamp makes the frame that paints final state the same frame
|
|
* that ends demand, by construction. Monotonicity removes the other half of that
|
|
* defect: a wall clock can jump backwards and leave an elapsed-time test true
|
|
* until it catches up, which is exactly the "asks forever" failure this module
|
|
* exists to prevent.
|
|
*
|
|
* Pure functions + constants so the policy is unit-testable without a viewer or
|
|
* a canvas; detection.js owns the plumbing.
|
|
*/
|
|
|
|
/** Duration of the fade-in when detection (re)activates, in ms. */
|
|
export const DETECTION_ENABLE_FADE_MS = 220;
|
|
|
|
/**
|
|
* Cadence of the scanline's scroll, in ms per 2px step — one frame at 60 fps.
|
|
*
|
|
* Derived rather than written as `16`: a rounded 16 ms is 62.5 Hz, which against
|
|
* a real 60 fps frame clock beats slowly and repeats an offset every few frames
|
|
* instead of alternating. Deriving the cadence from a clock rather than from a
|
|
* frame counter is what lets the animation rest without a render hold.
|
|
*/
|
|
export const SCANLINE_STEP_MS = 1000 / 60;
|
|
|
|
/** Scanline pattern period in px — the offset walks 0, 2, 0, 2, … */
|
|
export const SCANLINE_PERIOD_PX = 4;
|
|
|
|
/**
|
|
* Scanline scroll offset for a monotonic frame timestamp.
|
|
*
|
|
* @param {number} nowMs - Frame timestamp in ms (`performance.now()`).
|
|
* @returns {number} Vertical offset in px, within [0, SCANLINE_PERIOD_PX).
|
|
*/
|
|
export function scanlineOffsetPx(nowMs) {
|
|
if (!Number.isFinite(nowMs)) return 0;
|
|
return (Math.floor(nowMs / SCANLINE_STEP_MS) * 2) % SCANLINE_PERIOD_PX;
|
|
}
|
|
|
|
/**
|
|
* Whether detection still has work spanning frames, and therefore needs one more
|
|
* frame after the one it just painted.
|
|
*
|
|
* Returns false for everything change-driven: those paths already request their
|
|
* own frame through `invalidateHost()`, and asking here as well would turn a
|
|
* bounded chain into a permanent one.
|
|
*
|
|
* @param {object} input
|
|
* @param {boolean} input.active - Detection is on and not suspended.
|
|
* @param {number} input.nowMs - The frame's monotonic timestamp (see header).
|
|
* @param {number} input.enabledAtMs - When detection last (re)activated, same clock.
|
|
* @param {number} [input.fadeMs] - Enable fade-in duration.
|
|
* @param {number} [input.animatingLabelCount] - Labels mid-fade, IN or out.
|
|
* @param {boolean} [input.solvePending] - A label solve is owed but did not run.
|
|
* @returns {boolean} True when one more frame is owed.
|
|
*/
|
|
export function detectionNeedsFollowUpFrame({
|
|
active,
|
|
nowMs,
|
|
enabledAtMs,
|
|
fadeMs = DETECTION_ENABLE_FADE_MS,
|
|
animatingLabelCount = 0,
|
|
solvePending = false,
|
|
} = {}) {
|
|
if (!active) return false;
|
|
if (Number(animatingLabelCount) > 0) return true;
|
|
if (solvePending === true) return true;
|
|
if (!Number.isFinite(nowMs) || !Number.isFinite(enabledAtMs)) return false;
|
|
const age = nowMs - enabledAtMs;
|
|
// `age >= 0` is the fail-toward-idle guard. It cannot trip on the monotonic
|
|
// clock this is fed, but if a caller ever mixes clocks the wrong answer must
|
|
// be "stop asking", never "keep asking until time catches up".
|
|
return age >= 0 && age < fadeMs;
|
|
}
|
|
|
|
/**
|
|
* Paint cost above which the relief valve may skip alternate detection paints.
|
|
* Unchanged from when the valve was introduced; only its demand accounting moved.
|
|
*/
|
|
export const DETECTION_PAINT_SKIP_THRESHOLD_MS = 22;
|
|
|
|
/**
|
|
* The pathological-load relief valve: may this frame's detection paint be
|
|
* skipped, and if so, what does the skip owe?
|
|
*
|
|
* Extracted as a pure decision because the answer has two halves that MUST stay
|
|
* welded together. Under the old continuous hold a skip cost nothing — the next
|
|
* frame was guaranteed — so the valve could simply return false and forget. With
|
|
* no hold, the frame it declines may be the only one anybody asked for, and a
|
|
* skip that forgets strands whatever requested it: a reactivation, a new label,
|
|
* a fresh solve. Skipping is a DEFERRAL, never a cancellation.
|
|
*
|
|
* `requestFollowUp` is therefore exactly `skip`, and that equality is the
|
|
* invariant worth pinning — a wrapper cannot skip without re-requesting, because
|
|
* the same decision produces both.
|
|
*
|
|
* @param {object} input
|
|
* @param {boolean} input.layoutChanged - The host's layout revision moved this frame.
|
|
* @param {number} input.lastPaintMs - Cost of the previous detection paint.
|
|
* @param {number} input.frameCount - Monotonic detection frame counter.
|
|
* @param {number} [input.thresholdMs]
|
|
* @returns {{skip: boolean, requestFollowUp: boolean}}
|
|
*/
|
|
export function detectionPaintSkipDecision({
|
|
layoutChanged,
|
|
lastPaintMs,
|
|
frameCount,
|
|
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
|
|
} = {}) {
|
|
const skip =
|
|
!layoutChanged &&
|
|
Number(lastPaintMs) > thresholdMs &&
|
|
Number(frameCount) % 2 !== 0;
|
|
return { skip, requestFollowUp: skip };
|
|
}
|
|
|
|
/**
|
|
* Whether a rendered label row's alpha will still change on a later frame.
|
|
*
|
|
* A `selected` row is still climbing while its alpha is below 1. An unselected
|
|
* row only survives the arbiter's render pass while its exit tail is above 0, so
|
|
* any that reaches here is by definition still moving. Stateless rows have hard
|
|
* cliffs (exactly 1, or gone) and never animate.
|
|
*
|
|
* @param {{selected?: boolean, temporalAlpha?: number}} entry - Arbiter render row.
|
|
* @returns {boolean}
|
|
*/
|
|
export function renderEntryIsAnimating(entry) {
|
|
if (!entry) return false;
|
|
if (!entry.selected) return true;
|
|
return Number(entry.temporalAlpha) < 1;
|
|
}
|
|
|
|
/**
|
|
* Count rendered rows whose alpha is still moving, in either direction.
|
|
* @param {Array<{selected?: boolean, temporalAlpha?: number}>} renderEntries
|
|
* @returns {number}
|
|
*/
|
|
export function countAnimatingRenderEntries(renderEntries) {
|
|
let count = 0;
|
|
for (let i = 0; i < renderEntries.length; i++) {
|
|
if (renderEntryIsAnimating(renderEntries[i])) count++;
|
|
}
|
|
return count;
|
|
}
|