/** * 自适应渲染分辨率(票 61 第十九轮:以最优帧率为目标、画质只在需要时让位)。 * * ## 思路 * * 维护者要「最优帧率,同时保持当前质量」。固定降档(0.75 / 移动 0.6)是**一刀切**: * 快机器白让了画质,慢机器又可能不够。自适应的做法是**按实测帧间隔动态微调**: * * · 帧间隔 p95 持续偏慢(掉帧)→ 分辨率降一档(像素量立刻少 ~10%); * · 持续偏快(有富余)→ 分辨率升一档,回到画质上限; * · 中间地带不动 —— 避免在阈值附近来回抖。 * * 画质上限 = 部署配置的基准档(默认 0.75),下限 0.55:只有机器真的撑不住时才让位, * 且让得有限 —— 这就是「保持当前质量」的量化表达。 * * 纯函数:输入窗口统计与当前档位,输出下一步档位与计时器,脱浏览器可断言。 */ /** 帧间隔窗口的分位数(p95)—— 与 DEV 面板的 movingFrameIntervals 同口径。 */ export function percentile(sorted, p) { if (!Array.isArray(sorted) || !sorted.length) return null const list = [...sorted].sort((a, b) => a - b) const index = Math.min(list.length - 1, Math.floor(list.length * p)) return list[index] } /** * 推进一步自适应决策。 * * @param {object} state * @param {number} state.p95Ms 最近窗口的帧间隔 p95(毫秒) * @param {number} state.currentScale 当前内部分辨率 * @param {number} state.baseScale 画质上限(部署基准档) * @param {number} [state.floorScale] 画质下限(默认 0.55) * @param {number} state.nowMs 当前时刻 * @param {number|null} state.hotSinceMs 「持续偏慢」计时起点(null = 未在累计) * @param {number|null} state.coldSinceMs 「持续偏快」计时起点 * @param {number} [state.hotHoldMs] 偏慢要持续多久才降档(默认 1500) * @param {number} [state.coldHoldMs] 偏快要持续多久才升档(默认 4000,慢于降档防抖动) * @param {number} [state.step] 每档步长(默认 0.05) * @param {number} [state.hotMs] 「偏慢」阈值(默认 21ms ≈ 48fps) * @param {number} [state.coldMs] 「偏快」阈值(默认 16.7ms = 60fps) * @returns {{scale:number, hotSinceMs:(number|null), coldSinceMs:(number|null), changed:boolean}} */ export function stepAdaptiveResolution(state = {}) { const base = Number(state.baseScale) const current = Number(state.currentScale) const floor = Number.isFinite(Number(state.floorScale)) ? Number(state.floorScale) : 0.55 const step = Number.isFinite(Number(state.step)) ? Number(state.step) : 0.05 const hotMs = Number.isFinite(Number(state.hotMs)) ? Number(state.hotMs) : 21 const coldMs = Number.isFinite(Number(state.coldMs)) ? Number(state.coldMs) : 16.7 const hotHoldMs = Number.isFinite(Number(state.hotHoldMs)) ? Number(state.hotHoldMs) : 1500 const coldHoldMs = Number.isFinite(Number(state.coldHoldMs)) ? Number(state.coldHoldMs) : 4000 const nowMs = Number(state.nowMs) const p95 = Number(state.p95Ms) // 基础校验:任何关键量非法都「什么都不做」(宁可不动,不猜一个档位)。 if (!Number.isFinite(base) || !Number.isFinite(current) || !Number.isFinite(nowMs) || !Number.isFinite(p95)) { return { scale: current, hotSinceMs: state.hotSinceMs ?? null, coldSinceMs: state.coldSinceMs ?? null, changed: false } } const clampUp = (v) => Math.min(base, Math.max(floor, v)) let { hotSinceMs = null, coldSinceMs = null } = state if (p95 > hotMs) { // 偏慢:累计「降档观察期」,偏快计时清零(方向相反的两个计时不能同时走)。 coldSinceMs = null if (hotSinceMs === null) hotSinceMs = nowMs if (nowMs - hotSinceMs >= hotHoldMs) { const next = clampUp(current - step) const changed = Math.abs(next - current) > 1e-6 return { scale: next, hotSinceMs: null, coldSinceMs: null, changed } } } else if (p95 < coldMs) { hotSinceMs = null if (coldSinceMs === null) coldSinceMs = nowMs if (nowMs - coldSinceMs >= coldHoldMs) { const next = clampUp(current + step) const changed = Math.abs(next - current) > 1e-6 return { scale: next, hotSinceMs: null, coldSinceMs: null, changed } } } else { // 中间地带(coldMs..hotMs):两个方向都不累计 —— 这是防抖带,不许在这个区间里来回切。 hotSinceMs = null coldSinceMs = null } return { scale: current, hotSinceMs, coldSinceMs, changed: false } } /** * 从一组帧间隔(毫秒)算出 p95;样本不足返回 null(调用方据此跳过本轮决策)。 */ export function frameIntervalP95(intervalsMs, minSamples = 30) { if (!Array.isArray(intervalsMs) || intervalsMs.length < minSamples) return null return percentile(intervalsMs.filter((n) => Number.isFinite(n) && n > 0), 0.95) }