39-coarse-to-fine-handover.md 19 KB

39: 粗→精影像交接

What to build: 从全球飞到县城视角的全过程不出现肉眼可辨的矩形色块跳变。全球/引导态先隐藏精细叠加影像、保留粗底图;进入时等雾先形成保护、目标视野可显示,再渐显精细层;返回时反向交接。这是需求③的落地。

Blocked by: 38

Status: resolved Labels: ready-for-agent

契约来源

  • 规格:cockpit-primary-area-guardrails/spec-fog.md 的「与影像的边界」;cockpit-primary-area-guardrails/spec.md 的进出状态机。
  • 决策:cockpit-primary-area-guardrails/issues/08-coarse-to-fine-transition-solution.md(已 resolved,含维护者在真实场景的三条反馈)。
  • 可丢弃原型(只作参考,不进生产):src/modules/cockpit/prototypes/TransitionPrototype.vue 与同目录 scene 脚本、prototypes/prototype-coarse-to-fine-transition.html;打开方式写在决策票 08 的 Answer。

验收

  • 全球自转/引导态:精细叠加影像隐藏、保留粗底图;隐藏期间可继续预取瓦片(看不见≠停止加载)。
  • 进入:先满足「雾保护成立」且「目标视野瓦片可显示」,才渐显精细层;目标未就绪不揭示,避免先空白再跳方块。
  • 返回:反向交接 —— 精细层先收起,保护再撤除;镜头高度上升与雾气模糊减弱同步,不得出现「影像收完 → 停顿 → 镜头才飞走」。
  • 取消 / 快速返回 / 再次进入:只认最新目标;过期的渐显或撤雾不得生效,不留半透明卡死态。
  • 瓦片失败:保持隐藏、继续用粗底图,并如实提示。
  • 雾失效:业务可用优先 —— 提示降级并渐显精细层,不承诺完全遮边。
  • 三条实测反馈必须落地:进出雾变化比原型稍快;雾偏冷白灰、不发绿;返回时高度与雾同步。
  • 判据:真机「进入 → 停留 → 返回」全程无肉眼可辨的方形跳变;覆盖浅俯角与多个旋转方向,结论不能只基于一个方向。维护者 2026-09-30 真机调定后确认观感通过("目前的效果就行")。
  • 矩形内部随地形 LOD 的逐级换挡不由整层 alpha 修复;若仍可辨,按 40 评估局部过渡手段。已如实记录为"本层不解决"(决策 08 明确),是否可辨的判定与修复评估归 40。
  • 原型不进生产:/cockpit 入口的 DEV 原型接线(transitionPrototype 一路)在本票内收敛,生产只吸收已确认的交接语义。按维护者裁定收窄为"隔离":生产交接是 alpha 唯一写入方、与原型显式互斥;删除与归档归 40(见下)。

进度:100%

实现完成、过闸门;真机观感由维护者 2026-09-30 现场调定并确认通过,本票收口。

维护者在真机上自行完成一轮时长/阈值调试并裁定"以我为准"(部署值见 「维护者真机调定与收口」),观感判据(验收第 8 条)据此通过。 两条有意放宽(requireTilesLoaded: false、退出发白窗口 ≈387 ms)已如实记录在票面与部署手册。

按维护者裁定(2026-09-28):39 只做隔离,原型的删除与归档留给 40。

一处口径按维护者裁定收窄(见下「原型接线的归属」)。

原型接线的归属(维护者 2026-09-28 裁定)

验收第 10 条原文要求 transitionPrototype 那路 DEV 接线「在本票内收敛」,但票 40 的验收又写着 「可丢弃原型与 /cockpit 入口的 DEV 接线单独保存到 throwaway 分支,并在本票留分支指针」—— 两条重叠、归属不清。维护者裁定:39 只做隔离,删除与归档留给 40。

