/** * 区内视距雾气(票 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)}% 预算`, } }