Selaa lähdekoodia

fix(cockpit): 防止持续移动时精模开装饥饿

低配机记录和真实控制器回放表明:粗模已就绪且近处候选有预算时,持续移动仍因硬性闸门完全不发起细模区域装载。

移动期改为受控单槽,落定恢复原配置并发;不取消已经开启的流,保留预算、粗模就绪、失败冷却和异步清理。未修改LOD、分辨率、材质或驻留策略。

独立HEAD回放先红后绿;当前工作树相关22项非变异回归及两项独立回归通过。整体帧率与加载尖峰仍待同条件实机复验,不宣称FPS改善。

关联 #59。
ChenJZ 3 päivää sitten
vanhempi
commit
492aef1bde

+ 6 - 11
src/modules/cockpit/services/regionLoading.js

@@ -457,21 +457,16 @@ export function createRegionLoadingController({
                 loading.delete(e)
             }
         }
-        // 串行槽已换成"有限并发":只要没到并发上限就继续开下一个(不再等前一个全装完)。
-        // 内容仍在流的区域不阻塞别的区域开装 —— 前沿收缩已经管住了"在动时少选"。
-        //
-        // **移动期不开新装载**:相机在动时每一次 region 初始化都会带着一批瓦片请求与
-        // 上传,直接撞在移动帧上(这就是近景移动卡顿的来源)。
-        //
-        // 但闸门只挡 **start 新 tileset 实例** 这一步;卸载(unload)与显隐(syncVisibility)
-        // 必须在每帧照常——否则移动中扫过一片建筑时,已移出前沿的旧区域赖着不走,新进入的
-        // 又不开,观感比"移动期暂不上新"更糟。
+        // 移动时也让已准入的近处候选渐进开装,避免持续操作期间一直只有粗模。
+        // 移动期仅一个初始装载槽,落定后恢复配置并发;不取消已有流、不放宽选择预算。
+        // 这是区域初始装载并发限制,不是单帧瓦片上传预算。
         const moving = now() - lastMoveAt < movingWindowMs
-        if (!moving && loading.size < loadLimit && !pipeline && canStart()) {
+        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 >= loadLimit) break
+                if (loading.size >= startLimit) break
                 void start(candidate)
             }
         }

+ 149 - 0
tools/city-massing/verify-region-moving-admission.mjs