据此本票的实际落点(不是原文的字面"删掉接线"):

  • 生产与原型互斥:两套逻辑都写 ImageryLayer.alpha,同时跑必然互相覆盖。 现在生产交接是 alpha 的唯一写入方;原型挂载时调 setHandoverPrototypeActive(true) 显式接管(不是"谁后跑谁赢"),卸载时交还并重画一次。
  • 已加静态守卫 + 变异测试确认:把让位判断改成 if (false) 会被闸门打红。
  • 原型的文件与入口接线仍在,由票 40 负责归档到 throwaway 分支并留指针。 这样 40 的「原型归档」验收不至于是空的。

改动

  • 新增 domain/imageryHandover.js:交接状态机(相位 / alpha 推进 / 落定必为 0 或 1)、 揭示闸门(全球不揭示、雾保护、目标视野、边缘风险、瓦片失败)、返回同步判据、 边缘遮蔽率(与雾曲线同源)、过期判定、配置归一化、读数。 纯函数、不 import cesium、不读 DOM。
  • services/cesiumEngine.js:applyOverlays 改为返回句柄(不再只返回层数)—— 句柄的 setAlpha() 是 alpha 的唯一写入点;新增 createOverlayReadiness() 采集 globe.tilesLoaded 与瓦片失败(后者很关键:Cesium 在没有 errorEvent 监听者时 会把瓦片错误直接抛到窗口,见 ImageryLayer.js:1708,走查的「无页面级报错」会因此误伤)。
  • services/mapService.js:交接的策略与生命周期唯一所有者; 门控与顺序收口在 setAreaState 与渲染循环;宿主读数 8 个 data-map-handover*; DEV 把手 getHandoverState / getHandoverParams / advanceHandover / setHandoverPrototypeActive。
  • services/cockpitConfig.js:map.handover(缺省=默认;minEdgeOcclusion 越界退回默认并记 issue)。
  • docs/cockpit-deployment.md:新增 2.12 粗→精影像交接 + 一页速查 + 自检两行; config.example.json 同步该字段。

关键取舍(两条故意不照搬原型的口径)

  1. 边缘保护判据不拍脑袋。 原型用「相机距矩形边 ≥ 38 km」这个魔数。本模块改为 「把相机到矩形四边的最小距离代入同一条雾衰减曲线,要求遮蔽率 ≥ minEdgeOcclusion(默认 0.9)」。 阈值与雾参数同源,日后调雾端点时边缘判据自动跟随,不会各调各的。 实算证明不能照抄:区内最坏点(西南角)距影像边 35.8 km,遮蔽率 0.967(过闸); 若照抄 38 km 字面值,这个点会被误判为不安全而挡住本可显示的影像。
  2. 瓦片就绪只是代理信号。 globe.tilesLoaded 仅表示加载队列为空,不证明目标视野逐像素就绪 (决策票 08 明确警告)。读数里带 tilesReadyIsProxy: true、宿主带 data-map-handover-tiles-proxy="yes" 如实标注,免得下游把它当充分证明。

另有两处实现上的判断:

  1. "隐藏"必须用 alpha = 0 + show = true。 瓦片是否被请求由 GlobeSurfaceTile.js:691 按 layer.show 决定,不看 alpha。用 show = false 会真的停掉预取, 揭示时才开始下载 —— 正好造成"先空白再跳方块"。读数里 layerProbe 可直接核对。
  2. 返回拆成两个并行进程,而不是串行等待。 决策 08 同时要求 「影像在失去保护前收起」与「不能影像收完→停顿→镜头才飞走」,字面上矛盾。 解法:按下返回的那一帧就同时开始收影像与飞镜头;雾延后到影像确实不可见(alpha=0)才淡出。

证据

逻辑闸门 verify/t39-handover.mjs 通过,覆盖:相位与 alpha 端点(落定必为 0/1)、 闸门四类拦截与"三条件齐备才放行"、降级(雾不可用仍揭示但不承诺遮边、且不绕过瓦片就绪)、 瓦片失败保持隐藏且优先报出、边缘判据与雾曲线同源、返回同步(按下返回那一帧绝不撤雾)、 过期取消与幂等、配置可调且脏值退回默认、读数不承诺已遮边、静态守卫 8 条。

