# 38: 区内视距雾气 **What to build:** 在主场景区内加一层**只作用于有有效几何深度地物**的距离雾:近处地物清晰、远处平滑融入雾色,旋转镜头时不露出远景瑕疵。区内生效、全球与引导态关闭;照到规格的默认参数,并在没有深度能力或运行失败时如实降级、不挡住业务。这是需求②的落地。 Blocked by: 37 Status: resolved Labels: ready-for-agent ## 契约来源 - 规格:`cockpit-primary-area-guardrails/spec-fog.md`(`ready-for-agent`;由决策票 07 固化)。 - 决策:`cockpit-primary-area-guardrails/issues/07-fog-implementation-spec.md`(已 resolved)。 - 可丢弃原型(只作参考,不进生产):`src/modules/cockpit/prototypes/FogPrototype.vue`、`fog-prototype-stage.js`;打开方式见 `cockpit-primary-area-guardrails/prototypes/fog-review.md`。 ## 验收 - [x] 单一 Cesium **`PostProcessStage`** 深度后处理雾;**不引入第二渲染器**(ADR 0004 / 0007;决策 06 已裁定废止「深度喂 Three」备选)。 - [x] 采用规格的 **B 方案**:只处理有有效几何深度的地物;无有效深度的背景保留原颜色,不被整屏染色。 - [x] 默认参数按规格固化:世界射线距离 **18,000 m 起雾、42,000 m 完全遮蔽**、基准雾色 **`#bdcdd6`**,中间平滑增浓;两端语义精确(0 处全清、42 km 处全遮蔽,单纯指数衰减不算达标)。 - [x] **冷白灰、不发绿**:最终色相在真实画面里由维护者复核确认(决策 08 的实测反馈;`#bdcdd6` 是起点不是终值)。**维护者 2026-09-28 在真实画面确认通过**(生效值仍为规格默认 `#bdcdd6`);另有可断言的色相判据兜底(见下)。 - [x] 门控:`areaState='locked'` 期间生效;纯全球/引导态关闭。进入途中的早期高度渐变**只作强度输入**,不作为「可揭示精细影像」的充分条件(原型的 60/20 km 高度带已被人工演示判为遮挡过晚)。 - [x] 生命周期:可提前准备 → 启用 → 调强度 → 退出淡出(基准 0.8 s)→ 取消过期操作 → 禁用 → 销毁;复用处理级对象,离开地图时销毁;连续进出只认最新目标(过期淡出回调不得关掉新的进入)。 - [x] 对外可观测:**是否支持**、**是否可执行**、**当前强度**、**当前参数**、**失败/降级原因**;**「就绪」不等于「边缘已遮住」**,不提供未经验证的全局「已遮缝」承诺。 - [x] 无深度支持或运行失败 → 整级禁用 + 如实提示;业务可用优先(近景与业务仍可用),精细层仍可渐显但只有雾可用时才承诺遮缝。 - [x] 管线视图出现透明对象错误雾化时,允许减弱或暂关自建雾,**保留相机限位**并提示;正常情况不预先关闭。 - [x] 三维设备特效随远景雾衰减、不开穿雾例外;若走场景内 billboard 则天然在雾前,若走后处理级则按「先特效、后雾」串联,两级**开关独立**;面板内报警文字与状态始终清晰。 - [x] 性能:同机 / 同分辨率 / 同视角 / 相同加载条件下,开启自建雾的 **P95 帧耗时增幅 ≤15%**;首次启用与进出切换的卡顿单独记录。超预算先优化,仍不达标则降质或关闭并提示。**实测 P95 增幅 0%**(见下;口径限制已如实标注)。 - [x] 证据:纯逻辑断言(衰减端点、进出强度推进、降级选择、过期取消)+ 真机目视与性能读数。**逻辑断言 24 个闸门全绿;真机目视由维护者 2026-09-28 确认通过;性能读数已实测。** - [x] 本票实现并验收规格;若实现中必须偏离规格结论,先回写票面并经维护者确认,不静默改口径。 ## 进度:100% **已完成并验收(2026-09-28)。** 维护者在真实画面确认「效果没问题,完成验收」: 色相(冷白灰、不发绿)、进出观感、以及"矩形边缘是否被遮住"三项目视判据一次通过。 **第二轮(2026-09-29/30)**:维护者复核后实报「退出时整个地球变白一段时间」并指出修复引出 「雾几乎一瞬间就产生或消失」;根因、修法(时间限速 + 慢起飞行)与最终现场取值见 「维护者第二次现场复核」一节。**结论:维护者 2026-09-30 真机调定并确认"目前的效果就行"。** **验收闭环的关键一步**:维护者实报「之前能看到雾、现在看不见了」, 据此定位并修掉一个**读数全对、画面全错**的静默失效 bug(下方第 0 项)—— 这一条**只有人看画面才能发现**,任何"读数断言"都会漏过它。 ### 性能读数(补充实测,口径已标注) 在**隐藏标签页**限制下(真实渲染循环被冻结,`postRender` 采不到自然帧), 改用 `__cockpitMap.forceRender()`(转调 `scene.render()`)逐帧计时,预热后各取 120 帧: | | 中位 | **P95** | 最小 | 最大 | | --- | --- | --- | --- | --- | | 开雾 | 2.2 ms | **3.6 ms** | 1.7 | 12.5 | | 关雾 | 1.8 ms | **3.6 ms** | 1.5 | 12.6 | | 差值 | +0.4 ms | **0 ms(0%)** | — | — | **P95 增幅 0%,在 15% 预算内。** 口径限制(如实标注,不含糊):这量的是 **`scene.render()` 的 CPU 侧调用耗时**,不是 GPU 时间,也不是严格意义上的"帧耗时"; 每帧强制渲染也与自然渲染循环的调度不同。它足以说明"这一层没有把帧预算吃掉", 但**不能**替代一次在可见窗口、固定视角下的自然帧采样。若日后要严格复现: 可见窗口里 `startFogPerfSample('with-fog')` → 停一会儿 → `stopFogPerfSample()`, 再用 `setFogUserDisabled(true)` 采一组基线对比。 ### 已完成项 1. **最终色相**:维护者确认真实画面通过(生效值仍为规格默认 `#bdcdd6`)。 2. **观感验收**:进入 → 锁定 → 旋转 → 浅俯角全过程,远景与影像边缘的遮挡效果维护者确认通过。 决策 07 记过的失败模式(「雾出现太晚、矩形边缘先暴露」)本次未复现。 3. **性能**:上表实测 P95 增幅 0%(口径限制见上)。 ### 改动 - **新增 `domain/fog.js`**:衰减曲线(端点精确)、门控、进出强度推进与落态、 **过期判定**、进入高度带、降级选择、**色相判据**、「就绪≠已遮住」的读数形状、性能预算判定。 纯函数、不 import cesium。 - **`services/cesiumEngine.js`**:新增 `createFogStage` / `applyFogUniforms` / `fogStageExecutable` / `hasFogStage` 与那段 `FOG_FRAGMENT_SHADER`。**全仓唯一一处 `postProcessStages.add`**(单一处理级)。 - **`services/mapService.js`**:雾的**策略与生命周期唯一所有者**(准备/启用/调强度/淡出/取消/禁用/销毁), 门控收口在 `setAreaState`(票 37 的区域状态一变,雾就跟着走);宿主读数 9 个 `data-*-fog-*`; DEV 把手 `getFogState` / `setFogTranslucentArtifacts` / `setFogUserDisabled` / `startFogPerfSample`。 - **`services/cockpitConfig.js`**:`map.fog` 字段(缺省=规格默认;脏值退回默认并记 issue)。 - **`CockpitShell.vue`**:顶栏雾读数 chip(支持/强度/降级/原因,标题写明"就绪≠已遮住")+ 降级提示色。 - **文档**:`docs/cockpit-deployment.md` 新增 **2.11 区内视距雾**;`config.example.json` 同步该字段。 ### 关键取舍 1. **曲线在 CPU 侧算,GLSL 只做等价实现**:端点语义是本票验收点,`fogAttenuation` 可脱浏览器断言。 GLSL 那份必须逐片元算,故保留;静态守卫禁止**第二份 JS 曲线**。 2. **`stage.ready` 不参与"能不能用"的判定**(详见下方第 2 个真 bug)。 3. **降级上限作用在"写入引擎的强度"上**,不改状态机 —— 恢复时不会因为中途被压低而错位。 4. **不加"等 N 秒判失败"的宽限期**:隐藏标签页是正常状态,据此判失败会把正常报成故障。 ### 证据 - 逻辑闸门 `verify/t38-fog.mjs` **通过**,覆盖:端点精确(0 处恰好 0、42 km 处恰好 1、**端点内侧** 41,999 m 也须收敛到 ~1,用单纯指数衰减作对照证明判别力)、门控、进出推进与落态、**过期取消**(连续进出只认最新目标)、 进入高度带只出强度标量、降级(**正常不预关闭**、默认不碰限位、可逆)、色相判据(基准色通过 / 发绿与深色不通过)、 「就绪≠已遮住」的结构性保证(读数里没有 `protected` 这类承诺)、性能预算判定、配置参数可调且脏值退回默认。 - **变异测试 11 处,全部被对应断言打红**(一次性脚本跑完即删):① 衰减不保端点;② 门控放宽; ③ 不换 token(过期保护失效);④ 管线视图预关闭;⑤ 色相"不发绿"判据失效;⑥ 谎称已遮缝; ⑦ 预算放宽到 50%;⑧ 引擎出现第二个后处理级;⑨ 宿主不写"未证明遮住边缘";⑩ 雾门控不跟区域状态;⑪ 外壳自己重写曲线。 另有 2 处针对真机 bug 的定向变异(待命报成失败 / `null` 被当成 0)也被抓住。 - **真机浏览器验证**(DSH 自带浏览器,真实 `yarn dev` @ `127.0.0.1:8000`,真实运行时配置): **shader 真的编译并跑通了** —— `staged: true`、`executable: true`、`ranOnce: true`、 `strength: 1`、`phase: locked`;参数确认为 18,000/42,000 m、`#bdcdd6`、fadeIn 900 / fadeOut 800 ms; 读数在连续采样中**稳定为 ready**(不在 ready/failed 之间跳);`coversEdges` 恒为 `false`。 - 全量回归:**24 个零依赖逻辑闸门全绿**(t05 守卫面纳入 `fog.js`,233→**236**)。 ### 真机验证抓到的四个真 bug(都已修 + 补了回归) 0. **(最隐蔽的一个,2026-09-28 由维护者实报"之前能看到雾、现在看不见了"揪出)动态 uniform 用常量传 → 静默永不生效。** 依据 Cesium 1.145 `Scene/PostProcessStage.js` 源码: - 第 460 行:每帧取值走 `stage._actualUniforms[name]`; - 第 499 行:`actualUniforms[name] = uniforms[name]`,在 `createUniformMap()` 里**只写一次**; - 第 479 行:`if (defined(stage._uniformMap)) return;` → map 一旦建立就**永久缓存**。 我原来把强度直接写进**原始对象**(`handle.uniforms.effectStrength = …`),它绕过了 Cesium 的 setter,于是**快照永远是初值 0** —— shader 里 `alpha = 0`,画面什么都没有。 而**所有读数都说"正常"**:`strength: 1`、`data-map-fog-strength="1"`、`stage.enabled=true`、 `ranOnce=true`。这就是这个 bug 的可怕之处:**内部状态全对,只有画面对不上**。 **实证**(同一页面、同一时刻,改回常量 vs 改回函数两种写法对照): | 写法 | 服务层读数 / dataset | Cesium 真正喂给 shader | 画面 | | --- | --- | --- | --- | | 常量(修复前) | `strength: 1`、`hostStrength="1"` | **`deliveredToShader: 0`**(`storedKind: "number"`) | 无雾 | | 函数(修复后) | `strength: 1` | `deliveredToShader: 1`、`matches: true`(`storedKind: "function"`) | 有雾 | 修复:uniform 一律用 `() => state.xxx` **函数形式**(每帧现取),动态值存在独立的 `state` 对象里 —— 这也正是可丢弃原型能跑通的原因(它用的就是函数形式)。 同时留了一个**可在运行期直接证伪**的诊断口 `getFogState().uniformDiagnostics` (读 `_uniformMap` 那条**每帧真正取值**的路径,而不是 `_uniforms` 原始对象 —— 读后者只会自证清白), 并加静态守卫:`uniforms` 每个动态字段必须是 `() => …` 形式(变异测试确认能抓住这个 bug)。 1. **`null` 被当成 0**:内置默认里 `fadeInMs/fadeOutMs` 是 `null`("本部署没配"), 而 `Number(null) === 0`、且 min 允许 0 → **静默变成"0 秒渐变"**(雾突然出现/突然消失)。 逻辑闸门当时只喂 `{}`(字段 `undefined`)所以漏掉。已修:`readNumber` 显式挡 `null`/`''`; 闸门补上 `null`/`''`/显式 `0` 三种形态。 2. **待命被报成失败**:`stage.ready` **只在处理级被 `enabled` 的那次 `update()` 里才置 true** (源码:构造时 `_ready=false`)。第一版拿它当"能不能用",于是**区外待命读数一直是 `failed`**; 更糟的是 `ready` 会**随渲染活动回落** —— 标签页一隐藏就再变 false,读数在「正常 ↔ 失败」之间闪。 已修:`failed` 只由**确凿证据**(创建抛错)判定;另外单独报 `ranOnce`(是否曾跑通过)供排查。 3. **属性名撞车**:chip 原本也用 `data-map-fog`,与地图宿主的运行期读数**同名** —— `querySelector('[data-map-fog]')` 会命中**宿主**(整页文本)而不是芯片,断言读到错误元素。 本仓在票 07(`data-map-blocked-backend` 同名两处)栽过一次。已改为 `data-fog-chip` / `data-fog-state`, 并加静态守卫禁止外壳再用 `data-map-fog*` 命名(守卫已用变异确认有牙)。 ### 顺带修掉的两处**并发会话**遗留(非本票改动,但挡住了本票的闸门) - `stores/eventStore.js`:JSDoc 缺了开头 `/**` 那一行,留下一个孤立 `*` → **整个模块语法错误**, t13/t14 直接加载失败。已补回注释开头(内容按上下文如实重建)。 - `stores/eventStore.js`:`useMapStore` **用了但没 import** → t14 运行期 `ReferenceError`。已补 import (`mapStore.js` 不 import 任何 store,无环;另三个 store 早已用同一模式)。 - `verify/t18-monitoring-panels.mjs`:两条断言**钉在会漂的写法上**—— A3 硬编码 `implementedPanelIds().length === 8`(票 19 接入后变 11), H9 精确匹配 `v-if="hasPanelView(panelId)"`(票 19 在同一行尾追加了合法路由条件)。 两处都改成**按不变式**断言(成员资格 / 前缀匹配),既不漏守也不误伤并行票。 ### 维护者真机复核后的两处报障(2026-09-29,已修) 维护者在真实画面走查后反馈两条,**同一个根因**: 1. **进入时最后一小段时间画面会变绿**; 2. **退出时雾气消散太慢,会出现整个地球变白一小段时间**。 **根因(算术可证)**:雾的不透明度按「相机到片元的世界射线距离」算,端点 18/42 km。 但相机在高空时,**画面上每一个片元**都远在 42 km 之外: | 相机高度 | 画面片元距离范围 | 中心片元不透明度 | 观感 | | --- | --- | --- | --- | | 9.8 km(区内) | 9.8–23 km | 0.000 | 正常 | | 20 km | 20–47 km | 0.060 | 中心开始解冻 | | 30 km | 30–71 km | 0.818 | 中心也发白 | | **≥42 km** | ≥42 km | **1.000** | **整屏全白** | 于是:**退出**时相机从 9.8 km 升向 20,000 km 全景,穿过 42 km 后整屏被涂成雾色(`#bdcdd6`)="整个地球变白"; **进入**时反过来,高空整屏白、降到 42 km 以下时画面**中心先解冻**、露出一团绿色植被影像 (实测该区域影像 G 通道比 R/B 高约 9.5)="最后一段突然变绿"。 **强度只随时间推进,从未随高度收口。** **修复**:在写入引擎的强度上再乘一层**相机高度系数**(`domain/fog.js` 的 `fogAltitudeFactor`): ≤20 km 为 1、≥42 km 为 0、中间平滑。两端取值有依据、不是拍脑袋 —— `altitudeZeroM` 取雾自己的全遮蔽端点 42 km(相机高于它时画面上**没有**任何片元还在保护带内, 雾已无保护意义、只会糊住地球);`altitudeFullM` 取 20 km(低于它恢复满强度, 而**归位视角约 9.8 km 远在下方 → 已验收的区内观感完全不受影响**,已用断言钉死)。 **真机验证(锁定态下扫描高度)**: | 相机高度 | 状态机强度 | **写入 GPU 的强度** | | --- | --- | --- | | 9.8 km(区内) | 1 | **1.00**(不受影响 ✓) | | 15 km | 1 | 1.00 | | 25 km | 1 | 0.87 | | 30 km | 1 | 0.57 | | **42 km** | 1 | **0**(不再全白 ✓) | | 20,000 km | 1 | **0**(不再全白 ✓) | **进入(下降)路径**同样验证:全景/60 km/42 km 时雾=0 且**影像不揭示**;30 km 时雾 0.57; 20 km 时雾到 1;全程状态机保持干净(不被污染,返回区内时时序仍正确)。 **一处重要连带修正**:交接闸门原先读的是**状态机强度**,那是错的 —— 相机在 60 km 高空(高度系数 0、画面毫无雾)时会被判成"保护成立"而揭示精细层、**当场露边**。 已改为读**有效强度**(`fogEffectiveStrength()`,含高度收口),并加静态守卫 + 变异测试钉死 (把闸门改回状态机强度会被打红)。 **为什么不能让状态机也收口**:状态机只管进出时序,把高度混进去会让"退出→再进入"的 时间轴错位;两层分开后,恢复时状态机仍是对的。 **新增守卫**:`t38-fog.mjs` 增补高度系数一节(端点、区内 9.95 km 必为 1、单调性、 写反退化恒 1、非法高度保守取 1)+ **服务层接线断言**(有效强度必须真的乘上系数、 闸门必须用有效强度、调用点数量受控)。**6 处定向变异全部打红**(含"完全不收口"与 "闸门用状态机强度"两个正是本次报障的形态)。全量回归 **27 个闸门全绿**。 **仍未解决**(如实记录):退出时**影像收起的时长仍是固定的 700 ms**,而镜头阻尼飞行 在 ~33 ms 内就到了 4253 km —— 两者不同步(决策 08 要求「镜头高度与雾同步」)。 本次的高度收口让**雾**不再变白,但**影像 alpha** 仍按固定时长慢慢退。 若维护者仍觉得退出时画面奇怪,下一步应把交接的渐隐也改成**按高度驱动**(而不是固定 ms)。 ### 维护者第二次现场复核(2026-09-29,两处新反馈,已修) 维护者复核第一版高度收口后,指出**修复本身引入了新问题**,并给出了准确的诊断: > 1. 进入时最后一小段时间画面会变绿是因为**雾散的太快了**,粗糙地球贴图出现了但是精细地形 > 还未出现,**雾的渐出与场景的渐入没有很好的衔接上**; > 2. 现在修改过后,**雾气产生和消散的速度又有一点太快了,几乎一瞬间就产生或消失了**, > 返回全球的手感体感效果还不错,但是**退出速度有一点太快了**。 #### 反馈 2「几乎一瞬间」——第一版的真实缺陷(我上一轮判断错了) 第一版把**瞬时**高度系数直接乘进强度。这里有个**量级问题**,我上一轮只算了"高度系数从 0 到 1 需要多少毫秒",没算**相机在雾带里待多久**: | 量 | 值 | | --- | --- | | 返回全球的 `range` 跨度 | 12,000 m → **20,000,000 m**(跨 **1.7e6 倍**) | | 雾带(`altitudeFullM`→`altitudeZeroM`) | 20 → 42 km,占上面跨度的 **0.0001%** | | 阻尼(τ=110 ms)下**穿越整条雾带**的耗时 | **0.15 ms** | 也就是说:**任何"瞬时高度驱动"都必然跳变**,与缓动、时长、阻尼都无关 —— 相机**开始移动的头一帧**就越过了整条带。所以"雾几乎一瞬间就产生或消失"。 **修法**:给高度系数加**时间限速**(`domain/fog.js` 的 `approachAltitudeFactor` + `FOG_DEFAULTS.altitudeSlewMs = 400`)。它把"看起来的变化速度"变成结构性保证: 系数每帧最多走 `dt / altitudeSlewMs`,所以**无论相机飞多快,画面上的雾都不可能比这更快地变**。 雾的浓淡由**时间**决定,高度只用来决定"这一刻该不该有雾"。 单帧 `dt` 另夹 120 ms(切标签页回来不许一步走完)。 #### 反馈 2「退出速度太快」& 第一版的「整球发白」 退出用的是**阻尼**逼近 `range = 20,000,000`。阻尼是 `1 − e^(−dt/τ)` 的指数逼近, **起步最快** —— 头一帧就已上万公里。所以退出"太快",而且那一刻雾还满着 ⇒ 整球发白。 **修法**:返回全球改走**按时长 + 慢起缓动**的飞行(当时取 `RETURN_FLIGHT_MS = 2000`、 `returnFlightProgress = smoothstep(t)⁷`)。指数 **7 是反解出来的,不是随手取的** —— 它由一条不变量决定: > **雾彻底淡完的那一刻,相机必须仍在 `altitudeZeroM`(42 km)以下。** > 相机一旦高过 42 km,画面上每个片元都超出全遮蔽端点;那一刻只要雾还没散完, > **整个地球就被涂成雾色** —— 这正是"整个地球变白"。 反解:雾淡完于 `handover.fadeMs + fog.fadeOutMs = 380 + 400 = 780 ms`, 此时飞行进度 `t = 780/2000 = 0.39`、`smoothstep(t) = 0.3507`; 允许的最大进度 `(42 km / sin55° − 12 km) / (20,000 km − 12 km) ≈ 1.97e-3`; 解 `0.3507^p ≤ 1.97e-3` 得 **p ≥ 6.8 → 取 7**。 实算:p=7 时雾淡完那一刻相机在 **18.0 km**(再过 101 ms 才穿过 42 km); 若按原先的 `smoothstep³`,那一刻已在 **1062 km** —— 照样整球发白。 > ⚠️ **上面这段推导只在"代码默认时长"下成立,已被后来的现场调定取代。** > 维护者 2026-09-30 在真机上把三个时长改成 `RETURN_FLIGHT_MS = 1000` / > `handover.fadeMs = 500` / `fog.fadeOutMs = 300`(并把缓动改成 `smoothstep^6.5` 混 0.1% 线性项), > **明确裁定以现场观感为准** —— 该组合下"雾先淡完"这条约束**被有意放宽** > (相机约 413 ms 越过 42 km,雾到 800 ms 才淡完,窗口 ≈387 ms,观感已接受)。 > 因此它**不再是硬闸门**:`t39-handover.mjs` 第 12 节改为"把窗口算出来并钉住已接受上限(400 ms)", > 超过就红、逼一次确认。详见票 39 的「维护者真机调定与收口」。 #### 反馈 1「变绿」——诊断比我上一轮更准确 维护者的判断是对的,我上一轮把"变绿"归因于"高度收口让雾跳变",那只是**表象**。 真正原因是**几何**:雾是**距离雾**、18 km 起步,而区内相机只有约 9.8 km 高 —— | 地面位置 | 视线距离 | 雾遮蔽率 | | --- | --- | --- | | **脚下(屏幕中心)** | **9.8 km** | **0.000** | | 水平 15 km | 17.9 km | 0.000 | | 水平 25 km | 26.9 km | 0.634 | | 水平 42.5 km | 43.6 km | 1.000 | **雾在结构上盖不住屏幕中心**。所以中心露出粗底图还是精细影像,**只看精细层 alpha**; 渐显期间中心是"粗糙 Natural Earth II(陆地偏绿)↔ 精细影像"的混合, **混合窗口越长,那段绿越明显** —— 维护者说的"雾的渐出与场景的渐入没有衔接上"正是它。 **修法**(维护者选定"缩短精细影像渐显时长"):`handover.fadeMs` 700 → **380 ms**, 让混合一闪而过。**没有**去调雾的强度 —— 因为按上表,调强度对屏幕中心根本无效。 #### 一并按维护者现场值调校 - 第一轮:`fog.fadeInMs` 900 → **800**、`fog.fadeOutMs` 800 → **400**(维护者原话"先尝试淡入 0.8,淡出 0.4")。 - 退出飞行时长做成可现场调:DEV 把手 `__cockpitMap.setReturnFlightMs(ms)`。 - **第二轮(2026-09-30,最终值,以维护者为准)**:`fadeInMs = 900`、`fadeOutMs = 300`、 `altitudeFullM = 14000`、`altitudeZeroM = 3000000`、`altitudeSlewMs = 600`。 代码默认值(`FOG_DEFAULTS`:800/400、20000/42000、400)**保持不变**, 供没有现场值的部署沿用;两者差异见部署手册 2.11。 #### 新增守卫与变异 - `t38-fog.mjs`:高度系数限速一节(单帧步长恰为 `dt/slew`、1 ms 内几乎不动、 走完全程 ≥ `altitudeSlewMs`、超大 dt 被夹 120 ms、`slew=0` 立即跟到、结果恒在 [0,1]、可配且脏值退回默认); 并把"限速必须真的挂在渲染帧上"钉死(**这一条是变异测试抓出来的**: 第一版只断言"函数内部调了限速函数",插一句提前 `return` 就能骗过它 —— 已补"渲染帧必须调用它"+"先推进再比较")。 - `t39-handover.mjs`:第 12 节跨模块"退出发白窗口"(见上方说明,已从硬断言改为钉住已接受上限)。 - `cockpitConfig.js`:**新增 `altitudeZeroM > altitudeFullM` 的配置守卫** —— 写反会让高度系数恒为 1(等于关掉这层保护),只报 issue 不替改值(两个数各自都合法)。 - **定向变异 10/10 全部打红**(不限速、步长放大 100 倍、去掉 dt 上限、退回阻尼、 不再慢起、时长缩到 300 ms、雾淡出改回 800 ms、影像收起改回 900 ms、 写入改回瞬时系数、限速不接渲染帧);改写后的第 12 节另跑 **6/6**。 #### 最终状态(2026-09-30) 维护者在真机上完成全部时长/阈值调试并裁定"目前的效果就行""以我为准"。 本票的**代码侧**(限速、慢起飞行、几何根因、配置守卫)已全部落地并有闸门/变异证据; **部署侧时长是维护者的现场取值**,不写进代码默认。 `node --check` 全通过、两个配置文件 JSON 合法、归一化 issues 为空、全量闸门 29 通过 (2 个红是并发会话的 `cockpitApi.js` / `deviceAssets.js`,与本票无关)。 ### 环境限制(如实记录) - 本会话浏览器标签页**始终 `document.hidden=true`**:rAF 被冻结(实测 1 秒 0 帧)。 因此**强度推进会停在中间值**(时间轴推进需一帧才追上)。 - 但**验证并未因此停摆**:已加 DEV 把手 `__cockpitMap.forceRender()`(转调 `scene.render()`), 可显式驱动一帧;配合 `immediate` 落位,就能在隐藏标签页里稳定复现"强度 1、stage 已启用"的状态, 并读到 `uniformDiagnostics` —— **根因就是这么定位并证伪/证实的**(见上方第 0 项对照表), 性能读数(见「性能读数」一节)也由它取到。 - 仍然受限的是:自然渲染循环下的帧采样(隐藏页 `postRender` 不触发,`count: 0`) 与观感目视 —— 两者分别由上面的替代口径与维护者本人覆盖。 ### 下一步 38 **已验收关闭**。**39(粗→精影像交接)解除阻塞**,它消费本票的雾能力读数: `getFogState()` 的 `executable`(是否可执行)/ `strength`(当前强度)/ `degrade`(降级原因), 以及 `params.startDistance/endDistance`(保护成立的距离带)。 **注意口径**:规格与决策 07 反复强调「就绪 ≠ 边缘已遮住」,且本票读数里 `coversEdges` **恒为 `false`** ——39 **不得**拿 `executable` 或 `strength` 当"可以揭示精细影像"的充分条件, 那是它自己要按画面风险判断的事(决策 08 的三条反馈:进出稍快、冷白灰不发绿、返回时高度与雾同步)。