adaptiveResolution.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. /**
  2. * 自适应渲染分辨率(票 61 第十九轮:以最优帧率为目标、画质只在需要时让位)。
  3. *
  4. * ## 思路
  5. *
  6. * 维护者要「最优帧率,同时保持当前质量」。固定降档(0.75 / 移动 0.6)是**一刀切**:
  7. * 快机器白让了画质,慢机器又可能不够。自适应的做法是**按实测帧间隔动态微调**:
  8. *
  9. * · 帧间隔 p95 持续偏慢(掉帧)→ 分辨率降一档(像素量立刻少 ~10%);
  10. * · 持续偏快(有富余)→ 分辨率升一档,回到画质上限;
  11. * · 中间地带不动 —— 避免在阈值附近来回抖。
  12. *
  13. * 画质上限 = 部署配置的基准档(默认 0.75),下限 0.55:只有机器真的撑不住时才让位,
  14. * 且让得有限 —— 这就是「保持当前质量」的量化表达。
  15. *
  16. * 纯函数:输入窗口统计与当前档位,输出下一步档位与计时器,脱浏览器可断言。
  17. */
  18. /** 帧间隔窗口的分位数(p95)—— 与 DEV 面板的 movingFrameIntervals 同口径。 */
  19. export function percentile(sorted, p) {
  20. if (!Array.isArray(sorted) || !sorted.length) return null
  21. const list = [...sorted].sort((a, b) => a - b)
  22. const index = Math.min(list.length - 1, Math.floor(list.length * p))
  23. return list[index]
  24. }
  25. /**
  26. * 推进一步自适应决策。
  27. *
  28. * @param {object} state
  29. * @param {number} state.p95Ms 最近窗口的帧间隔 p95(毫秒)
  30. * @param {number} state.currentScale 当前内部分辨率
  31. * @param {number} state.baseScale 画质上限(部署基准档)
  32. * @param {number} [state.floorScale] 画质下限(默认 0.55)
  33. * @param {number} state.nowMs 当前时刻
  34. * @param {number|null} state.hotSinceMs 「持续偏慢」计时起点(null = 未在累计)
  35. * @param {number|null} state.coldSinceMs 「持续偏快」计时起点
  36. * @param {number} [state.hotHoldMs] 偏慢要持续多久才降档(默认 1500)
  37. * @param {number} [state.coldHoldMs] 偏快要持续多久才升档(默认 4000,慢于降档防抖动)
  38. * @param {number} [state.step] 每档步长(默认 0.05)
  39. * @param {number} [state.hotMs] 「偏慢」阈值(默认 21ms ≈ 48fps)
  40. * @param {number} [state.coldMs] 「偏快」阈值(默认 16.7ms = 60fps)
  41. * @returns {{scale:number, hotSinceMs:(number|null), coldSinceMs:(number|null), changed:boolean}}
  42. */
  43. export function stepAdaptiveResolution(state = {}) {
  44. const base = Number(state.baseScale)
  45. const current = Number(state.currentScale)
  46. const floor = Number.isFinite(Number(state.floorScale)) ? Number(state.floorScale) : 0.55
  47. const step = Number.isFinite(Number(state.step)) ? Number(state.step) : 0.05
  48. const hotMs = Number.isFinite(Number(state.hotMs)) ? Number(state.hotMs) : 21
  49. const coldMs = Number.isFinite(Number(state.coldMs)) ? Number(state.coldMs) : 16.7
  50. const hotHoldMs = Number.isFinite(Number(state.hotHoldMs)) ? Number(state.hotHoldMs) : 1500
  51. const coldHoldMs = Number.isFinite(Number(state.coldHoldMs)) ? Number(state.coldHoldMs) : 4000
  52. const nowMs = Number(state.nowMs)
  53. const p95 = Number(state.p95Ms)
  54. // 基础校验:任何关键量非法都「什么都不做」(宁可不动,不猜一个档位)。
  55. if (!Number.isFinite(base) || !Number.isFinite(current) || !Number.isFinite(nowMs) || !Number.isFinite(p95)) {
  56. return { scale: current, hotSinceMs: state.hotSinceMs ?? null, coldSinceMs: state.coldSinceMs ?? null, changed: false }
  57. }
  58. const clampUp = (v) => Math.min(base, Math.max(floor, v))
  59. let { hotSinceMs = null, coldSinceMs = null } = state
  60. if (p95 > hotMs) {
  61. // 偏慢:累计「降档观察期」,偏快计时清零(方向相反的两个计时不能同时走)。
  62. coldSinceMs = null
  63. if (hotSinceMs === null) hotSinceMs = nowMs
  64. if (nowMs - hotSinceMs >= hotHoldMs) {
  65. const next = clampUp(current - step)
  66. const changed = Math.abs(next - current) > 1e-6
  67. return { scale: next, hotSinceMs: null, coldSinceMs: null, changed }
  68. }
  69. } else if (p95 < coldMs) {
  70. hotSinceMs = null
  71. if (coldSinceMs === null) coldSinceMs = nowMs
  72. if (nowMs - coldSinceMs >= coldHoldMs) {
  73. const next = clampUp(current + step)
  74. const changed = Math.abs(next - current) > 1e-6
  75. return { scale: next, hotSinceMs: null, coldSinceMs: null, changed }
  76. }
  77. } else {
  78. // 中间地带(coldMs..hotMs):两个方向都不累计 —— 这是防抖带,不许在这个区间里来回切。
  79. hotSinceMs = null
  80. coldSinceMs = null
  81. }
  82. return { scale: current, hotSinceMs, coldSinceMs, changed: false }
  83. }
  84. /**
  85. * 从一组帧间隔(毫秒)算出 p95;样本不足返回 null(调用方据此跳过本轮决策)。
  86. */
  87. export function frameIntervalP95(intervalsMs, minSamples = 30) {
  88. if (!Array.isArray(intervalsMs) || intervalsMs.length < minSamples) return null
  89. return percentile(intervalsMs.filter((n) => Number.isFinite(n) && n > 0), 0.95)
  90. }