fog.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. /**
  2. * 区内视距雾气(票 38):把 `spec-fog.md` 的 B 方案翻成可脱浏览器断言的纯函数。
  3. *
  4. * 纯函数、不 import cesium、不读 DOM —— 衰减端点、门控、进出强度推进、过期取消、降级选择
  5. * 都能直接断言(见 verify/t38-fog.mjs)。引擎侧(services/cesiumEngine.js)只负责
  6. * 「照着这份计划建一个 PostProcessStage 并把强度塞进 uniform」。
  7. *
  8. * 契约来源(`cockpit-primary-area-guardrails/spec-fog.md` + 决策票 07/08):
  9. * · **B 方案**:只处理有**有效几何深度**的地物;无有效深度的背景保留原色,不被整屏染色;
  10. * · 世界射线距离 **18,000 m 起雾 / 42,000 m 完全遮蔽**,中间平滑增浓,**两端语义精确**
  11. * (0 处全清、42 km 处全遮蔽 —— 「单纯指数衰减」在有限距离到不了 1,不算达标);
  12. * · 门控:`locked` 期间生效,纯全球/引导态关闭;进入途中的高度渐变**只作强度输入**,
  13. * 不作为「可揭示精细影像」的充分条件(那是票 39 的事);
  14. * · 生命周期:准备 → 启用 → 调强度 → 退出淡出(基准 0.8 s)→ **取消过期操作** → 禁用 → 销毁;
  15. * 复用处理级对象,离开地图时销毁;**连续进出只认最新目标**;
  16. * · 对外可观测:是否支持 / 是否可执行 / 当前强度 / 当前参数 / 失败降级原因;
  17. * **「就绪」不等于「矩形边缘已遮住」** —— 不提供未经验证的全局 `protected` 承诺;
  18. * · 降级:无深度支持或运行失败 → 整级禁用 + 如实提示,**业务可用优先**;
  19. * 管线视图出现透明深度错位时**允许减弱或暂关**并提示(正常情况不预关闭)。
  20. */
  21. /** 规格固化的默认参数(决策票 07 R4-3「按推荐来」=采用原型默认)。 */
  22. export const FOG_DEFAULTS = {
  23. /** 世界射线距离起雾端点(米)。 */
  24. startDistance: 18000,
  25. /** 完全遮蔽端点(米)。 */
  26. endDistance: 42000,
  27. /**
  28. * 基准雾色。`#bdcdd6` 是**早期原型已确认的起点,不是终值** ——
  29. * 决策 08 的实测反馈要求「冷白灰、不得发绿」,最终色相在真实画面里由维护者复核。
  30. */
  31. color: '#bdcdd6',
  32. /**
  33. * 退出淡出基准时长(毫秒)。决策 08 要求「进出都比原型稍快」。
  34. * 维护者 2026-09-29 现场调校:淡入 0.8 s、淡出 0.4 s(退出要更利落)。
  35. */
  36. fadeOutMs: 400,
  37. /** 进入渐显基准时长(毫秒)—— 同样可调,规格未指定精确秒数。 */
  38. fadeInMs: 800,
  39. /** 进入途中由相机高度驱动的早期渐显带(米):从 60 km 降到 20 km。 */
  40. enterAltitudeFrom: 80000,
  41. enterAltitudeTo: 20000,
  42. /**
  43. * **相机高度收口**(维护者 2026-09-29 实报两个缺陷后新增)。
  44. *
  45. * 为什么必须有它:雾的不透明度按「相机到片元的世界射线距离」算,18/42 km 两端。
  46. * 但相机在高空时,**画面上每一个片元**都远在 42 km 之外 ——
  47. * 于是整个地球被涂成雾色,出现两个可复现的缺陷:
  48. * · **退出**:相机从县城升向 20,000 km 全景,途中穿过 42 km ⇒ 整个地球变白一段时间;
  49. * · **进入**:高空整屏白,降到 42 km 以下时画面**中心先解冻**、露出一团绿色植被影像,
  50. * 观感就是「最后一段突然变绿」。
  51. * 两者同一个根因:**强度只随时间推进,没有随高度收口**。
  52. *
  53. * 取值的依据(不是拍脑袋):`altitudeZeroM` 取雾自己的全遮蔽端点 42 km ——
  54. * 相机一旦高于它,画面上**没有**任何片元还在 42 km 以内,雾已无保护意义、只会糊住地球;
  55. * `altitudeFullM` 取 20 km —— 低于它时雾恢复满强度,而**归位视角(约 9.8 km)远在下方**,
  56. * 因此已验收的区内观感**完全不受影响**。
  57. */
  58. altitudeFullM: 20000,
  59. altitudeZeroM: 42000,
  60. /**
  61. * 高度系数的**时间限速**(毫秒):系数从 1 走到 0(或反向)至少花这么久。
  62. *
  63. * ── 为什么必须有它(维护者 2026-09-29 第二次现场反馈)──
  64. * 第一版直接乘**瞬时**高度系数,于是「雾几乎一瞬间就产生或消失」。原因是量级差:
  65. * 返回全球时 `range` 从 12 km 变到 **20,000 km**(跨 1.7e6 倍),而雾的高度带只有
  66. * 20→42 km —— 相机**开始移动的头一帧**就越过了整条带(实算穿越耗时 ≈ 0.15 ms)。
  67. * 也就是说:**任何"瞬时高度驱动"都必然跳变**,与缓动、时长、阻尼都无关。
  68. * 雾的浓淡只能由**时间**决定,高度只用来决定"这一刻该不该有雾"。
  69. *
  70. * 限速把这件事变成结构性保证:系数每次最多走 `dt / altitudeSlewMs`,
  71. * 所以无论相机多快,画面上的雾都不可能比这个时长更快地变化。
  72. * 取 400 ms = 淡出时长,保证它与淡出**同量级**(不会比配置的淡出更快,也不会拖慢它)。
  73. */
  74. altitudeSlewMs: 400,
  75. }
  76. /**
  77. * 雾的对外状态(写进宿主 `data-map-fog`)。
  78. * `ready` = 处理级已可执行(**不等于**已遮住边缘);`unsupported` / `failed` = 整级不可用。
  79. */
  80. export const FOG_STATE = {
  81. READY: 'ready',
  82. DISABLED: 'disabled',
  83. UNSUPPORTED: 'unsupported',
  84. FAILED: 'failed',
  85. }
  86. /** 进出阶段(内部状态机;`locked` / `global` 与区域状态同名,便于对照)。 */
  87. export const FOG_PHASE = {
  88. GLOBAL: 'global',
  89. ENTERING: 'entering',
  90. LOCKED: 'locked',
  91. EXITING: 'exiting',
  92. }
  93. /** 降级档:正常 / 减弱(管线透明错位时可读性优先)/ 关闭。 */
  94. export const FOG_DEGRADE = {
  95. NONE: 'none',
  96. REDUCED: 'reduced',
  97. OFF: 'off',
  98. }
  99. /** 管线视图降级时的强度上限(「允许减弱」的可微调数值)。 */
  100. export const PIPELINE_REDUCED_STRENGTH = 0.35
  101. /** 性能预算:同条件下开启雾的 P95 帧耗时增幅上限。 */
  102. export const FOG_PERF_BUDGET = 0.15
  103. const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/
  104. /**
  105. * 读一个数值参数:**没给**(`undefined` / `null` / `''`)→ 用默认;给了但不可用 → 也用默认。
  106. * (导出:sceneGrade 等需要同一"脏值退默认"口径的 domain 模块复用这一份,不再各抄一份。)
  107. *
  108. * 为什么要显式挡 `null` 与 `''`:内置默认里这两个状态就是 `null`/`''`("本部署没配"),
  109. * 而 `Number(null) === 0`、`Number('') === 0`。当 `min` 允许 0 时(淡入淡出时长正是这种情况),
  110. * `null` 会**静默变成 0 秒** —— 表现是"雾突然出现/突然消失",而配置里什么都没写。
  111. * 这个 bug 是本票真机验证时抓到的(`fadeInMs/fadeOutMs` 全成了 0),
  112. * 逻辑闸门当时只喂了 `{}`(字段是 `undefined`)所以没暴露 —— 现在两边都补上。
  113. */
  114. export function readNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) {
  115. if (value === undefined || value === null || value === '') return fallback
  116. const num = Number(value)
  117. if (!Number.isFinite(num)) return fallback
  118. if (num < min || num > max) return fallback
  119. return num
  120. }
  121. /** 解析 `#rrggbb` → `{r,g,b}`(0..1);不合法返回 null。 */
  122. export function parseHexColor(value) {
  123. const match = HEX_PATTERN.exec(String(value || '').trim())
  124. if (!match) return null
  125. const int = parseInt(match[1], 16)
  126. return {
  127. r: ((int >> 16) & 0xff) / 255,
  128. g: ((int >> 8) & 0xff) / 255,
  129. b: (int & 0xff) / 255,
  130. }
  131. }
  132. /**
  133. * 色相判据:**冷白灰、不发绿**(决策 08 的实测反馈「正式色值须白灰偏冷、不得发绿」)。
  134. *
  135. * 客观化三件事,避免"看着像"这种没法断言的说法:
  136. * 1. 明度足够高(是"白灰"而不是深色);
  137. * 2. 饱和度足够低(是"灰"而不是彩色);
  138. * 3. **绿色通道不得显著高于红/蓝** —— 这正是"发绿"的可测含义。
  139. *
  140. * 饱和度用 **HSV 口径 `(max-min)/max`**,不用 HSL:浅色的 HSL 饱和度天然偏高,
  141. * 会把已认可的基准色误判成"彩色"(实测 `#bdcdd6` 的 HSL 饱和度 0.234、
  142. * 而同属冷白灰的 `#c8d8e8` 高达 0.410;两者 HSV 饱和度只有 0.117 / 0.138,
  143. * 而明显发绿的 `#a8d8b0` 是 0.222、饱和绿 `#35e39a` 是 0.767)。HSV 才是浅色灰度的好判据。
  144. *
  145. * 返回 `{ok, reasons[]}`,失败原因逐条列出,界面与票面都能引用同一份判定。
  146. */
  147. export function fogColorVerdict(color, { minLightness = 0.6, maxSaturation = 0.2, maxGreenBias = 0.02 } = {}) {
  148. const rgb = parseHexColor(color)
  149. if (!rgb) return { ok: false, reasons: ['色值不是 #rrggbb'], rgb: null }
  150. const max = Math.max(rgb.r, rgb.g, rgb.b)
  151. const min = Math.min(rgb.r, rgb.g, rgb.b)
  152. const lightness = (max + min) / 2
  153. const saturation = max === 0 ? 0 : (max - min) / max
  154. // 绿色偏差:绿通道比红蓝的较大者高出多少("发绿"就是它为正且明显)。
  155. const greenBias = rgb.g - Math.max(rgb.r, rgb.b)
  156. const reasons = []
  157. if (lightness < minLightness) reasons.push(`明度 ${lightness.toFixed(3)} 偏低(应是白灰)`)
  158. if (saturation > maxSaturation) reasons.push(`饱和度 ${saturation.toFixed(3)} 偏高(应是灰)`)
  159. if (greenBias > maxGreenBias) reasons.push(`绿色通道偏高 ${greenBias.toFixed(3)}(不得发绿)`)
  160. return { ok: reasons.length === 0, reasons, rgb, lightness, saturation, greenBias }
  161. }
  162. /**
  163. * 雾参数计划:配置优先、缺项用规格默认;**脏值一律退回默认**(与 domain/viewModes.js 同立场)。
  164. * @param {object} mapConfig 归一化后的 `config.map`
  165. */
  166. export function planFog(mapConfig = {}) {
  167. const fog = (mapConfig && mapConfig.fog) || {}
  168. const startDistance = readNumber(fog.startDistance, FOG_DEFAULTS.startDistance, { min: 1 })
  169. // 结束端点必须**严格大于**起点:否则 smoothstep 退化,两端语义就没法同时成立。
  170. const rawEnd = readNumber(fog.endDistance, FOG_DEFAULTS.endDistance, { min: 1 })
  171. const endDistance = rawEnd > startDistance ? rawEnd : Math.max(startDistance + 1, FOG_DEFAULTS.endDistance)
  172. const color = typeof fog.color === 'string' && parseHexColor(fog.color) ? fog.color.trim() : FOG_DEFAULTS.color
  173. return {
  174. startDistance,
  175. endDistance,
  176. color,
  177. fadeOutMs: readNumber(fog.fadeOutMs, FOG_DEFAULTS.fadeOutMs, { min: 0, max: 10000 }),
  178. fadeInMs: readNumber(fog.fadeInMs, FOG_DEFAULTS.fadeInMs, { min: 0, max: 10000 }),
  179. enterAltitudeFrom: readNumber(fog.enterAltitudeFrom, FOG_DEFAULTS.enterAltitudeFrom, { min: 1 }),
  180. enterAltitudeTo: readNumber(fog.enterAltitudeTo, FOG_DEFAULTS.enterAltitudeTo, { min: 0 }),
  181. /** 相机高度收口的两端(米);见 `fogAltitudeFactor` 与 `FOG_DEFAULTS` 的说明。 */
  182. altitudeFullM: readNumber(fog.altitudeFullM, FOG_DEFAULTS.altitudeFullM, { min: 0 }),
  183. altitudeZeroM: readNumber(fog.altitudeZeroM, FOG_DEFAULTS.altitudeZeroM, { min: 1 }),
  184. /** 高度系数的限速时长(毫秒);见 `approachAltitudeFactor`。 */
  185. altitudeSlewMs: readNumber(fog.altitudeSlewMs, FOG_DEFAULTS.altitudeSlewMs, { min: 0, max: 10000 }),
  186. /** 配置里显式关掉雾(部署不需要这层效果时的开关)。 */
  187. enabled: fog.enabled !== false,
  188. }
  189. }
  190. /**
  191. * **相机高度收口**:把雾强度按相机高度打折,`[0, 1]`。
  192. *
  193. * 存在的理由见 `FOG_DEFAULTS.altitudeFullM` 的注释:不加这一层,
  194. * 相机在高空时整屏每个片元都超出全遮蔽端点,整个地球被涂成雾色
  195. * —— 退出时「地球变白」、进入时「最后一段突然变绿」都源于此。
  196. *
  197. * 它**只压缩强度**,不改雾色、距离端点或交接时序;区内稳态(约 9.8 km)系数恰为 1。
  198. * `altitudeFullM >= altitudeZeroM`(配置写反)时退化为"恒 1"(回到旧行为),
  199. * 不静默把雾关掉 —— 那种失败模式更难查。
  200. *
  201. * @param {number} altitudeM 相机椭球高度(米)
  202. * @returns {number} 0..1
  203. */
  204. export function fogAltitudeFactor(altitudeM, params = FOG_DEFAULTS) {
  205. const full = Number(params.altitudeFullM)
  206. const zero = Number(params.altitudeZeroM)
  207. if (!Number.isFinite(altitudeM) || !Number.isFinite(full) || !Number.isFinite(zero) || zero <= full) return 1
  208. if (altitudeM <= full) return 1
  209. if (altitudeM >= zero) return 0
  210. const t = (zero - altitudeM) / (zero - full)
  211. return t * t * (3 - 2 * t)
  212. }
  213. /** 单帧 dt 上限(毫秒):切标签页回来时别让系数一次走完(与 `camera.js` 同口径)。 */
  214. export const FOG_MAX_FRAME_MS = 120
  215. /**
  216. * 高度系数的时间限速推进(纯函数):把瞬时目标收敛成"看得见"的变化。
  217. *
  218. * 存在的理由见 `FOG_DEFAULTS.altitudeSlewMs`:瞬时高度系数在返回全球时会跳变
  219. * (range 跨 1.7e6 倍,雾带 20–42 km 只占其中 0.0001%,头一帧就越过),
  220. * 于是雾"几乎一瞬间消失"。这里把系数**限速**,使它至少花 `altitudeSlewMs` 走完全程。
  221. *
  222. * 只做速率限制,不做缓动:缓动会让"该有雾时雾还不够"的时间变长,
  223. * 而这里的诉求恰恰是"别比配置时长更快"。两端仍然精确落在 0 / 1。
  224. *
  225. * @param {number} shown 上一帧的系数(0..1)
  226. * @param {number} target 这一帧的瞬时目标(`fogAltitudeFactor` 的结果)
  227. * @param {number} dtMs 距上一帧的真实毫秒
  228. * @returns {number} 限速后的系数(0..1)
  229. */
  230. export function approachAltitudeFactor(shown, target, dtMs, params = FOG_DEFAULTS) {
  231. const from = Number.isFinite(shown) ? Math.min(1, Math.max(0, shown)) : 0
  232. const to = Number.isFinite(target) ? Math.min(1, Math.max(0, target)) : 1
  233. const slew = Number(params.altitudeSlewMs)
  234. // 没配 / 配成 0:不限速(立刻跟到目标)—— 与其它参数的"显式 0 是合法意图"一致。
  235. if (!Number.isFinite(slew) || slew <= 0) return to
  236. const dt = Math.min(FOG_MAX_FRAME_MS, Math.max(0, Number.isFinite(dtMs) ? dtMs : 0))
  237. const maxStep = dt / slew
  238. const delta = to - from
  239. if (Math.abs(delta) <= maxStep) return to
  240. return from + Math.sign(delta) * maxStep
  241. }
  242. /**
  243. * 世界射线距离 → 雾不透明度 `[0, 1]`。**两端语义精确**:
  244. * · `distance <= start` → **恰好 0**(全清);
  245. * · `distance >= end` → **恰好 1**(全遮蔽);
  246. * · 中间用 smoothstep 再套一条保端点的光学厚度曲线,曲线单调、无明显"雾墙"。
  247. *
  248. * 为什么不直接用 `1 - exp(-k·t)`:它在有限距离**永远到不了 1**,
  249. * 于是「42 km 完全遮蔽」这条端点语义不成立 —— 规格明确指出这种写法不算达标。
  250. * 这里用 `(1 - exp(-3t)) / (1 - exp(-3))`:形状与光学厚度类似,但**端点被精确归一化到 1**。
  251. *
  252. * @param {number} distanceM 相机到片元的世界射线距离(米)
  253. * @param {{startDistance: number, endDistance: number}} [params]
  254. * @returns {number} 0..1
  255. */
  256. export function fogAttenuation(distanceM, params = FOG_DEFAULTS) {
  257. const start = Number(params.startDistance)
  258. const end = Number(params.endDistance)
  259. if (!Number.isFinite(distanceM) || !Number.isFinite(start) || !Number.isFinite(end)) return 0
  260. if (end <= start) return distanceM >= end ? 1 : 0
  261. if (distanceM <= start) return 0
  262. if (distanceM >= end) return 1
  263. // smoothstep:中点附近增浓最快,两端平缓(避免出现一条生硬的"雾墙")。
  264. const t = (distanceM - start) / (end - start)
  265. const smooth = t * t * (3 - 2 * t)
  266. // 保端点的光学厚度形状:t=0 → 0,t=1 → 1。
  267. return (1 - Math.exp(-3 * smooth)) / (1 - Math.exp(-3))
  268. }
  269. /**
  270. * 门控:雾**只在主场景区内**生效。
  271. *
  272. * 「纯全球/引导态关闭」是规格原文;`areaState` 由票 37 提供(与 `range_max` 同一份输入契约)。
  273. * 主场景区不可用(未配置 / 非法)时同样关闭 —— 沿票 36 的关闭路径。
  274. *
  275. * @param {string} areaState `locked` | `global`
  276. * @param {object} plan 主场景区计划(`mapService.getPrimaryAreaPlan()`)
  277. */
  278. export function shouldFogBeActive(areaState, plan) {
  279. if (!plan || !plan.enabled) return false
  280. return areaState === 'locked'
  281. }
  282. /**
  283. * 进入途中的早期强度输入:相机高度从 `enterAltitudeFrom` 降到 `enterAltitudeTo` 时,
  284. * 强度从 0 平滑升到 1。
  285. *
  286. * **它只是"强度输入",不是"可以揭示精细影像"的充分条件**(决策 07 第 4 条:
  287. * 原型的 60/20 km 高度带已被人工演示判为遮挡过晚)。票 39 不得拿本函数的结果当遮缝承诺。
  288. *
  289. * @param {number} altitudeM 相机椭球高度(米)
  290. */
  291. export function enterAltitudeStrength(altitudeM, params = FOG_DEFAULTS) {
  292. const from = Number(params.enterAltitudeFrom)
  293. const to = Number(params.enterAltitudeTo)
  294. if (!Number.isFinite(altitudeM) || !Number.isFinite(from) || !Number.isFinite(to) || from <= to) return 0
  295. if (altitudeM >= from) return 0
  296. if (altitudeM <= to) return 1
  297. const t = (from - altitudeM) / (from - to)
  298. return t * t * (3 - 2 * t)
  299. }
  300. /**
  301. * 进出强度推进(纯函数):给定当前阶段与已过去的时间,算这一帧的强度。
  302. *
  303. * `entering`:从 `from` 线性推进到 1(缓动);
  304. * `exiting`:从 `from` 淡到 0(基准 0.8 s,决策 08 要求"比原型稍快",故时长可配置);
  305. * `locked`:恒 1;`global`:恒 0。
  306. *
  307. * @returns {{strength: number, done: boolean}}
  308. */
  309. export function advanceFogStrength(phase, elapsedMs, from, params = FOG_DEFAULTS) {
  310. const base = Number.isFinite(from) ? Math.min(1, Math.max(0, from)) : 0
  311. if (phase === FOG_PHASE.LOCKED) return { strength: 1, done: true }
  312. if (phase === FOG_PHASE.GLOBAL) return { strength: 0, done: true }
  313. if (phase === FOG_PHASE.ENTERING) {
  314. const duration = Number(params.fadeInMs)
  315. if (!(duration > 0)) return { strength: 1, done: true }
  316. const t = Math.min(1, Math.max(0, elapsedMs / duration))
  317. const eased = t * t * (3 - 2 * t)
  318. return { strength: base + (1 - base) * eased, done: t >= 1 }
  319. }
  320. if (phase === FOG_PHASE.EXITING) {
  321. const duration = Number(params.fadeOutMs)
  322. if (!(duration > 0)) return { strength: 0, done: true }
  323. const t = Math.min(1, Math.max(0, elapsedMs / duration))
  324. const eased = t * t * (3 - 2 * t)
  325. return { strength: base * (1 - eased), done: t >= 1 }
  326. }
  327. return { strength: 0, done: true }
  328. }
  329. /**
  330. * 生命周期状态机(**只有它决定阶段**,宿主与组件都不许自己算)。
  331. *
  332. * 关键性质:**连续进出只认最新目标**。每次开始一次进出都递增 `token`;
  333. * 上一轮排定的淡出/渐显回调带着旧 token 到达时被判过期、**不得**改动当前状态
  334. * —— 这正是「过期淡出回调不得关掉新的进入」的落点(决策 07 第 2 条)。
  335. */
  336. export function createFogLifecycle() {
  337. return { phase: FOG_PHASE.GLOBAL, strength: 0, token: 0, since: 0, from: 0 }
  338. }
  339. /** 开始一次进出:`enter` → entering,`exit` → exiting。返回新状态(含新 token)。 */
  340. export function beginFogTransition(state, action, now, params = FOG_DEFAULTS) {
  341. const current = state || createFogLifecycle()
  342. const from = Number.isFinite(current.strength) ? current.strength : 0
  343. if (action === 'enter') {
  344. // 已经在锁定态就不必重新渐显(幂等:重复"进入"不该把雾推倒重来)。
  345. if (current.phase === FOG_PHASE.LOCKED) return { ...current }
  346. return { phase: FOG_PHASE.ENTERING, strength: from, token: current.token + 1, since: now, from }
  347. }
  348. if (action === 'exit') {
  349. if (current.phase === FOG_PHASE.GLOBAL) return { ...current }
  350. return { phase: FOG_PHASE.EXITING, strength: from, token: current.token + 1, since: now, from }
  351. }
  352. return { ...current }
  353. }
  354. /** 推进一帧:按阶段与已过时间更新强度,到端点即落定。 */
  355. export function stepFogLifecycle(state, now, params = FOG_DEFAULTS) {
  356. const current = state || createFogLifecycle()
  357. if (current.phase === FOG_PHASE.GLOBAL) return { ...current, strength: 0 }
  358. if (current.phase === FOG_PHASE.LOCKED) return { ...current, strength: 1 }
  359. const elapsed = Math.max(0, now - current.since)
  360. const { strength, done } = advanceFogStrength(current.phase, elapsed, current.from, params)
  361. if (!done) return { ...current, strength }
  362. return current.phase === FOG_PHASE.ENTERING
  363. ? { ...current, phase: FOG_PHASE.LOCKED, strength: 1 }
  364. : { ...current, phase: FOG_PHASE.GLOBAL, strength: 0 }
  365. }
  366. /**
  367. * 过期判定:**带旧 token 的回调不得改动状态**。
  368. * @returns {boolean} true = 这次回调已过期,调用方必须丢弃
  369. */
  370. export function isStaleFogToken(state, token) {
  371. const current = state || createFogLifecycle()
  372. return token !== current.token
  373. }
  374. /** 当前是否应当渲染雾(可执行 + 有强度 + 阶段允许)。 */
  375. export function shouldRenderFog(state, { supported, phase } = {}) {
  376. const current = state || createFogLifecycle()
  377. const target = phase || current.phase
  378. if (supported === false) return false
  379. if (target === FOG_PHASE.GLOBAL) return false
  380. return current.strength > 0
  381. }
  382. /**
  383. * 降级选择(决策 07 第 6 条 / spec-fog 的「管线可读性优先」)。
  384. *
  385. * **正常情况不预先关闭**:只有真的观察到管线视图下透明对象被错误雾化时,才减弱;
  386. * 用户显式选择"暂关"时才关闭。降级**不动相机限位**(那是票 37 的事)。
  387. *
  388. * @param {object} options
  389. * @param {string} options.viewMode `ground` | `pipeline`
  390. * @param {boolean} options.translucentArtifacts 是否观察到透明深度错位(由人工/走查置位)
  391. * @param {boolean} options.userDisabled 用户是否显式要求暂关
  392. * @param {boolean} options.supported 深度能力是否可用
  393. */
  394. export function planFogDegrade({ viewMode, translucentArtifacts = false, userDisabled = false, supported = true } = {}) {
  395. if (supported === false) {
  396. return { level: FOG_DEGRADE.OFF, strengthCap: 0, reason: '当前环境不支持深度纹理,自建雾整级禁用' }
  397. }
  398. if (userDisabled) {
  399. return { level: FOG_DEGRADE.OFF, strengthCap: 0, reason: '已按用户要求暂关自建雾(相机限位不受影响)' }
  400. }
  401. if (translucentArtifacts) {
  402. return {
  403. level: FOG_DEGRADE.REDUCED,
  404. strengthCap: PIPELINE_REDUCED_STRENGTH,
  405. reason: '管线视图透明对象被错误雾化 · 已减弱雾效(可读性优先)',
  406. }
  407. }
  408. return { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
  409. }
  410. /**
  411. * 对外可观测读数(写进宿主 dataset 与浮标;**「就绪」不等于「已遮住边缘」**)。
  412. *
  413. * 刻意**不提供** `protected: true` 这类全局承诺:规格明确说
  414. * 「ready 只能说明处理级已可执行,不证明当前强度、视角与片元距离已经遮住边缘」。
  415. */
  416. export function fogReadout({ state, degrade, params, supported, executable, reason = '' } = {}) {
  417. const current = state || createFogLifecycle()
  418. const plan = params || FOG_DEFAULTS
  419. const degradePlan = degrade || { level: FOG_DEGRADE.NONE, reason: '' }
  420. const fogState = supported === false ? FOG_STATE.UNSUPPORTED : executable === false ? FOG_STATE.FAILED : FOG_STATE.READY
  421. return {
  422. state: fogState,
  423. supported: supported !== false,
  424. executable: executable !== false,
  425. active: current.phase !== FOG_PHASE.GLOBAL && current.strength > 0,
  426. phase: current.phase,
  427. strength: Number(current.strength.toFixed(4)),
  428. degrade: degradePlan.level,
  429. startDistance: plan.startDistance,
  430. endDistance: plan.endDistance,
  431. color: plan.color,
  432. reason: reason || degradePlan.reason || '',
  433. /** 显式声明:这只是"处理级可执行",不是"矩形边缘已被遮住"。 */
  434. coversEdges: false,
  435. }
  436. }
  437. /**
  438. * 性能预算判定:同机 / 同分辨率 / 同视角 / 相同加载条件下,
  439. * 开启雾的 P95 帧耗时增幅 ≤15%。
  440. * @param {number} p95WithFog 开启雾的 P95 帧耗时(毫秒)
  441. * @param {number} p95Baseline 关闭雾的 P95 帧耗时(毫秒)
  442. */
  443. export function fogPerformanceVerdict(p95WithFog, p95Baseline, budget = FOG_PERF_BUDGET) {
  444. if (!(p95Baseline > 0) || !Number.isFinite(p95WithFog)) {
  445. return { ok: false, ratio: null, reason: '基线无效或未采样(没有基线就无法判定达标)' }
  446. }
  447. const ratio = p95WithFog / p95Baseline - 1
  448. return {
  449. ok: ratio <= budget,
  450. ratio,
  451. reason: ratio <= budget ? '' : `P95 帧耗时增幅 ${(ratio * 100).toFixed(1)}% 超出 ${(budget * 100).toFixed(0)}% 预算`,
  452. }
  453. }