变异测试 15 处,全部被对应断言打红(一次性脚本跑完即删):闸门不看雾强度/瓦片/边缘、 全球态也揭示、瓦片失败也放行、降级锁死业务、返回立刻撤雾、落定停中间值、 边缘判据自写一套曲线、读数谎称已遮边、隐藏改用 show=false、原型不互斥、 配置脏值不退回默认、瓦片信号不标代理、进出动作被稳态节流吞掉、 渐显途中不幂等(见下方真 bug ②)。

真机时序证据(页面可见时采集,逐 100 ms 采样)——进入:

时间 雾强度 精细层 alpha 挡板
102–901 ms 0.03 → 1.00 恒为 0 fog(等待雾形成保护)
1009 ms 1.00 0.025 放行
1009–1710 ms 1.00 0.025 → 0.142 → 0.323 → 0.534 → 0.770 → 0.904 → 0.993 渐显
1710 ms 起 1.00 1.00(revealed) —

返回:

时间 精细层 alpha 雾强度 说明
103–703 ms 0.942 → 0.797 → 0.603 → 0.390 → 0.196 → 0.053 → 0 恒为 1.00 影像先收起,雾全程保持保护
803–1502 ms 0(已收起) 1.00 → 0.96 → 0.85 → 0.69 → 0.50 → 0.32 → 0.16 → 0.04 → 0 收起之后才开始撤雾

全程 appliedAlpha 与状态机 alpha 逐帧一致 —— 图层真的被写(票 38 的"读数对、画面没变"陷阱)。 另有隐藏期间的可核事实:layerProbe = [{ show: true, alpha: 0 }] —— alpha 归零但 show 仍为 true,即"看不见 ≠ 停止加载"成立。

全量回归:26 个零依赖逻辑闸门全绿(t05 守卫面纳入 imageryHandover.js,236→248)。

真机验证抓到的两个真 bug(都已修 + 补了回归)

① 隐藏若用 show = false 会停掉预取。 实现时查了 Cesium GlobeSurfaceTile.js:691, 确认瓦片请求只看 show 不看 alpha,据此定成 alpha=0 + show=true; 并加静态守卫禁止 layer.show = false(变异确认有牙)。

② 渐显途中 beginHandover 不幂等 → alpha 永远到不了 1,精细层永不出现。 服务层每渲染帧都会调一次 beginHandover("条件还成立吗"天然要重复问)。 第一版只在端点(REVEALED/GLOBAL)幂等,于是渐显途中每帧都被当成新一轮: since 每帧被重置 → elapsed 恒 ≈0 → alpha 一直是 0 附近。 最阴的地方:所有读数都显示"entering / 正在渐显 / 保护成立",看起来完全正常 —— 只有画面不动。逻辑闸门当时抓不到(它只验相位,不验"推进时间后 alpha 是否真的变大")。 已修(同一相位幂等、不重置时间轴;换方向才重置),并补一条行为断言: 同一相位连续调 beginHandover 后 since 不得变、且推进一个 fadeMs 后 alpha 必须到 1 (变异确认能抓住)。

维护者第二次现场复核后的两处修正(2026-09-29)

