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。PostProcessStage 深度后处理雾;不引入第二渲染器(ADR 0004 / 0007;决策 06 已裁定废止「深度喂 Three」备选)。#bdcdd6,中间平滑增浓;两端语义精确(0 处全清、42 km 处全遮蔽,单纯指数衰减不算达标)。#bdcdd6 是起点不是终值)。维护者 2026-09-28 在真实画面确认通过(生效值仍为规格默认 #bdcdd6);另有可断言的色相判据兜底(见下)。areaState='locked' 期间生效;纯全球/引导态关闭。进入途中的早期高度渐变只作强度输入,不作为「可揭示精细影像」的充分条件(原型的 60/20 km 高度带已被人工演示判为遮挡过晚)。已完成并验收(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) 采一组基线对比。
#bdcdd6)。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 同步该字段。fogAttenuation 可脱浏览器断言。
GLSL 那份必须逐片元算,故保留;静态守卫禁止第二份 JS 曲线。stage.ready 不参与"能不能用"的判定(详见下方第 2 个真 bug)。verify/t38-fog.mjs 通过,覆盖:端点精确(0 处恰好 0、42 km 处恰好 1、端点内侧 41,999 m
也须收敛到 ~1,用单纯指数衰减作对照证明判别力)、门控、进出推进与落态、过期取消(连续进出只认最新目标)、
进入高度带只出强度标量、降级(正常不预关闭、默认不碰限位、可逆)、色相判据(基准色通过 / 发绿与深色不通过)、
「就绪≠已遮住」的结构性保证(读数里没有 protected 这类承诺)、性能预算判定、配置参数可调且脏值退回默认。null 被当成 0)也被抓住。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。fog.js,233→236)。Scene/PostProcessStage.js 源码:
stage._actualUniforms[name];actualUniforms[name] = uniforms[name],在 createUniformMap() 里只写一次;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)。
null 被当成 0:内置默认里 fadeInMs/fadeOutMs 是 null("本部署没配"),
而 Number(null) === 0、且 min 允许 0 → 静默变成"0 秒渐变"(雾突然出现/突然消失)。
逻辑闸门当时只喂 {}(字段 undefined)所以漏掉。已修:readNumber 显式挡 null/'';
闸门补上 null/''/显式 0 三种形态。stage.ready 只在处理级被 enabled 的那次 update() 里才置 true
(源码:构造时 _ready=false)。第一版拿它当"能不能用",于是区外待命读数一直是 failed;
更糟的是 ready 会随渲染活动回落 —— 标签页一隐藏就再变 false,读数在「正常 ↔ 失败」之间闪。
已修:failed 只由确凿证据(创建抛错)判定;另外单独报 ranOnce(是否曾跑通过)供排查。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 在同一行尾追加了合法路由条件)。
两处都改成按不变式断言(成员资格 / 前缀匹配),既不漏守也不误伤并行票。维护者在真实画面走查后反馈两条,同一个根因:
根因(算术可证):雾的不透明度按「相机到片元的世界射线距离」算,端点 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)。
维护者复核第一版高度收口后,指出修复本身引入了新问题,并给出了准确的诊断:
- 进入时最后一小段时间画面会变绿是因为雾散的太快了,粗糙地球贴图出现了但是精细地形 还未出现,雾的渐出与场景的渐入没有很好的衔接上;
- 现在修改过后,雾气产生和消散的速度又有一点太快了,几乎一瞬间就产生或消失了, 返回全球的手感体感效果还不错,但是退出速度有一点太快了。
第一版把瞬时高度系数直接乘进强度。这里有个量级问题,我上一轮只算了"高度系数从 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(切标签页回来不许一步走完)。
退出用的是阻尼逼近 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 的「维护者真机调定与收口」。
维护者的判断是对的,我上一轮把"变绿"归因于"高度收口让雾跳变",那只是表象。 真正原因是几何:雾是距离雾、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")。__cockpitMap.setReturnFlightMs(ms)。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 不替改值(两个数各自都合法)。维护者在真机上完成全部时长/阈值调试并裁定"目前的效果就行""以我为准"。
本票的代码侧(限速、慢起飞行、几何根因、配置守卫)已全部落地并有闸门/变异证据;
部署侧时长是维护者的现场取值,不写进代码默认。
node --check 全通过、两个配置文件 JSON 合法、归一化 issues 为空、全量闸门 29 通过
(2 个红是并发会话的 cockpitApi.js / deviceAssets.js,与本票无关)。
document.hidden=true:rAF 被冻结(实测 1 秒 0 帧)。
因此强度推进会停在中间值(时间轴推进需一帧才追上)。__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 的三条反馈:进出稍快、冷白灰不发绿、返回时高度与雾同步)。