# 26: 诊断票 —— 主场景等待后画面抽搐的根因定位 Type: research Status: resolved Assignee: ASUS Label: ready-for-agent Category: frontend-diagnosis ## 来源 票 18 真人走查第 3 项(2026-10-10):进入主场景后等待一段时间,整个画面进入**抽搐状态**,与期望不符。 ## 已知观测 - 现象为「等待一段时间后」出现,说明与时间或后台加载相关,而非首帧。 - 抖动是整个画面级别的,不是单个图层。 ## 纪律 **当前不猜成因。** 候选方向仅作为待排除清单,不是结论:雾过渡(`domain/fog.js` 的 altitude 收口与 slew 限速)、主场景区限位(`domain/areaLimits.js`)、3D Tiles 批次懒加载与渲染节流(`services/tilesetLoading.js`、`requestRenderMode`)、影像粗→精交接(`domain/imageryHandover.js`)、相机控制与动画循环。 必须先建立可复现、可判定的反馈回路,再给结论;不得凭读代码猜一个原因就改。 ## 范围 1. 构建可重复的复现步骤(进入模式、相机动作、等待时长、是否必现、是否与移动/静止相关),并记录可观察指标(帧率、长任务、渲染节流状态、图层批次加载事件)。 2. 逐项排除候选方向,给出证据链;必要时做最小对照实验(关闭单个子系统看抖动是否消失)。 3. 定位到根因后给出修复方案与回归验证方法。 ## 验收 产出:可复现步骤 + 判定依据 + 根因 + 修复建议;若判定为非缺陷(例如加载期正常表现),需用证据说明并给出期望行为的重新定义。 ## 关联 发现于票 18;修复属前端执行票范围,本票只做诊断。 ## 进度 - 2026-10-12:认领;建反馈回路(CDP 无头探针,仓库既有 `cdp-driver.mjs`),两轮干净复跑 + 一轮消融,根因定位完成(见 Answer)。 ## Answer **结论:抽搐 = 每 30 秒的新鲜度 tick 触发地图摘要全量分页重载,每一批到达都把整个管线层(5000+ 实体)判定为「变化」并重同步,主线程每次阻塞 ~175ms、一轮 8 批连续发生 —— 静置画面每 30 秒规律性卡顿数秒,即真人看到的「抽搐」。** ### 可复现步骤 1. dev server(:8000)+ 真实后端(`config.json` → `real`),冷启动进 `/cockpit`,点「点击进入系统」。 2. 不做任何交互,静置 ≥70 秒。 3. 现象:进入后前 ~25 秒画面平顺;**~30s 与 ~60s 各出现一段持续数秒的卡顿窗口**,与 `map-snapshot` 的 8 连发分页重取(间隔 ~250-550ms)精确重合。 ### 判定依据(探针数据,两轮独立复跑一致) 探针 [`verify/artifacts/t26/probe-jitter.mjs`](../verify/artifacts/t26/probe-jitter.mjs)(页内 rAF 帧间隔 + longtask + WS/REST 时刻 + `__cockpitMap` 相机/雾/交接/区域状态轮询): | 观测 | 结果 | 判定 | | --- | --- | --- | | 帧间隔(5s 分桶 p50/p95/max) | 平时 p50=16.7ms(60fps);30s/60s 窗口 p95 冲到 400-666ms、max 700ms | 卡顿是**周期性**的,非随机性能问题 | | 长任务与 REST 对齐 | `start=61123ms dur=176ms`、`start=61623ms dur=175ms`,±400ms 内恰有 `map-snapshot` 响应到达 | 每批快照到达 → 一次 ~175ms 主线程阻塞 | | 相机 h/p/r、雾状态、交接阶段、区域状态 | 全程 **0 次翻转**(201 采样,振荡检测 flips=0) | **排除**票面候选里的「雾 slew/限位打架/交接循环/相机控制」四族 | | WS 帧 | 0 帧(期间无增量推送) | 排除 WS 增量触发 | | 控制台错误 | 0 | 排除异常路径 | ### 根因(代码链,逐行核实) ``` freshness tick(DEFAULT_REFRESH_SECONDS=30,freshnessService.js:22) → CockpitShell tick: mapStore.refresh() + refreshCurrentModePanels()(CockpitShell.vue:1443-1452) → mapStore.refresh() = setup + runLoadChain('refresh')(mapStore.js:211-215) → loader.refresh() 先 emptyMapSummary() 再逐页 loadNext(),auto-batch 间隔 120ms —— 共 ~8 页 → 每页到达 summary.value = 新对象 → MapCanvas watcher(summary.objects)触发 applyMarkers() → applyMarkers 每批都做两件事(MapCanvas.vue:257-268): ① syncPointMarkers:entities.removeAll() + 重建全部标记(cesiumEngine.js:767-769) ② syncPipeLayerForPlan → syncPipeLayer: 对全部 ~5268 管线 + 设施实体无条件执行 existing.polyline = spec.polyline / existing.point = spec.point / existing.label = spec.label(cesiumPipeLayer.js:144-169) ``` **要害在 ②**:`syncPipeLayer` 注释声称「只做 upsert 不全量重建」,但 upsert 分支**每次都把整个 `PolylineGraphics`/`PointGraphics` 对象重新赋值**——Cesium 的 `Entity.polyline` setter 是属性级订阅,重赋值即 `definitionChanged` → 几何失效 → **贴地折线全网重新 tessellate**。也就是说「数据没变」时它照样做全量重建,`entityIndex` 只挡住了 `add/remove`,没挡住属性重赋值。`modeLayerPlan` 每次重算还 `pipelines.slice()` 造新数组引用(modeLayerPlan.js:106),watcher `deep:false` 照样触发——两层防线全部绕过。 **放大器**:引擎开 `requestRenderMode: true`(cesiumEngine.js:77),每批实体变更都驱动一次重渲染;8 批 × (5000+ 属性失效 + 标记 removeAll/重建) 在 ~4-8 秒内连续发生 → 主线程分块阻塞 + 标记层肉眼可见地闪 8 次 → 「整个画面抽搐」。与票面「等待一段时间后」吻合:首屏后第一个 tick 就在 30 秒。 ### 消融验证 - **消融 A**([`probe-jitter-ablate.mjs`](../verify/artifacts/t26/probe-jitter-ablate.mjs),15s 后拦截 `map-snapshot`):30s/60s 周期性 stall **消失**(p95 回 16.7ms),仅剩单次 ~250-500ms 尖峰(拦截后 refresh 仍会做一次空载重同步)。**注意局限**:`loader.refresh()` 失败会把 summary 清空,故消融 A 不能单独定罪——已用代码链 + 下述旁证补齐。 - **旁证**:尖峰单次(消融 A)vs 连续数秒(基线)的差异 = 8 批连续重同步 vs 单次,与「每批一次全量重同步」的机制预测一致。 - **消融 B**(缓存回放)因探针补丁干扰实验(回放计数 0 却出现 40 秒持续 1.5fps 崩塌,疑补丁破坏 XHR 路径)**作废不采信**;其意外副产品记录在案:该场景若被强制连续渲染,每帧 ~130ms(SwiftShader 软渲染口径,仅作参考)。 ### 硬件口径(如实声明) 全部测量在**无头 Chrome + `--disable-gpu`(软件渲染)**下取得:绝对帧时与真机 GPU 不可比,但**机制(每 tick 8 批 × 全管线重同步、每批 ~175ms JS 阻塞)与硬件无关**,真机表现为「每 30 秒数秒规律性卡顿/闪烁」与走查描述一致。本会话未在真机 GPU 上复测(浏览器会话在诊断中途已关)。 ### 修复建议(归前端执行票,本票不改代码) 1. **`syncPipeLayer` 真 diff**:upsert 前比较 spec(内容等值或脏标记),未变不重赋值——这是根治点,把「每批 5000+ 实体失效」降为 0。 2. **计划层防抖**:`modeLayerPlan` 对 `pipelines`/`facilities` 做引用记忆(输入未变不 `slice()` 新数组);或 `applyMarkers` 在 auto-batch 期间合并(批间 120ms 足够 debounce 到每轮 refresh 只重同步 1-2 次)。 3. **标记层 diff**(次要):`syncPointMarkers` 去掉 `removeAll()` 改增删差量,消除刷新期标记闪烁。 4. **回归方法**:重跑本票探针,验收判据 = 70s 静置内 (a) 无任何 ≥100ms 长任务落在 `map-snapshot` 响应后 ±500ms;(b) 30s/60s 窗口帧 p95 ≤ 33ms。消融 A 脚本可直接复用为反向对照。 ### 关联事实(顺带) - `refreshCurrentModePanels()` 每 tick 还重取四块面板数据(Vue DOM 重渲染,量级远小于地图层),修复后可一并观察是否仍有小尖峰。 - 票 27 修复(台账 `pageNote`)与本缺陷无交集,互不影响。 Status: resolved Type: research Blocked by: #19, #22 Labels: ready-for-agent, frontend-diagnosis, wayfinder:task ## Comments