瀏覽代碼

fix(cockpit): 在原预算内复用已就绪细模区域

修复已就绪区域A到B再回A时不必要的实例重建。活动、不可见缓存和初始化预约共同计入原规划预算,仅在缓存淘汰确实能容纳新目标时按最近需求时间释放缓存,不挤占活动近区。

缓存关闭显形和隐藏预取,回用先等待新渲染更新再判断就绪/覆盖;清理旧可见证据与保持期。失败、未完成和未知零成本实例不缓存,管线/无需求静止态/父粗模不可用及销毁路径释放缓存。

独立HEAD红绿回归及当前工作树22项相关套件、3项独立回归通过。未修改LOD、分辨率、预算或装载并发,不宣称低配机FPS收益,实机对照仍待完成。

关联 #59。
ChenJZ 3 天之前
父節點
當前提交
282fb7f908
共有 2 個文件被更改,包括 276 次插入 和 12 次删除
  1. 68 12
      src/modules/cockpit/services/regionLoading.js
  2. 208 0
      tools/city-massing/verify-region-residency.mjs

+ 68 - 12
src/modules/cockpit/services/regionLoading.js

@@ -189,6 +189,7 @@ export function createRegionLoadingController({
         phase: 'pending', instance: null, loaded: new Set(), pending: 0, processing: 0,
         visible: false, visibleThisFrame: false, fineVisible: false, fineVisibleThisFrame: false,
         shown: false, retryAt: 0, distance: Infinity, centerDistance: Infinity, active: false,
+        cached: false, awaitingRender: false, lastWantedAt: -Infinity,
         // previewSeen 与 loaded 是两份账:loaded 会被 tileUnload(视锥剔除/内存回收)扣减,
         // previewSeen 是单调累计、永不清除 —— 用它判"这片 preview 曾经到过",不受剔除影响。
         previewSeen: new Set(),
@@ -235,10 +236,50 @@ export function createRegionLoadingController({
         mask(e, false)
         if (e.instance) { e.detaches.forEach(d => d()); remove(e.instance) }
         e.instance = null; e.phase = 'pending'; e.loaded.clear()
+        e.cached = false; e.awaitingRender = false; e.lastWantedAt = -Infinity
         e.visible = e.visibleThisFrame = e.fineVisible = e.fineVisibleThisFrame = false
         e.pending = e.processing = 0
     }
 
+    const contentCost = e => Math.max(0, finiteOr(e.contentCount, 0))
+    const byteCost = e => Math.max(0, finiteOr(e.decodedGeometryBytes, 0))
+    function hasRoomFor(candidate, includeCached = true) {
+        let count = 1, contents = contentCost(candidate), bytes = byteCost(candidate)
+        // Reserve in-flight initializations too; async completion must not overbook the budget.
+        for (const e of entries) {
+            if (!e.instance && e.phase !== 'initializing') continue
+            if (!includeCached && e.cached) continue
+            count++; contents += contentCost(e); bytes += byteCost(e)
+        }
+        return count <= Math.max(0, Math.ceil(finiteOr(budgetOptions.maxRegions, Infinity)))
+            && contents <= finiteOr(budgetOptions.maxContents, REGION_BUDGET_DEFAULTS.maxContents)
+            && bytes <= finiteOr(budgetOptions.maxDecodedBytes, REGION_BUDGET_DEFAULTS.maxDecodedBytes)
+    }
+    function makeRoomFor(candidate) {
+        if (hasRoomFor(candidate)) return true
+        // Do not discard useful caches if active/in-flight reservations still prevent admission.
+        if (!hasRoomFor(candidate, false)) return false
+        // Only hidden caches can yield capacity; never evict an active near target for a cache.
+        const cached = entries.filter(e => e.cached).sort((a, b) => a.lastWantedAt - b.lastWantedAt)
+        for (const e of cached) {
+            unload(e)
+            if (hasRoomFor(candidate)) return true
+        }
+        return false
+    }
+    function parkReady(e) {
+        if (e.cached) return
+        mask(e, false)
+        e.instance.show = false
+        e.instance.preloadWhenHidden = false
+        e.cached = true; e.awaitingRender = false
+        // The old view's coverage/grace must not authorize the next view's coarse handoff.
+        e.visible = e.visibleThisFrame = e.fineVisible = e.fineVisibleThisFrame = false
+        e.renderedTiles = []; e.renderedTilesThisFrame = []
+        e.usableAt = e.coveredAt = -Infinity
+        requestRender()
+    }
+
     async function start(e) {
         if (disposed || loading.size >= loadLimit) return
         loading.add(e)
@@ -329,7 +370,8 @@ export function createRegionLoadingController({
      * @param {object} e 区域条目
      */
     function syncVisibility(e) {
-        if (!e.instance) return
+        if (!e.instance || e.cached) return
+        if (e.awaitingRender) return
         const arrived = arrivedOf(e)
         const at = now()
         // 「现在可用」刷新时刻;不可用时不一定立刻失效 —— 看下面的保持期。
@@ -441,6 +483,26 @@ export function createRegionLoadingController({
             advanceFrontier()
         }
         const wanted = desired()
+        const at = now(), moving = at - lastMoveAt < movingWindowMs
+        const parentReady = !pipeline && canStart()
+        const keepReady = parentReady && (moving || wanted.length > 0)
+        for (const e of entries) {
+            const needed = wanted.includes(e.id) && e.phase !== 'failed'
+            if (needed) {
+                e.lastWantedAt = at
+                if (e.cached && parentReady) {
+                    e.cached = false; e.instance.preloadWhenHidden = true
+                    // Hidden preload must update the new view before old tilesLoaded can be trusted.
+                    e.awaitingRender = true
+                    if (opts.skipVisibility === true) syncVisibility(e)
+                    requestRender()
+                } else if (e.cached) { unload(e); loading.delete(e) }
+            } else if (e.instance) {
+                // Zero/unknown-cost models cannot accumulate in a supposedly bounded cache.
+                if (keepReady && e.phase === 'ready' && arrivedOf(e) && (contentCost(e) > 0 || byteCost(e) > 0)) parkReady(e)
+                else { unload(e); loading.delete(e) }
+            }
+        }
         if (opts.skipVisibility !== true) {
             // 定时刷新可能紧接 postRender,仍属于同一个待渲染帧,不能重新发放显形配额。
             // 与 onRender 共用剩余配额;只有下一次 onRender 才开启下一帧额度。
@@ -450,23 +512,15 @@ export function createRegionLoadingController({
                 if (e.instance) syncVisibility(e)
             }
         }
-        // 卸载必须**每轮都跑**(正确性),不受 skipVisibility 影响。
-        for (const e of entries) {
-            if ((!wanted.includes(e.id) || e.phase === 'failed') && e.instance) {
-                unload(e)
-                loading.delete(e)
-            }
-        }
         // 移动时也让已准入的近处候选渐进开装,避免持续操作期间一直只有粗模。
         // 移动期仅一个初始装载槽,落定后恢复配置并发;不取消已有流、不放宽选择预算。
         // 这是区域初始装载并发限制,不是单帧瓦片上传预算。
-        const moving = now() - lastMoveAt < movingWindowMs
         const startLimit = moving ? 1 : loadLimit
         if (loading.size < startLimit && !pipeline && canStart()) {
             const pending = wanted.map(id => byId.get(id))
                 .filter(candidate => candidate && !candidate.instance && candidate.phase !== 'initializing' && !loading.has(candidate))
             for (const candidate of pending) {
-                if (loading.size >= startLimit) break
+                if (loading.size >= startLimit || !makeRoomFor(candidate)) break
                 void start(candidate)
             }
         }
@@ -489,7 +543,8 @@ export function createRegionLoadingController({
             for (const e of ordered) {
                 e.visible = e.visibleThisFrame; e.visibleThisFrame = false
                 e.fineVisible = e.fineVisibleThisFrame; e.fineVisibleThisFrame = false
-                if (!e.instance) continue
+                if (!e.instance || e.cached) continue
+                e.awaitingRender = false
                 // **显形与遮罩必须在每帧路径上完成,不能留在节流分支里**:
                 // 留在那里意味着细模最多晚 180ms 才显形;更要命的是注入时钟不前进的
                 // 场合(闸门/合成用例)会永远不显形 —— 那是正确性问题,不是性能问题。
@@ -513,11 +568,12 @@ export function createRegionLoadingController({
                 saturated: lastSaturated,
                 moving: now() - lastMoveAt < movingWindowMs,
                 detailRegions: entries.filter(e => e.instance || e.phase === 'initializing').length,
+                cachedRegions: entries.filter(e => e.cached).length,
             }
         },
         snapshot() {
             return entries.filter(e => e.instance || e.phase === 'initializing').map(e => ({
-                id: e.id, phase: e.phase, contents: e.contentCount, loaded: e.loaded.size,
+                id: e.id, phase: e.phase, cached: e.cached, awaitingRender: e.awaitingRender, contents: e.contentCount, loaded: e.loaded.size,
                 pending: e.pending, processing: e.processing, decodedGeometryEstimate: e.decodedGeometryBytes,
                 distance: e.distance, centerDistance: e.centerDistance,
                 previewLoaded: e.expectedPreviewContentUrls?.filter(url => e.loaded.has(url)).length || 0,

+ 208 - 0
tools/city-massing/verify-region-residency.mjs

@@ -0,0 +1,208 @@
+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) } }
+}
+const flush = async () => { await Promise.resolve(); await Promise.resolve() }
+let clock = 0, front = 0
+const loads = [], removed = [], models = new Map()
+const controller = createRegionLoadingController({
+    regions: [0, 1].map(id => ({ id, contentCount: 1, decodedGeometryBytes: 1024 })),
+    budget: { maxContents: 2, maxDecodedBytes: 2048 },
+    distance: r => r.id === front ? 10 : Infinity, readCameraKey: () => String(front), now: () => clock,
+    load: async r => { loads.push(r.id); const m = { id: r.id, show: false, tilesLoaded: true, covered: true, tileVisible: event() }; models.set(r.id, m); return m },
+    add() {}, remove: m => removed.push(m.id), coverageReady: e => e.instance.covered,
+    canStart: () => true, requestRender() {}, setTimer: () => 1, clearTimer() {},
+})
+try {
+    controller.refresh(); await flush(); controller.onRender()
+    const a = models.get(0)
+    a.tileVisible.raise({ content: { trianglesLength: 1 } }); controller.onRender()
+    assert.equal(controller.detailReady(0), true, 'Initial A coverage was rendered')
+    clock = 200; front = 1; controller.refresh(); await flush(); controller.onRender()
+    clock = 216; controller.onRender() // next frame has an unused show slot
+    clock = 400; front = 0; controller.refresh(); await flush()
+    assert.deepEqual(loads, [0, 1], 'A -> B -> A within the existing budget must reuse ready A, not reload it')
+    assert.equal(models.get(0), a, 'Reuse the existing model instance')
+    assert.equal(a.show, false, 'Cached readiness must not show before a fresh renderer pass')
+    assert.equal(controller.snapshot().find(e => e.id === 0).awaitingRender, true)
+    a.tilesLoaded = a.covered = false // renderer discovers an uncovered part of the new view
+    controller.onRender()
+    assert.equal(a.show, false, 'Fresh view readiness overrides the cached ready flag')
+    assert.equal(controller.detailReady(0), false, 'Reused model must render again before coarse yields')
+    a.tilesLoaded = a.covered = true; clock = 416; controller.onRender()
+    assert.equal(controller.detailReady(0), false)
+    a.tileVisible.raise({ content: { trianglesLength: 1 } }); clock = 432; controller.onRender()
+    assert.equal(controller.detailReady(0), true)
+    assert.deepEqual(removed, [], 'No eviction needed while both models fit the original budget')
+    console.log('PASS bounded residency: ready A -> B -> A reuses A and re-proves visible coverage')
+} finally { controller.detach() }
+
+async function scene(options, run) {
+    const costs = options.costs || [1, 1, 1]
+    const budget = options.budget || { maxContents: 2, maxDecodedBytes: 2048 }
+    let time = 0, pose = 0, visible = [0], parentReady = true, renderRequested = false
+    const loaded = [], added = [], disposed = [], byId = new Map(), waiting = new Map()
+    const control = createRegionLoadingController({
+        regions: costs.map((cost, id) => ({ id, contentCount: cost, decodedGeometryBytes: cost * 1024 })),
+        budget, distance: r => visible.includes(r.id) ? r.id + 1 : Infinity,
+        readCameraKey: () => String(pose), now: () => time, canStart: () => parentReady,
+        coverageReady: e => e.instance.covered,
+        load: r => {
+            loaded.push(r.id)
+            const m = { id: r.id, show: false, covered: options.ready !== false, tilesLoaded: options.ready !== false,
+                tileVisible: event(), tileFailed: event() }
+            byId.set(r.id, m)
+            return (options.deferred || options.deferredIds?.includes(r.id)) ? new Promise((resolve, reject) => waiting.set(r.id, { resolve: () => resolve(m), reject })) : Promise.resolve(m)
+        },
+        add: m => added.push(m.id), remove: m => disposed.push(m.id),
+        requestRender: () => { renderRequested = true }, setTimer: () => 1, clearTimer() {},
+    })
+    const h = {
+        control, loaded, added, disposed, byId,
+        async view(ids, elapsed = 200, moving = true) {
+            time += elapsed; visible = ids; if (moving) pose++
+            control.refresh(); await flush(); control.onRender(); await flush()
+        },
+        async resolve(id) { waiting.get(id).resolve(); waiting.delete(id); await flush() },
+        async reject(id) { waiting.get(id).reject(new Error('load rejected')); waiting.delete(id); await flush() },
+        parent(value) { parentReady = value; control.refresh() },
+        renderPending: () => renderRequested,
+        clearRenderRequest() { renderRequested = false },
+        checkBudget() {
+            const rows = control.snapshot()
+            assert.ok(rows.reduce((n, e) => n + e.contents, 0) <= (budget.maxContents ?? 1200))
+            assert.ok(rows.reduce((n, e) => n + e.decodedGeometryEstimate, 0) <= (budget.maxDecodedBytes ?? 256 * 1024 * 1024))
+            if (budget.maxRegions !== undefined) assert.ok(rows.length <= Math.ceil(budget.maxRegions))
+        },
+    }
+    control.refresh(); await flush(); control.onRender(); await flush()
+    try { await run(h) } finally {
+        control.detach()
+        for (const wait of waiting.values()) wait.resolve()
+        await flush()
+    }
+}
+
+await scene({}, async h => {
+    await h.view([1])
+    const a = h.byId.get(0)
+    assert.equal(a.show, false)
+    assert.equal(a.preloadWhenHidden, false, 'Parked ready model does not preload or render')
+    assert.equal(h.control.detailReady(0), false, 'Cached region cannot mask coarse')
+    assert.equal(h.control.snapshot().find(e => e.id === 0).cached, true)
+    assert.equal(h.control.frontierSnapshot().cachedRegions, 1)
+    h.clearRenderRequest()
+    await h.view([0])
+    assert.equal(h.renderPending(), true, 'Reactivation wakes requestRenderMode')
+    assert.equal(a.preloadWhenHidden, true)
+    assert.deepEqual(h.loaded, [0, 1])
+    h.checkBudget()
+})
+
+await scene({}, async h => {
+    await h.view([1]); await h.view([0]); await h.view([2])
+    assert.deepEqual(h.disposed, [1], 'Least recently wanted hidden cache yields first')
+    assert.equal(h.control.snapshot().find(e => e.id === 0).cached, true)
+    await h.view([0])
+    assert.deepEqual(h.loaded, [0, 1, 2], 'Most recently reused cache survives pressure')
+    h.checkBudget()
+})
+
+await scene({}, async h => {
+    await h.view([1]); await h.view([0]); await h.view([0, 2])
+    assert.deepEqual(h.disposed, [1], 'Cache yields instead of evicting the active near target')
+    assert.equal(h.byId.get(0).show, true)
+    h.checkBudget()
+})
+
+for (const budget of [{ maxContents: 1 }, { maxDecodedBytes: 1024 }, { maxRegions: 1 }]) {
+    await scene({ budget }, async h => {
+        await h.view([1])
+        assert.deepEqual(h.disposed, [0], 'Content, bytes and optional count limits include cached models')
+        assert.deepEqual(h.control.snapshot().map(e => e.id), [1])
+        h.checkBudget()
+    })
+}
+
+await scene({ deferred: true, budget: { maxContents: 1, maxDecodedBytes: 1024 } }, async h => {
+    await h.view([1], 1000, false)
+    assert.deepEqual(h.loaded, [0], 'Withdrawn unresolved initialization still reserves its budget')
+    h.checkBudget()
+    await h.resolve(0)
+    assert.deepEqual(h.added, [], 'Late withdrawn initialization never attaches')
+    await h.view([1], 1000, false)
+    assert.deepEqual(h.loaded, [0, 1], 'Released initialization reservation permits the new target')
+    h.checkBudget()
+})
+
+await scene({ ready: false }, async h => {
+    await h.view([1])
+    assert.deepEqual(h.disposed, [0], 'Incomplete initial stream is not retained')
+    assert.equal(h.control.frontierSnapshot().cachedRegions, 0)
+})
+await scene({ costs: [0, 0, 0] }, async h => {
+    await h.view([1])
+    assert.deepEqual(h.disposed, [0], 'Unknown zero-cost models cannot accumulate unbounded caches')
+})
+
+await scene({}, async h => {
+    await h.view([1])
+    h.byId.get(0).tileFailed.raise({ message: 'late cached failure' })
+    h.control.refresh()
+    assert.deepEqual(h.disposed, [0], 'Failure removes a cached instance')
+    assert.equal(h.control.frontierSnapshot().cachedRegions, 0)
+})
+await scene({}, async h => {
+    await h.view([1]); h.parent(false)
+    assert.deepEqual(h.disposed, [0], 'Unavailable parent releases hidden caches')
+    assert.equal(h.control.frontierSnapshot().cachedRegions, 0)
+})
+await scene({}, async h => {
+    await h.view([1]); await h.view([], 1000, false)
+    assert.equal(h.control.snapshot().length, 0, 'Settled scene without demand releases residency')
+})
+await scene({}, async h => {
+    await h.view([1]); h.control.setPipeline(true)
+    assert.equal(h.control.snapshot().length, 0)
+    assert.deepEqual(h.disposed.slice().sort(), [0, 1], 'Pipeline releases active and cached models')
+    h.control.detach()
+    assert.equal(h.disposed.length, 2, 'Dispose does not release a model twice')
+})
+await scene({ costs: [1, 1, 3], budget: { maxContents: 3, maxDecodedBytes: 3072 }, deferredIds: [1] }, async h => {
+    await h.view([1]) // ready A is cached; B is still initializing.
+    await h.view([2], 1000, false)
+    assert.deepEqual(h.loaded, [0, 1], 'Outstanding B prevents a full-budget C from starting')
+    assert.deepEqual(h.disposed.slice(), [], 'Do not evict A when even clearing caches cannot yet admit C')
+    await h.resolve(1)
+    await h.view([2], 1000, false)
+    assert.deepEqual(h.loaded, [0, 1, 2], 'C starts after the obsolete reservation resolves')
+    assert.deepEqual(h.disposed.slice().sort(), [0, 1])
+    h.checkBudget()
+})
+await scene({ deferred: true }, async h => {
+    await h.view([0, 1, 2], 1000, false)
+    assert.deepEqual(h.loaded, [0, 1], 'Two unresolved initializations fill the shared budget')
+    h.checkBudget()
+    await h.view([2], 1000, false)
+    assert.deepEqual(h.loaded, [0, 1])
+    await h.resolve(0)
+    await h.view([2], 1000, false)
+    assert.deepEqual(h.loaded, [0, 1, 2], 'Only released reservation is replaced')
+    h.checkBudget()
+})
+await scene({ deferredIds: [1] }, async h => {
+    await h.view([1]); await h.reject(1); await h.view([2])
+    assert.deepEqual(h.loaded, [0, 1, 2], 'Rejected load releases capacity for the next target')
+    assert.equal(h.control.snapshot().find(e => e.id === 0).cached, true, 'Unrelated valid cache survives rejection')
+    h.checkBudget()
+})
+await scene({ deferredIds: [1] }, async h => {
+    await h.view([1]); h.control.detach(); await h.resolve(1)
+    assert.deepEqual(h.added, [0], 'Disposed controller never attaches a late model')
+    assert.deepEqual(h.disposed.slice().sort(), [0, 1], 'Cache and late model are each released once')
+    assert.equal(h.control.snapshot().length, 0)
+})
+console.log('PASS residency boundaries: hidden state, wakeup, LRU, budgets, pending reservation, failure and release')