| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491 |
- /**
- * 区内视距雾气(票 38):把 `spec-fog.md` 的 B 方案翻成可脱浏览器断言的纯函数。
- *
- * 纯函数、不 import cesium、不读 DOM —— 衰减端点、门控、进出强度推进、过期取消、降级选择
- * 都能直接断言(见 verify/t38-fog.mjs)。引擎侧(services/cesiumEngine.js)只负责
- * 「照着这份计划建一个 PostProcessStage 并把强度塞进 uniform」。
- *
- * 契约来源(`cockpit-primary-area-guardrails/spec-fog.md` + 决策票 07/08):
- * · **B 方案**:只处理有**有效几何深度**的地物;无有效深度的背景保留原色,不被整屏染色;
- * · 世界射线距离 **18,000 m 起雾 / 42,000 m 完全遮蔽**,中间平滑增浓,**两端语义精确**
- * (0 处全清、42 km 处全遮蔽 —— 「单纯指数衰减」在有限距离到不了 1,不算达标);
- * · 门控:`locked` 期间生效,纯全球/引导态关闭;进入途中的高度渐变**只作强度输入**,
- * 不作为「可揭示精细影像」的充分条件(那是票 39 的事);
- * · 生命周期:准备 → 启用 → 调强度 → 退出淡出(基准 0.8 s)→ **取消过期操作** → 禁用 → 销毁;
- * 复用处理级对象,离开地图时销毁;**连续进出只认最新目标**;
- * · 对外可观测:是否支持 / 是否可执行 / 当前强度 / 当前参数 / 失败降级原因;
- * **「就绪」不等于「矩形边缘已遮住」** —— 不提供未经验证的全局 `protected` 承诺;
- * · 降级:无深度支持或运行失败 → 整级禁用 + 如实提示,**业务可用优先**;
- * 管线视图出现透明深度错位时**允许减弱或暂关**并提示(正常情况不预关闭)。
- */
- /** 规格固化的默认参数(决策票 07 R4-3「按推荐来」=采用原型默认)。 */
- export const FOG_DEFAULTS = {
- /** 世界射线距离起雾端点(米)。 */
- startDistance: 18000,
- /** 完全遮蔽端点(米)。 */
- endDistance: 42000,
- /**
- * 基准雾色。`#bdcdd6` 是**早期原型已确认的起点,不是终值** ——
- * 决策 08 的实测反馈要求「冷白灰、不得发绿」,最终色相在真实画面里由维护者复核。
- */
- color: '#bdcdd6',
- /**
- * 退出淡出基准时长(毫秒)。决策 08 要求「进出都比原型稍快」。
- * 维护者 2026-09-29 现场调校:淡入 0.8 s、淡出 0.4 s(退出要更利落)。
- */
- fadeOutMs: 400,
- /** 进入渐显基准时长(毫秒)—— 同样可调,规格未指定精确秒数。 */
- fadeInMs: 800,
- /** 进入途中由相机高度驱动的早期渐显带(米):从 60 km 降到 20 km。 */
- enterAltitudeFrom: 80000,
- enterAltitudeTo: 20000,
- /**
- * **相机高度收口**(维护者 2026-09-29 实报两个缺陷后新增)。
- *
- * 为什么必须有它:雾的不透明度按「相机到片元的世界射线距离」算,18/42 km 两端。
- * 但相机在高空时,**画面上每一个片元**都远在 42 km 之外 ——
- * 于是整个地球被涂成雾色,出现两个可复现的缺陷:
- * · **退出**:相机从县城升向 20,000 km 全景,途中穿过 42 km ⇒ 整个地球变白一段时间;
- * · **进入**:高空整屏白,降到 42 km 以下时画面**中心先解冻**、露出一团绿色植被影像,
- * 观感就是「最后一段突然变绿」。
- * 两者同一个根因:**强度只随时间推进,没有随高度收口**。
- *
- * 取值的依据(不是拍脑袋):`altitudeZeroM` 取雾自己的全遮蔽端点 42 km ——
- * 相机一旦高于它,画面上**没有**任何片元还在 42 km 以内,雾已无保护意义、只会糊住地球;
- * `altitudeFullM` 取 20 km —— 低于它时雾恢复满强度,而**归位视角(约 9.8 km)远在下方**,
- * 因此已验收的区内观感**完全不受影响**。
- */
- altitudeFullM: 20000,
- altitudeZeroM: 42000,
- /**
- * 高度系数的**时间限速**(毫秒):系数从 1 走到 0(或反向)至少花这么久。
- *
- * ── 为什么必须有它(维护者 2026-09-29 第二次现场反馈)──
- * 第一版直接乘**瞬时**高度系数,于是「雾几乎一瞬间就产生或消失」。原因是量级差:
- * 返回全球时 `range` 从 12 km 变到 **20,000 km**(跨 1.7e6 倍),而雾的高度带只有
- * 20→42 km —— 相机**开始移动的头一帧**就越过了整条带(实算穿越耗时 ≈ 0.15 ms)。
- * 也就是说:**任何"瞬时高度驱动"都必然跳变**,与缓动、时长、阻尼都无关。
- * 雾的浓淡只能由**时间**决定,高度只用来决定"这一刻该不该有雾"。
- *
- * 限速把这件事变成结构性保证:系数每次最多走 `dt / altitudeSlewMs`,
- * 所以无论相机多快,画面上的雾都不可能比这个时长更快地变化。
- * 取 400 ms = 淡出时长,保证它与淡出**同量级**(不会比配置的淡出更快,也不会拖慢它)。
- */
- altitudeSlewMs: 400,
- }
- /**
- * 雾的对外状态(写进宿主 `data-map-fog`)。
- * `ready` = 处理级已可执行(**不等于**已遮住边缘);`unsupported` / `failed` = 整级不可用。
- */
- export const FOG_STATE = {
- READY: 'ready',
- DISABLED: 'disabled',
- UNSUPPORTED: 'unsupported',
- FAILED: 'failed',
- }
- /** 进出阶段(内部状态机;`locked` / `global` 与区域状态同名,便于对照)。 */
- export const FOG_PHASE = {
- GLOBAL: 'global',
- ENTERING: 'entering',
- LOCKED: 'locked',
- EXITING: 'exiting',
- }
- /** 降级档:正常 / 减弱(管线透明错位时可读性优先)/ 关闭。 */
- export const FOG_DEGRADE = {
- NONE: 'none',
- REDUCED: 'reduced',
- OFF: 'off',
- }
- /** 管线视图降级时的强度上限(「允许减弱」的可微调数值)。 */
- export const PIPELINE_REDUCED_STRENGTH = 0.35
- /** 性能预算:同条件下开启雾的 P95 帧耗时增幅上限。 */
- export const FOG_PERF_BUDGET = 0.15
- const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/
- /**
- * 读一个数值参数:**没给**(`undefined` / `null` / `''`)→ 用默认;给了但不可用 → 也用默认。
- * (导出:sceneGrade 等需要同一"脏值退默认"口径的 domain 模块复用这一份,不再各抄一份。)
- *
- * 为什么要显式挡 `null` 与 `''`:内置默认里这两个状态就是 `null`/`''`("本部署没配"),
- * 而 `Number(null) === 0`、`Number('') === 0`。当 `min` 允许 0 时(淡入淡出时长正是这种情况),
- * `null` 会**静默变成 0 秒** —— 表现是"雾突然出现/突然消失",而配置里什么都没写。
- * 这个 bug 是本票真机验证时抓到的(`fadeInMs/fadeOutMs` 全成了 0),
- * 逻辑闸门当时只喂了 `{}`(字段是 `undefined`)所以没暴露 —— 现在两边都补上。
- */
- export function readNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) {
- if (value === undefined || value === null || value === '') return fallback
- const num = Number(value)
- if (!Number.isFinite(num)) return fallback
- if (num < min || num > max) return fallback
- return num
- }
- /** 解析 `#rrggbb` → `{r,g,b}`(0..1);不合法返回 null。 */
- export function parseHexColor(value) {
- const match = HEX_PATTERN.exec(String(value || '').trim())
- if (!match) return null
- const int = parseInt(match[1], 16)
- return {
- r: ((int >> 16) & 0xff) / 255,
- g: ((int >> 8) & 0xff) / 255,
- b: (int & 0xff) / 255,
- }
- }
- /**
- * 色相判据:**冷白灰、不发绿**(决策 08 的实测反馈「正式色值须白灰偏冷、不得发绿」)。
- *
- * 客观化三件事,避免"看着像"这种没法断言的说法:
- * 1. 明度足够高(是"白灰"而不是深色);
- * 2. 饱和度足够低(是"灰"而不是彩色);
- * 3. **绿色通道不得显著高于红/蓝** —— 这正是"发绿"的可测含义。
- *
- * 饱和度用 **HSV 口径 `(max-min)/max`**,不用 HSL:浅色的 HSL 饱和度天然偏高,
- * 会把已认可的基准色误判成"彩色"(实测 `#bdcdd6` 的 HSL 饱和度 0.234、
- * 而同属冷白灰的 `#c8d8e8` 高达 0.410;两者 HSV 饱和度只有 0.117 / 0.138,
- * 而明显发绿的 `#a8d8b0` 是 0.222、饱和绿 `#35e39a` 是 0.767)。HSV 才是浅色灰度的好判据。
- *
- * 返回 `{ok, reasons[]}`,失败原因逐条列出,界面与票面都能引用同一份判定。
- */
- export function fogColorVerdict(color, { minLightness = 0.6, maxSaturation = 0.2, maxGreenBias = 0.02 } = {}) {
- const rgb = parseHexColor(color)
- if (!rgb) return { ok: false, reasons: ['色值不是 #rrggbb'], rgb: null }
- const max = Math.max(rgb.r, rgb.g, rgb.b)
- const min = Math.min(rgb.r, rgb.g, rgb.b)
- const lightness = (max + min) / 2
- const saturation = max === 0 ? 0 : (max - min) / max
- // 绿色偏差:绿通道比红蓝的较大者高出多少("发绿"就是它为正且明显)。
- const greenBias = rgb.g - Math.max(rgb.r, rgb.b)
- const reasons = []
- if (lightness < minLightness) reasons.push(`明度 ${lightness.toFixed(3)} 偏低(应是白灰)`)
- if (saturation > maxSaturation) reasons.push(`饱和度 ${saturation.toFixed(3)} 偏高(应是灰)`)
- if (greenBias > maxGreenBias) reasons.push(`绿色通道偏高 ${greenBias.toFixed(3)}(不得发绿)`)
- return { ok: reasons.length === 0, reasons, rgb, lightness, saturation, greenBias }
- }
- /**
- * 雾参数计划:配置优先、缺项用规格默认;**脏值一律退回默认**(与 domain/viewModes.js 同立场)。
- * @param {object} mapConfig 归一化后的 `config.map`
- */
- export function planFog(mapConfig = {}) {
- const fog = (mapConfig && mapConfig.fog) || {}
- const startDistance = readNumber(fog.startDistance, FOG_DEFAULTS.startDistance, { min: 1 })
- // 结束端点必须**严格大于**起点:否则 smoothstep 退化,两端语义就没法同时成立。
- const rawEnd = readNumber(fog.endDistance, FOG_DEFAULTS.endDistance, { min: 1 })
- const endDistance = rawEnd > startDistance ? rawEnd : Math.max(startDistance + 1, FOG_DEFAULTS.endDistance)
- const color = typeof fog.color === 'string' && parseHexColor(fog.color) ? fog.color.trim() : FOG_DEFAULTS.color
- return {
- startDistance,
- endDistance,
- color,
- fadeOutMs: readNumber(fog.fadeOutMs, FOG_DEFAULTS.fadeOutMs, { min: 0, max: 10000 }),
- fadeInMs: readNumber(fog.fadeInMs, FOG_DEFAULTS.fadeInMs, { min: 0, max: 10000 }),
- enterAltitudeFrom: readNumber(fog.enterAltitudeFrom, FOG_DEFAULTS.enterAltitudeFrom, { min: 1 }),
- enterAltitudeTo: readNumber(fog.enterAltitudeTo, FOG_DEFAULTS.enterAltitudeTo, { min: 0 }),
- /** 相机高度收口的两端(米);见 `fogAltitudeFactor` 与 `FOG_DEFAULTS` 的说明。 */
- altitudeFullM: readNumber(fog.altitudeFullM, FOG_DEFAULTS.altitudeFullM, { min: 0 }),
- altitudeZeroM: readNumber(fog.altitudeZeroM, FOG_DEFAULTS.altitudeZeroM, { min: 1 }),
- /** 高度系数的限速时长(毫秒);见 `approachAltitudeFactor`。 */
- altitudeSlewMs: readNumber(fog.altitudeSlewMs, FOG_DEFAULTS.altitudeSlewMs, { min: 0, max: 10000 }),
- /** 配置里显式关掉雾(部署不需要这层效果时的开关)。 */
- enabled: fog.enabled !== false,
- }
- }
- /**
- * **相机高度收口**:把雾强度按相机高度打折,`[0, 1]`。
- *
- * 存在的理由见 `FOG_DEFAULTS.altitudeFullM` 的注释:不加这一层,
- * 相机在高空时整屏每个片元都超出全遮蔽端点,整个地球被涂成雾色
- * —— 退出时「地球变白」、进入时「最后一段突然变绿」都源于此。
- *
- * 它**只压缩强度**,不改雾色、距离端点或交接时序;区内稳态(约 9.8 km)系数恰为 1。
- * `altitudeFullM >= altitudeZeroM`(配置写反)时退化为"恒 1"(回到旧行为),
- * 不静默把雾关掉 —— 那种失败模式更难查。
- *
- * @param {number} altitudeM 相机椭球高度(米)
- * @returns {number} 0..1
- */
- export function fogAltitudeFactor(altitudeM, params = FOG_DEFAULTS) {
- const full = Number(params.altitudeFullM)
- const zero = Number(params.altitudeZeroM)
- if (!Number.isFinite(altitudeM) || !Number.isFinite(full) || !Number.isFinite(zero) || zero <= full) return 1
- if (altitudeM <= full) return 1
- if (altitudeM >= zero) return 0
- const t = (zero - altitudeM) / (zero - full)
- return t * t * (3 - 2 * t)
- }
- /** 单帧 dt 上限(毫秒):切标签页回来时别让系数一次走完(与 `camera.js` 同口径)。 */
- export const FOG_MAX_FRAME_MS = 120
- /**
- * 高度系数的时间限速推进(纯函数):把瞬时目标收敛成"看得见"的变化。
- *
- * 存在的理由见 `FOG_DEFAULTS.altitudeSlewMs`:瞬时高度系数在返回全球时会跳变
- * (range 跨 1.7e6 倍,雾带 20–42 km 只占其中 0.0001%,头一帧就越过),
- * 于是雾"几乎一瞬间消失"。这里把系数**限速**,使它至少花 `altitudeSlewMs` 走完全程。
- *
- * 只做速率限制,不做缓动:缓动会让"该有雾时雾还不够"的时间变长,
- * 而这里的诉求恰恰是"别比配置时长更快"。两端仍然精确落在 0 / 1。
- *
- * @param {number} shown 上一帧的系数(0..1)
- * @param {number} target 这一帧的瞬时目标(`fogAltitudeFactor` 的结果)
- * @param {number} dtMs 距上一帧的真实毫秒
- * @returns {number} 限速后的系数(0..1)
- */
- export function approachAltitudeFactor(shown, target, dtMs, params = FOG_DEFAULTS) {
- const from = Number.isFinite(shown) ? Math.min(1, Math.max(0, shown)) : 0
- const to = Number.isFinite(target) ? Math.min(1, Math.max(0, target)) : 1
- const slew = Number(params.altitudeSlewMs)
- // 没配 / 配成 0:不限速(立刻跟到目标)—— 与其它参数的"显式 0 是合法意图"一致。
- if (!Number.isFinite(slew) || slew <= 0) return to
- const dt = Math.min(FOG_MAX_FRAME_MS, Math.max(0, Number.isFinite(dtMs) ? dtMs : 0))
- const maxStep = dt / slew
- const delta = to - from
- if (Math.abs(delta) <= maxStep) return to
- return from + Math.sign(delta) * maxStep
- }
- /**
- * 世界射线距离 → 雾不透明度 `[0, 1]`。**两端语义精确**:
- * · `distance <= start` → **恰好 0**(全清);
- * · `distance >= end` → **恰好 1**(全遮蔽);
- * · 中间用 smoothstep 再套一条保端点的光学厚度曲线,曲线单调、无明显"雾墙"。
- *
- * 为什么不直接用 `1 - exp(-k·t)`:它在有限距离**永远到不了 1**,
- * 于是「42 km 完全遮蔽」这条端点语义不成立 —— 规格明确指出这种写法不算达标。
- * 这里用 `(1 - exp(-3t)) / (1 - exp(-3))`:形状与光学厚度类似,但**端点被精确归一化到 1**。
- *
- * @param {number} distanceM 相机到片元的世界射线距离(米)
- * @param {{startDistance: number, endDistance: number}} [params]
- * @returns {number} 0..1
- */
- export function fogAttenuation(distanceM, params = FOG_DEFAULTS) {
- const start = Number(params.startDistance)
- const end = Number(params.endDistance)
- if (!Number.isFinite(distanceM) || !Number.isFinite(start) || !Number.isFinite(end)) return 0
- if (end <= start) return distanceM >= end ? 1 : 0
- if (distanceM <= start) return 0
- if (distanceM >= end) return 1
- // smoothstep:中点附近增浓最快,两端平缓(避免出现一条生硬的"雾墙")。
- const t = (distanceM - start) / (end - start)
- const smooth = t * t * (3 - 2 * t)
- // 保端点的光学厚度形状:t=0 → 0,t=1 → 1。
- return (1 - Math.exp(-3 * smooth)) / (1 - Math.exp(-3))
- }
- /**
- * 门控:雾**只在主场景区内**生效。
- *
- * 「纯全球/引导态关闭」是规格原文;`areaState` 由票 37 提供(与 `range_max` 同一份输入契约)。
- * 主场景区不可用(未配置 / 非法)时同样关闭 —— 沿票 36 的关闭路径。
- *
- * @param {string} areaState `locked` | `global`
- * @param {object} plan 主场景区计划(`mapService.getPrimaryAreaPlan()`)
- */
- export function shouldFogBeActive(areaState, plan) {
- if (!plan || !plan.enabled) return false
- return areaState === 'locked'
- }
- /**
- * 进入途中的早期强度输入:相机高度从 `enterAltitudeFrom` 降到 `enterAltitudeTo` 时,
- * 强度从 0 平滑升到 1。
- *
- * **它只是"强度输入",不是"可以揭示精细影像"的充分条件**(决策 07 第 4 条:
- * 原型的 60/20 km 高度带已被人工演示判为遮挡过晚)。票 39 不得拿本函数的结果当遮缝承诺。
- *
- * @param {number} altitudeM 相机椭球高度(米)
- */
- export function enterAltitudeStrength(altitudeM, params = FOG_DEFAULTS) {
- const from = Number(params.enterAltitudeFrom)
- const to = Number(params.enterAltitudeTo)
- if (!Number.isFinite(altitudeM) || !Number.isFinite(from) || !Number.isFinite(to) || from <= to) return 0
- if (altitudeM >= from) return 0
- if (altitudeM <= to) return 1
- const t = (from - altitudeM) / (from - to)
- return t * t * (3 - 2 * t)
- }
- /**
- * 进出强度推进(纯函数):给定当前阶段与已过去的时间,算这一帧的强度。
- *
- * `entering`:从 `from` 线性推进到 1(缓动);
- * `exiting`:从 `from` 淡到 0(基准 0.8 s,决策 08 要求"比原型稍快",故时长可配置);
- * `locked`:恒 1;`global`:恒 0。
- *
- * @returns {{strength: number, done: boolean}}
- */
- export function advanceFogStrength(phase, elapsedMs, from, params = FOG_DEFAULTS) {
- const base = Number.isFinite(from) ? Math.min(1, Math.max(0, from)) : 0
- if (phase === FOG_PHASE.LOCKED) return { strength: 1, done: true }
- if (phase === FOG_PHASE.GLOBAL) return { strength: 0, done: true }
- if (phase === FOG_PHASE.ENTERING) {
- const duration = Number(params.fadeInMs)
- if (!(duration > 0)) return { strength: 1, done: true }
- const t = Math.min(1, Math.max(0, elapsedMs / duration))
- const eased = t * t * (3 - 2 * t)
- return { strength: base + (1 - base) * eased, done: t >= 1 }
- }
- if (phase === FOG_PHASE.EXITING) {
- const duration = Number(params.fadeOutMs)
- if (!(duration > 0)) return { strength: 0, done: true }
- const t = Math.min(1, Math.max(0, elapsedMs / duration))
- const eased = t * t * (3 - 2 * t)
- return { strength: base * (1 - eased), done: t >= 1 }
- }
- return { strength: 0, done: true }
- }
- /**
- * 生命周期状态机(**只有它决定阶段**,宿主与组件都不许自己算)。
- *
- * 关键性质:**连续进出只认最新目标**。每次开始一次进出都递增 `token`;
- * 上一轮排定的淡出/渐显回调带着旧 token 到达时被判过期、**不得**改动当前状态
- * —— 这正是「过期淡出回调不得关掉新的进入」的落点(决策 07 第 2 条)。
- */
- export function createFogLifecycle() {
- return { phase: FOG_PHASE.GLOBAL, strength: 0, token: 0, since: 0, from: 0 }
- }
- /** 开始一次进出:`enter` → entering,`exit` → exiting。返回新状态(含新 token)。 */
- export function beginFogTransition(state, action, now, params = FOG_DEFAULTS) {
- const current = state || createFogLifecycle()
- const from = Number.isFinite(current.strength) ? current.strength : 0
- if (action === 'enter') {
- // 已经在锁定态就不必重新渐显(幂等:重复"进入"不该把雾推倒重来)。
- if (current.phase === FOG_PHASE.LOCKED) return { ...current }
- return { phase: FOG_PHASE.ENTERING, strength: from, token: current.token + 1, since: now, from }
- }
- if (action === 'exit') {
- if (current.phase === FOG_PHASE.GLOBAL) return { ...current }
- return { phase: FOG_PHASE.EXITING, strength: from, token: current.token + 1, since: now, from }
- }
- return { ...current }
- }
- /** 推进一帧:按阶段与已过时间更新强度,到端点即落定。 */
- export function stepFogLifecycle(state, now, params = FOG_DEFAULTS) {
- const current = state || createFogLifecycle()
- if (current.phase === FOG_PHASE.GLOBAL) return { ...current, strength: 0 }
- if (current.phase === FOG_PHASE.LOCKED) return { ...current, strength: 1 }
- const elapsed = Math.max(0, now - current.since)
- const { strength, done } = advanceFogStrength(current.phase, elapsed, current.from, params)
- if (!done) return { ...current, strength }
- return current.phase === FOG_PHASE.ENTERING
- ? { ...current, phase: FOG_PHASE.LOCKED, strength: 1 }
- : { ...current, phase: FOG_PHASE.GLOBAL, strength: 0 }
- }
- /**
- * 过期判定:**带旧 token 的回调不得改动状态**。
- * @returns {boolean} true = 这次回调已过期,调用方必须丢弃
- */
- export function isStaleFogToken(state, token) {
- const current = state || createFogLifecycle()
- return token !== current.token
- }
- /** 当前是否应当渲染雾(可执行 + 有强度 + 阶段允许)。 */
- export function shouldRenderFog(state, { supported, phase } = {}) {
- const current = state || createFogLifecycle()
- const target = phase || current.phase
- if (supported === false) return false
- if (target === FOG_PHASE.GLOBAL) return false
- return current.strength > 0
- }
- /**
- * 降级选择(决策 07 第 6 条 / spec-fog 的「管线可读性优先」)。
- *
- * **正常情况不预先关闭**:只有真的观察到管线视图下透明对象被错误雾化时,才减弱;
- * 用户显式选择"暂关"时才关闭。降级**不动相机限位**(那是票 37 的事)。
- *
- * @param {object} options
- * @param {string} options.viewMode `ground` | `pipeline`
- * @param {boolean} options.translucentArtifacts 是否观察到透明深度错位(由人工/走查置位)
- * @param {boolean} options.userDisabled 用户是否显式要求暂关
- * @param {boolean} options.supported 深度能力是否可用
- */
- export function planFogDegrade({ viewMode, translucentArtifacts = false, userDisabled = false, supported = true } = {}) {
- if (supported === false) {
- return { level: FOG_DEGRADE.OFF, strengthCap: 0, reason: '当前环境不支持深度纹理,自建雾整级禁用' }
- }
- if (userDisabled) {
- return { level: FOG_DEGRADE.OFF, strengthCap: 0, reason: '已按用户要求暂关自建雾(相机限位不受影响)' }
- }
- if (translucentArtifacts) {
- return {
- level: FOG_DEGRADE.REDUCED,
- strengthCap: PIPELINE_REDUCED_STRENGTH,
- reason: '管线视图透明对象被错误雾化 · 已减弱雾效(可读性优先)',
- }
- }
- return { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
- }
- /**
- * 对外可观测读数(写进宿主 dataset 与浮标;**「就绪」不等于「已遮住边缘」**)。
- *
- * 刻意**不提供** `protected: true` 这类全局承诺:规格明确说
- * 「ready 只能说明处理级已可执行,不证明当前强度、视角与片元距离已经遮住边缘」。
- */
- export function fogReadout({ state, degrade, params, supported, executable, reason = '' } = {}) {
- const current = state || createFogLifecycle()
- const plan = params || FOG_DEFAULTS
- const degradePlan = degrade || { level: FOG_DEGRADE.NONE, reason: '' }
- const fogState = supported === false ? FOG_STATE.UNSUPPORTED : executable === false ? FOG_STATE.FAILED : FOG_STATE.READY
- return {
- state: fogState,
- supported: supported !== false,
- executable: executable !== false,
- active: current.phase !== FOG_PHASE.GLOBAL && current.strength > 0,
- phase: current.phase,
- strength: Number(current.strength.toFixed(4)),
- degrade: degradePlan.level,
- startDistance: plan.startDistance,
- endDistance: plan.endDistance,
- color: plan.color,
- reason: reason || degradePlan.reason || '',
- /** 显式声明:这只是"处理级可执行",不是"矩形边缘已被遮住"。 */
- coversEdges: false,
- }
- }
- /**
- * 性能预算判定:同机 / 同分辨率 / 同视角 / 相同加载条件下,
- * 开启雾的 P95 帧耗时增幅 ≤15%。
- * @param {number} p95WithFog 开启雾的 P95 帧耗时(毫秒)
- * @param {number} p95Baseline 关闭雾的 P95 帧耗时(毫秒)
- */
- export function fogPerformanceVerdict(p95WithFog, p95Baseline, budget = FOG_PERF_BUDGET) {
- if (!(p95Baseline > 0) || !Number.isFinite(p95WithFog)) {
- return { ok: false, ratio: null, reason: '基线无效或未采样(没有基线就无法判定达标)' }
- }
- const ratio = p95WithFog / p95Baseline - 1
- return {
- ok: ratio <= budget,
- ratio,
- reason: ratio <= budget ? '' : `P95 帧耗时增幅 ${(ratio * 100).toFixed(1)}% 超出 ${(budget * 100).toFixed(0)}% 预算`,
- }
- }
|