Преглед на файлове

fix(cockpit): 修正运动帧间隔的采样口径

低配机导出的movingFrameIntervals会把静止到运动的首段(含requestRenderMode空档)计入窗口,造成数百毫秒假尖峰;连续运动中超过1000ms的真停顿又被静默丢弃。

新增纯domain运动窗:只收连续运动帧间隔,起步/隐藏/超长空档排除但计数并保留最大值;开发诊断改用该窗口,试点重置随之清理。不改产品渲染、加载、视觉或预算。

新增行为回归覆盖连续采样、重启/隐藏空档、慢帧保留、滚动上限、重置与接线;相关22+3回归通过。服务端已返回新模块,下次lodAudit导出即用新口径。

关联 #59。
ChenJZ преди 19 часа
родител
ревизия
b292b72086

+ 79 - 0
src/modules/cockpit/domain/frameMotionWindow.js

@@ -0,0 +1,79 @@
+/** 运动帧间隔窗口(票 59 采样口径修正)。
+ *
+ * 为什么需要:导出里的 movingFrameIntervals 曾把"静止→运动"的第一段也收进窗口,
+ * 而这一段包含按需渲染(requestRenderMode)的空档,几百毫秒的假尖峰正是这样来的;
+ * 反过来,真正的长停顿又因 1000ms 上限被静默丢弃、连计数都没有。
+ *
+ * 现在的口径:只收**连续运动帧之间**的间隔(上一帧也在运动)。起步段、隐藏期、
+ * 以及 >=maxGapMs 的空档都不进窗口,但都会被**计数**并保留最大被排除值,
+ * 所以既去掉起步空档,也不会把长停顿藏起来。
+ */
+export const MOTION_WINDOW_DEFAULTS = { maxSamples: 240, maxGapMs: 1000 }
+
+/**
+ * @param {{maxSamples?: number, maxGapMs?: number}} [options]
+ * @returns {{push: (tMs: number, moving: boolean) => string, markGap: (tMs: number) => void,
+ *   reset: () => void, snapshot: () => object}}
+ */
+export function createMotionIntervalWindow(options = {}) {
+    const configured = Number(options.maxSamples)
+    const maxSamples = Number.isFinite(configured) && configured >= 1
+        ? Math.trunc(configured)
+        : MOTION_WINDOW_DEFAULTS.maxSamples
+    const ceiling = Number(options.maxGapMs)
+    const maxGapMs = Number.isFinite(ceiling) ? ceiling : MOTION_WINDOW_DEFAULTS.maxGapMs
+    let intervals = []
+    let previous = null
+    let restartGapsExcluded = 0
+    let longGapsExcluded = 0
+    let maxExcludedMs = null
+    const exclude = (kind, dt) => {
+        if (kind === 'restart-gap') restartGapsExcluded += 1
+        else longGapsExcluded += 1
+        if (maxExcludedMs === null || dt > maxExcludedMs) maxExcludedMs = dt
+        return kind
+    }
+    return {
+        /** @returns {'first'|'sampled'|'restart-gap'|'long-gap'|'idle'} */
+        push(tMs, moving) {
+            const at = Number(tMs)
+            if (!Number.isFinite(at)) return 'idle'
+            const isMoving = !!moving
+            if (previous === null) { previous = { at, moving: isMoving }; return 'first' }
+            const dt = at - previous.at
+            const wasMoving = previous.moving
+            previous = { at, moving: isMoving }
+            if (!isMoving || dt <= 0) return 'idle'
+            if (!wasMoving) return exclude('restart-gap', dt)
+            if (dt >= maxGapMs) return exclude('long-gap', dt)
+            intervals.push(dt)
+            if (intervals.length > maxSamples) intervals.shift()
+            return 'sampled'
+        },
+        /** 隐藏期间的帧不采样,但必须打断"连续运动",否则恢复可见的首段会被当成连续帧。 */
+        markGap(tMs) {
+            const at = Number(tMs)
+            if (Number.isFinite(at)) previous = { at, moving: false }
+        },
+        reset() {
+            intervals = []
+            previous = null
+            restartGapsExcluded = 0
+            longGapsExcluded = 0
+            maxExcludedMs = null
+        },
+        snapshot() {
+            const sorted = [...intervals].sort((a, b) => a - b)
+            return {
+                samples: sorted.length,
+                p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null,
+                p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null,
+                maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null,
+                over50Ms: sorted.filter(n => n > 50).length,
+                restartGapsExcluded,
+                longGapsExcluded,
+                maxExcludedMs: maxExcludedMs === null ? null : +maxExcludedMs.toFixed(2),
+            }
+        },
+    }
+}