维护者复核票 38 第一版的高度收口后,指出两处新问题;详细的根因与反解过程记在 票 38 的「维护者第二次现场复核」一节(雾是那边的实现面),此处只记与交接有关的部分:

  1. 「进入时最后一小段时间画面会变绿」 —— 维护者诊断:"粗糙地球贴图出现了但是精细地形 还未出现,雾的渐出与场景的渐入没有很好的衔接上",并选定修法「缩短精细影像渐显时长」。 原因是几何:雾是距离雾(18 km 起步),而区内相机只有约 9.8 km 高 ⇒ 屏幕中心(脚下街区)视线距离恒 < 18 km,雾恒为 0,结构上盖不住中心。 于是中心的观感只看精细层 alpha:渐显期间它是「粗糙底图(陆地偏绿)↔ 精细影像」的混合, 窗口越长绿越明显。 改动:HANDOVER_DEFAULTS.fadeMs 700 → 380 ms(比原型 900 ms 短得多, 但仍是一段可辨的渐变而不是硬切)。刻意没有去调雾的强度 —— 按上面的几何,调强度对中心无效。
  2. 「退出速度有一点太快」 —— 返回全球原先走阻尼,目标 range 是 20,000,000 m、 起点 12,000 m(跨 1.7e6 倍),指数逼近起步最快,头一帧就越过整条雾带(实算 0.15 ms)。 即"雾几乎一瞬间就散了"、且那时雾还满着 ⇒ 整球发白。 修法在相机侧(RETURN_FLIGHT_MS + 慢起缓动),但约束是交接定的: 雾淡完于 handover.fadeMs + fog.fadeOutMs,那一刻相机应仍在 42 km 以下。 该跨模块关系已固化成本票闸门的第 12 节 —— 但 2026-09-30 维护者按现场观感有意放宽了它,故第 12 节改为 "把窗口算出来并钉住已接受上限(400 ms)"而非硬断言(详见下方收口一节)。

map.handover.fadeMs 保持可配;380 ms 是当时的取值,最终由维护者现场定为 500 ms。

观感判据(原"待维护者目视",2026-09-30 已由维护者真机确认)

下面四项原本是 agent 不能代判的观感项。维护者在真机上走查并调定时长后 确认"目前的效果就行"(见「维护者真机调定与收口」),故本票据此收口。 保留原文以便日后回归时知道该看什么、以及当时依据的是哪条判据。

  1. 「全程无肉眼可辨的方形跳变」 —— 本票提供的是时序正确的读数证据: 雾先到位、影像后亮、返回先收后撤,且全程无"影像可见而雾已撤"的帧。 回归时应覆盖浅俯角与多个旋转方向(决策票要求结论不能只基于一个方向)。
  2. 矩形内部随地形 LOD 的逐级换挡 —— 决策 08 明确"不能靠整层 alpha 修复"。 本层不声称已解决;若日后仍可辨,按票 40 评估局部过渡或 shader 羽化(预烘焙中精度层仅作兜底)。
  3. 三条实测反馈的最终观感:进出雾变化是否够"稍快"(map.fog.fadeInMs/fadeOutMs 可调)、 雾色是否"冷白灰不发绿"(票 38 已确认色相)、返回时高度与雾是否"看起来同步"(本票已保证时序)。
  4. 2026-09-29 新增两条:
    • 「进入时最后一段还会不会变绿」—— 渐显窗口从 700 ms 压到 380 ms,最终由维护者现场调为 500 ms;
    • 「退出够不够慢、雾是不是一段淡出而不是一闪就没」—— 返回改成慢起飞行 + 高度系数时间限速(altitudeSlewMs),最终现场值为 RETURN_FLIGHT_MS = 1000、altitudeSlewMs = 600。

已知限制(如实记录)

  • 本会话浏览器标签页大部分时间 document.hidden=true:rAF 被冻结, 雾的时间轴推进与交接的逐帧推进都停摆(读数为 0)。上表的时序证据是在标签页可见时采集的; 隐藏标签页下闸门行为本身仍正确(如实报 block: fog、拒绝过早揭示),但不能用隐藏页判断推进。
  • 本地 puppeteer 不可用(@puppeteer/browsers 为空目录),故 verify/t39-handover-walkthrough.mjs 已按既有写法备好、供修复后一键复跑; 本票的真机证据由 DSH 浏览器逐项采集(上表),两者逐条对应。

维护者真机调定与收口(2026-09-30)

维护者在本机对时长与阈值做了完整一轮现场调试,并明确裁定:以现场观感为准。 原话(两轮问答):

