Procházet zdrojové kódy

fix(cockpit): 防止定时刷新重复发放同帧显形配额

定时刷新与 postRender 原本分别重置显形配额,导致两个渲染帧之间可集中显示多个区域。现在两条路径共享剩余配额,仅由下一次 postRender 补充额度。

补充跨回调配额、按需渲染唤醒、近处优先、粗模交接和销毁回归。独立 HEAD 红绿回归及当前工作树相关 22 项非变异套件通过。

关联 #59;本次只修确定性调度缺陷,不宣称整体帧率改善,实机性能验收仍未完成。
ChenJZ před 3 dny
rodič
revize
0731678fef

+ 2 - 2
src/modules/cockpit/services/regionLoading.js

@@ -442,8 +442,8 @@ export function createRegionLoadingController({
         }
         const wanted = desired()
         if (opts.skipVisibility !== true) {
-            // 显形配额重置 + 按距离近→远排序:配额不够时近处先显形(远处还在粗模,无感)。
-            showBudgetRemaining = Math.max(1, Math.trunc(finiteOr(showBudgetPerFrame, 1)))
+            // 定时刷新可能紧接 postRender,仍属于同一个待渲染帧,不能重新发放显形配额。
+            // 与 onRender 共用剩余配额;只有下一次 onRender 才开启下一帧额度。
             const ordered = [...entries].sort((a, b) => (a.distance - b.distance) || 0)
             for (const e of ordered) {
                 if (!wanted.includes(e.id) || e.phase === 'failed') continue

+ 96 - 0
tools/city-massing/verify-region-frame-budget.mjs

@@ -0,0 +1,96 @@
+import assert from 'node:assert/strict'
+import { createRegionLoadingController } from '../../src/modules/cockpit/services/regionLoading.js'
+
+function event() {
+    const listeners = new Set()
+    return {
+        addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn) },
+        raise(value) { for (const fn of listeners) fn(value) },
+    }
+}
+
+async function harness({ count = 2, budget = 1 } = {}) {
+    let clock = 0, timer = null, renderRequested = false
+    const models = new Map(), removed = []
+    const regions = Array.from({ length: count }, (_, index) => ({
+        id: count - index, contentCount: 1, decodedGeometryBytes: 1024,
+    }))
+    const controller = createRegionLoadingController({
+        regions, showBudgetPerFrame: budget, maxConcurrentLoads: count,
+        load: async region => {
+            const model = { show: false, tilesLoaded: false, tileVisible: event() }
+            models.set(region.id, model)
+            return model
+        },
+        add() {}, remove(model) { removed.push(model) }, requestRender() { renderRequested = true },
+        distance: region => region.id * 10, readCameraKey: () => 'fixed', now: () => clock,
+        setTimer(callback) { timer = callback; return 1 }, clearTimer() { timer = null },
+    })
+    controller.refresh()
+    await Promise.resolve()
+    for (const model of models.values()) model.tilesLoaded = true
+    return {
+        controller, models, removed,
+        shown: () => [...models].filter(([, model]) => model.show).map(([id]) => id).sort((a, b) => a - b),
+        renderPending: () => renderRequested,
+        frame(at) { clock = at; renderRequested = false; controller.onRender() },
+        timer(at) { clock = at; const callback = timer; timer = null; callback?.() },
+    }
+}
+
+// 199ms postRender + 200ms refresh occur before the next 216ms render.
+// A scheduling callback is not a rendered frame, even after a long idle gap.
+for (const budget of [1, 2]) {
+    const h = await harness({ count: budget * 2, budget })
+    try {
+        const nearest = Array.from({ length: budget }, (_, index) => index + 1)
+        h.frame(199)
+        assert.deepEqual(h.shown(), nearest, 'First-show slots go to nearest ready regions')
+        assert.equal(h.renderPending(), true, 'First show requests the next render to advance deferred work')
+        h.timer(200)
+        assert.deepEqual(h.shown(), nearest, 'Timer must share the current frame show budget')
+        h.timer(400)
+        h.controller.refresh()
+        assert.deepEqual(h.shown(), nearest, 'Elapsed time and explicit refresh do not grant frame slots')
+        for (let id = budget + 1; id <= budget * 2; id++) {
+            assert.equal(h.controller.detailReady(id), false, 'Deferred regions retain coarse fallback')
+        }
+        // A new frame, not wall-clock advancement, grants the next budget.
+        h.frame(400)
+        assert.equal(h.shown().length, budget * 2, 'Ready work progresses on the next frame')
+    } finally { h.controller.detach() }
+}
+
+// A newly shown model must actually render before its coarse fallback yields.
+{
+    const h = await harness()
+    try {
+        h.frame(199)
+        h.timer(200)
+        assert.equal(h.controller.detailReady(1), false)
+        h.models.get(1).tileVisible.raise({ content: { url: 'near', trianglesLength: 1 } })
+        h.frame(216)
+        assert.equal(h.controller.detailReady(1), true)
+        assert.equal(h.controller.detailReady(2), false)
+        h.models.get(2).tileVisible.raise({ content: { url: 'far', trianglesLength: 1 } })
+        h.frame(233)
+        assert.equal(h.controller.detailReady(2), true)
+    } finally { h.controller.detach() }
+}
+
+// Exhausted show slots must not throttle pipeline release or resurrect disposed work.
+{
+    const h = await harness()
+    h.frame(199)
+    h.controller.setPipeline(true)
+    assert.equal(h.removed.length, 2, 'Pipeline change releases every loaded model immediately')
+    assert.equal(h.controller.detailReady(1), false)
+    assert.equal(h.controller.detailReady(2), false)
+    h.controller.detach()
+    h.timer(400)
+    h.frame(416)
+    h.controller.refresh()
+    assert.equal(h.models.size, 2, 'Disposed controller does not start another model')
+    assert.equal(h.removed.length, 2, 'Each model is removed exactly once')
+}
+console.log('PASS region frame budget: timer/render interleaving, nearest-first, progress, coarse handoff, disposal')