@@ -0,0 +1,149 @@
+import assert from 'node:assert/strict'
+import { createRegionLoadingController } from '../../src/modules/cockpit/services/regionLoading.js'
+
+// Replay the load boundary seen in the low-spec exports: coarse is ready,
+// close candidates remain visible, but the camera never stops moving.
+let clock = 0, pose = 'initial', visible = false
+const started = [], pending = new Map()
+const controller = createRegionLoadingController({
+    regions: [1, 2].map(id => ({ id, contentCount: 1, decodedGeometryBytes: 1024 })),
+    load: region => { started.push(region.id); return new Promise(resolve => pending.set(region.id, resolve)) },
+    add() {}, remove() {}, requestRender() {},
+    distance: region => visible ? region.id * 10 : Infinity,
+    canStart: () => true, readCameraKey: () => pose, now: () => clock,
+    setTimer: () => 1, clearTimer() {},
+})
+try {
+    controller.refresh()
+    visible = true
+    for (let i = 1; i <= 8; i++) {
+        clock = i * 180
+        pose = 'moving-' + i
+        controller.onRender()
+    }
+    assert.equal(controller.frontierSnapshot().moving, true)
+    const whileMoving = started.slice(), movingCount = controller.snapshot().length
+    clock += 1000
+    controller.refresh()
+    console.log(JSON.stringify({ whileMoving, afterSettling: started }))
+    assert.deepEqual(started, [1, 2], 'Same candidates start under the unchanged stationary concurrency limit')
+    assert.deepEqual(whileMoving, [1], 'Continuous movement must admit one nearest region, not starve all fine loading')
+    assert.equal(movingCount, 1, 'Movement keeps at most one initial load in flight')
+    console.log('PASS moving admission: visible near candidate progresses through one bounded load slot')
+} finally {
+    controller.detach()
+    for (const resolve of pending.values()) resolve({ show: false, tilesLoaded: false })
+    await Promise.resolve()
+}
+
+// Boundary cases use the same public controller and explicit asynchronous loads.
+const flush = async () => { await Promise.resolve(); await Promise.resolve() }
+async function check(options, scenario) {
+    const { count = 3, ...controllerOptions } = options
+    let clock = 0, pose = 0
+    const state = { visible: false, coarseReady: true }
+    const started = [], added = [], removed = [], models = new Map(), waits = new Map()
+    const controller = createRegionLoadingController({
+        regions: Array.from({ length: count }, (_, i) => ({ id: i + 1, contentCount: 1, decodedGeometryBytes: 1024 })),
+        distance: r => state.visible ? r.id * 10 : Infinity,
+        readCameraKey: () => String(pose), now: () => clock,
+        canStart: () => state.coarseReady,
+        coverageReady: e => e.instance.covered === true,
+        load: r => { started.push(r.id); return new Promise((resolve, reject) => waits.set(r.id, { resolve, reject })) },
+        add: m => added.push(m), remove: m => removed.push(m), requestRender() {},
+        setTimer: () => 1, clearTimer() {},
+        ...controllerOptions,
+    })
+    controller.refresh()
+    const h = {
+        controller, state, started, added, removed,
+        async step(ms = 180, moving = true) { clock += ms; if (moving) pose++; controller.onRender(); await flush() },
+        async resolveLoad(id, ready = false) {
+            const wait = waits.get(id)
+            assert.ok(wait, 'Only pending loads can resolve')
+            const m = { show: false, tilesLoaded: ready, covered: ready }
+            models.set(id, m); wait.resolve(m); waits.delete(id); await flush(); return m
+        },
+        markReady(id) { const m = models.get(id); m.tilesLoaded = m.covered = true },
+        async fail(id) { waits.get(id).reject(new Error('fixture load failed')); waits.delete(id); await flush() },
+    }
+    try { await scenario(h) } finally {
+        controller.detach()
+        for (const wait of waits.values()) wait.resolve({ show: false, tilesLoaded: false, covered: false })
+        await flush()
+    }
+}
+
+await check({}, async h => {
+    h.state.visible = true
+    await h.step()
+    assert.deepEqual(h.started, [1], 'Nearest candidate starts first during movement')
+    await h.resolveLoad(1) // fromUrl resolved, but content is not ready yet.
+    await h.step()
+    assert.deepEqual(h.started, [1], 'Streaming keeps the single moving slot occupied')
+    assert.equal(h.controller.detailReady(1), false, 'Incomplete coverage keeps coarse fallback')
+    h.markReady(1)
+    await h.step()
+    assert.deepEqual(h.started, [1, 2], 'Arrival releases the slot without waiting for camera settle')
+})
+
+await check({}, async h => {
+    h.state.visible = true
+    await h.step(300, false)
+    assert.deepEqual(h.started, [1, 2], 'Stationary phase retains configured parallelism')
+    await h.step()
+    assert.equal(h.removed.length, 0, 'Moving does not cancel the two existing initial loads')
+    await h.resolveLoad(1, true); await h.step()
+    assert.deepEqual(h.started, [1, 2], 'Do not add a new load while one existing slot remains')
+    await h.resolveLoad(2, true); await h.step()
+    assert.deepEqual(h.started, [1, 2, 3], 'Resume bounded moving admission after existing work completes')
+})
+
+for (const budget of [{ maxContents: 1 }, { maxDecodedBytes: 1024 }]) {
+    await check({ budget }, async h => {
+        h.state.visible = true
+        await h.step(); await h.resolveLoad(1, true); await h.step()
+        await h.step(1000, false)
+        assert.deepEqual(h.started, [1], 'Neither movement nor settling bypasses the selection budget')
+    })
+}
+
+await check({ maxConcurrentLoads: 1 }, async h => {
+    h.state.visible = true
+    await h.step(300, false)
+    assert.deepEqual(h.started, [1], 'Explicit single-slot configuration still applies when stationary')
+    await h.resolveLoad(1, true); await h.step(300, false)
+    assert.deepEqual(h.started, [1, 2])
+})
+
+await check({}, async h => {
+    h.state.visible = true; h.state.coarseReady = false
+    await h.step(); await h.step()
+    assert.deepEqual(h.started, [], 'Coarse readiness gate remains mandatory')
+    h.state.coarseReady = true
+    await h.step(); assert.deepEqual(h.started, [1])
+    h.controller.setPipeline(true)
+    const m = await h.resolveLoad(1, true)
+    await h.step()
+    assert.equal(h.added.length, 0, 'Late completion cannot attach in pipeline mode')
+    assert.deepEqual(h.removed, [m], 'Rejected late model is released exactly once')
+    assert.deepEqual(h.started, [1], 'Pipeline mode does not admit new ground regions')
+})
+
+await check({ count: 1 }, async h => {
+    h.state.visible = true; await h.step(); await h.fail(1)
+    await h.step(); assert.deepEqual(h.started, [1], 'Failure cooldown prevents repeated starts')
+    await h.step(15001)
+    assert.deepEqual(h.started, [1, 1], 'Retry can progress during movement after cooldown')
+})
+
+await check({ count: 1 }, async h => {
+    h.state.visible = true; await h.step()
+    h.state.visible = false; await h.step()
+    const m = await h.resolveLoad(1, true)
+    assert.equal(h.added.length, 0, 'Withdrawn candidate cannot attach after async completion')
+    assert.deepEqual(h.removed, [m])
+    h.state.visible = true; await h.step()
+    assert.deepEqual(h.started, [1, 1], 'Rejected completion releases its slot for a new candidate')
+})
+console.log('PASS moving admission boundaries: progress, streaming cap, static transition, budgets, coarse gate, cancellation, retry')