①「就按我调整的数值来,不要继续检查和改动了,目前的效果就行」 ②(关于高度带)「我已改为 altitudeFullM: 14000 / altitudeZeroM: 3600000(后修至 3000000),以我为准」

据此的最终落点(部署层=public/cockpit/config.json,已 gitignore):

项 代码默认(规格取向) 本部署现场值
fog.fadeInMs 800 900
fog.fadeOutMs 400 300
fog.altitudeFullM 20000 14000
fog.altitudeZeroM 42000 3000000
fog.altitudeSlewMs 400 600
handover.fadeMs 380 500
handover.minEdgeOcclusion 0.9 0.7
handover.requireTilesLoaded true false
RETURN_FLIGHT_MS — 1000
返回缓动 — smoothstep^6.5 混入 0.1% 线性项

两条必须如实记录的放宽(都是维护者的明确取舍,不是疏漏):

  1. requireTilesLoaded: false —— 不再等"瓦片加载队列为空"就允许揭示。 决策 08 当初设这一条正是为了避免"先空白再跳方块";关掉后牺牲的就是那个保障。 已写进部署手册 2.12 的字段说明(含代价与回退方法)。
  2. 退出时"相机已高于雾的全遮蔽端点、而雾仍可见"的窗口 —— 本组合下 相机在 ~413 ms 越过 42 km,而雾要到 500 + 300 = 800 ms 才淡完,窗口 ≈ 387 ms。 结构性约束(雾应先淡完)在部署层被有意放宽;维护者已确认当前观感可接受。 没有把它偷偷写成"通过":见下面对闸门的处理。

闸门的相应处理(不掩盖、保留牙):原第 12 节是一条硬断言 ("雾淡完时相机必须仍在 42 km 以下"),它在 T=1000 下不可能成立, 留着就是一条永远红的假门。已改写为把窗口算出来并钉住已接受上限(400 ms):

  • 缓动的结构性性质(慢起、两端精确、单调、真的走 flightMs 而非阻尼)仍硬断言;
  • 窗口值改为"超过已接受量级就红",逼下一次"这是有意的吗"的确认 —— 6/6 定向变异全部打红(退回光滑缓动、加大线性项、放大 fadeMs/fadeOutMs、 返回退回阻尼、缓动停在中途)。

同时修掉一处会误导人的注释:camera.js 里 RETURN_FLIGHT_MS 与缓动的 JSDoc 原先写着 "指数 7 由不变量反解"的推导,而维护者已把缓动改成 6.5 + 0.1% 线性项、 且约束已被放宽 —— 注释若照旧就是在源码里说谎。已改写成: 说明结构性约束本身、说明本部署按观感有意放宽、并指出代码默认值仍保持"雾先淡完"的取向。

新增一条配置层守卫:altitudeZeroM 必须大于 altitudeFullM。 写反会让高度系数恒为 1(等于把这层保护关掉),而它正是"退出整球发白"的防线。 只报 issue、不替改值 —— 两个数各自都合法,"要不要在高空保留雾"是部署取舍 (本部署有意把上端放到 3000 km,使飞入全程都有雾)。

验证:node --check 全部通过;两个配置文件 JSON 合法且归一化 issues 为空; t38-fog / t39-handover 均绿;全量 29 个闸门通过, 仅 2 个红且与本票无关(t04-config 的"不该硬编码后端地址"与 t44-device-assets 由并发会话正在改的 cockpitApi.js / deviceAssets.js 引起,我未触碰这些文件)。

环境限制(如实记录):本会话的工具清单里已没有浏览器工具 (browser_refresh 报 "unknown tool"),故本轮的"观感通过"是维护者在真机上自行确认的, 不是我这边采集的证据。我提供的是算术、闸门与变异证据。

结论

验收第 8 条(观感判据)由维护者真机确认通过("目前的效果就行"); 其余 9 条此前已逐条落地并有闸门/变异证据。本票按维护者裁定收口。