+ 7 - 5
src/modules/cockpit/services/modelDiagnostics.js

@@ -1,19 +1,21 @@
 // Development-only, read-only browser evidence. No forced render or streaming changes.
+import { createMotionIntervalWindow } from '../domain/frameMotionWindow.js'
 export function attachModelDiagnostics(scene, host, readState, {now=()=>performance.now(),isMoving=()=>false}={}) {
     const node=host.ownerDocument.createElement('details'),summary=host.ownerDocument.createElement('summary'),output=host.ownerDocument.createElement('output')
     node.setAttribute('data-model-diagnostics','');summary.textContent='模型加载诊断(开发)'
     node.style.cssText='position:absolute;bottom:4px;left:4px;z-index:8;max-width:420px;font:11px monospace;background:#061827dd;color:#bad4e0;padding:4px;pointer-events:auto'
     output.style.cssText='display:block;white-space:pre-wrap;max-height:180px;overflow:auto'
     output.setAttribute('aria-label','模型加载诊断读数');node.append(summary,output);host.appendChild(node)
-    let start=null,lastFrame=null,lastPaint=-Infinity,total=0
-    const durations=[],intervals=[]
+    let start=null,lastPaint=-Infinity,total=0
+    const durations=[]
+    const motion=createMotionIntervalWindow()
     const add=(list,n)=>{list.push(n);if(list.length>120)list.shift()}
     const stats=list=>{const sorted=[...list].sort((a,b)=>a-b);return{samples:list.length,p50Ms:sorted.length?+sorted[Math.floor(sorted.length*.5)].toFixed(2):null,p95Ms:sorted.length?+sorted[Math.min(sorted.length-1,Math.floor(sorted.length*.95))].toFixed(2):null}}
     const pre=scene.preRender.addEventListener(()=>{start=now()})
-    function paint(){const state=readState();output.textContent=JSON.stringify({...state,renderEvents:total,renderSubmissionDuration:stats(durations),movingFrameIntervals:stats(intervals),gpuTimeMeasured:false,fpsDuringIdleMeaningful:false},null,2)}
+    function paint(){const state=readState();output.textContent=JSON.stringify({...state,renderEvents:total,renderSubmissionDuration:stats(durations),movingFrameIntervals:motion.snapshot(),gpuTimeMeasured:false,fpsDuringIdleMeaningful:false},null,2)}
     const post=scene.postRender.addEventListener(()=>{const t=now();total++
-        if(!host.ownerDocument.hidden){if(start!==null)add(durations,t-start);if(lastFrame!==null&&isMoving()&&t-lastFrame<1000)add(intervals,t-lastFrame)}
-        start=null;lastFrame=t;if(t-lastPaint>=500){lastPaint=t;paint()}
+        if(!host.ownerDocument.hidden){if(start!==null)add(durations,t-start);motion.push(t,isMoving())}else{motion.markGap(t)}
+        start=null;if(t-lastPaint>=500){lastPaint=t;paint()}
     })
     summary.addEventListener('click',paint);paint()
     return()=>{pre();post();node.remove()}

+ 116 - 0
tools/city-massing/verify-frame-motion-window.mjs

@@ -0,0 +1,116 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { createMotionIntervalWindow } from '../../src/modules/cockpit/domain/frameMotionWindow.js'
+
+const referenceStats = list => {
+    const sorted = [...list].sort((a, b) => a - b)
+    return {
+        samples: sorted.length,
+        p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null,
+        p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null,
+        maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null,
+        over50Ms: sorted.filter(n => n > 50).length,
+    }
+}
+const emptyStats = { samples: 0, p50Ms: null, p95Ms: null, maxMs: null, over50Ms: 0 }
+
+// Continuous motion keeps the same percentile math the export already had.
+{
+    const w = createMotionIntervalWindow()
+    assert.equal(w.push(0, true), 'first', 'The first frame only seeds the previous frame')
+    const frames = [16.7, 16.6, 33.4, 17.1, 16.9]
+    let t = 0
+    for (const dt of frames) assert.equal(w.push(t += dt, true), 'sampled')
+    const snapshot = w.snapshot()
+    const { samples: count, p50Ms, p95Ms, maxMs, over50Ms } = snapshot
+    assert.deepEqual({ samples: count, p50Ms, p95Ms, maxMs, over50Ms }, referenceStats(frames))
+    assert.equal(snapshot.restartGapsExcluded, 0)
+    assert.equal(snapshot.longGapsExcluded, 0)
+    assert.equal(snapshot.maxExcludedMs, null)
+}
+
+// Idle -> motion start must not enter the window, and the loss is counted.
+{
+    const w = createMotionIntervalWindow()
+    w.push(1000, false)
+    assert.equal(w.push(1600, true), 'restart-gap', 'First moving frame after idle is a restart gap')
+    assert.equal(w.push(1616.7, true), 'sampled', 'The next continuous moving frame is sampled')
+    const snapshot = w.snapshot()
+    assert.equal(snapshot.samples, 1)
+    assert.equal(snapshot.maxMs, 16.7)
+    assert.equal(snapshot.restartGapsExcluded, 1)
+    assert.equal(snapshot.maxExcludedMs, 600, 'Excluded gap magnitude stays visible')
+}
+
+// Hidden pages mark a break instead of silently treating the gap as motion.
+{
+    const w = createMotionIntervalWindow()
+    w.push(500, true)
+    w.markGap(900)
+    assert.equal(w.push(940, true), 'restart-gap')
+    assert.equal(w.snapshot().samples, 0)
+    assert.equal(w.snapshot().restartGapsExcluded, 1)
+}
+
+// A genuine >=1000ms stall between two moving frames is excluded but reported.
+{
+    const w = createMotionIntervalWindow()
+    w.push(0, true)
+    assert.equal(w.push(999, true), 'sampled', 'Just under the ceiling is still a real interval')
+    assert.equal(w.push(1999, true), 'long-gap', 'Exactly 1000ms is a gap, not a frame interval')
+    assert.equal(w.push(2999, true), 'long-gap')
+    const snapshot = w.snapshot()
+    assert.equal(snapshot.samples, 1)
+    assert.equal(snapshot.longGapsExcluded, 2)
+    assert.equal(snapshot.maxExcludedMs, 1000)
+}
+
+// Slow but continuous frames are real evidence and must not be hidden.
+{
+    const w = createMotionIntervalWindow()
+    w.push(0, true)
+    assert.equal(w.push(300, true), 'sampled')
+    w.push(600, true)
+    assert.equal(w.snapshot().maxMs, 300)
+    assert.equal(w.snapshot().over50Ms, 2)
+}
+
+// Non-monotonic or stationary observations never create samples.
+{
+    const w = createMotionIntervalWindow()
+    w.push(100, true)
+    assert.equal(w.push(100, true), 'idle', 'Zero-length interval is not a frame')
+    assert.equal(w.push(90, true), 'idle', 'Backwards clock is rejected')
+    assert.equal(w.snapshot().samples, 0)
+    assert.deepEqual(w.snapshot(), { ...emptyStats, restartGapsExcluded: 0, longGapsExcluded: 0, maxExcludedMs: null })
+}
+
+// The rolling window keeps the newest 240 intervals.
+{
+    const w = createMotionIntervalWindow()
+    w.push(0, true)
+    for (let i = 1; i <= 300; i++) w.push(i * 10, true)
+    const snapshot = w.snapshot()
+    assert.equal(snapshot.samples, 240, 'Only the newest 240 intervals are kept')
+    assert.equal(snapshot.maxMs, 10, 'Every kept interval is the same 10ms step')
+    assert.equal(snapshot.p50Ms, 10)
+}
+
+// Reset clears samples and counters so a new pilot window starts clean.
+{
+    const w = createMotionIntervalWindow()
+    w.push(0, true); w.push(2000, true); w.markGap(2100)
+    w.reset()
+    assert.deepEqual(w.snapshot(), { ...emptyStats, restartGapsExcluded: 0, longGapsExcluded: 0, maxExcludedMs: null })
+    assert.equal(w.push(5000, true), 'first', 'Reset also clears the previous frame')
+}
+
+// Wiring: the export must use this window instead of the old raw interval list.
+{
+    const source = readFileSync('src/modules/cockpit/services/modelDiagnostics.js', 'utf8')
+    assert.match(source, /createMotionIntervalWindow/, 'diagnostics must use the motion window')
+    assert.match(source, /motion\.push\(/, 'diagnostics must feed the window')
+    assert.match(source, /motion\.markGap\(/, 'hidden frames must mark a break')
+    assert.doesNotMatch(source, /add\(intervals/, 'the raw interval list must be gone')
+}
+console.log('PASS motion interval window: continuity, restart/hidden gaps, stall reporting, rolling cap, reset, wiring')