资源租约与字节预算resource-cache

Working Set 控制同时活跃多少个节点;LOD 控制每张图用多大规格;Resource Pool 控制这些资源合计最多占多少内存。三者缺一不可。

学习目标

  • 区分压缩文件大小、解码内存和 GPU 成本;
  • 理解 original、variant、decoded resource 的不同保留规则;
  • 根据屏幕投影尺寸选择 LOD;
  • 用 Lease + LRU + Hard Budget 管理共享资源;
  • 解释预算不足时为什么必须拒绝,而不是驱逐正在使用的资源。

1. 5 MB 图片为什么可能占 92 MiB

JPEG/WebP 的文件大小是压缩后体积。解码后的常用估算:

decodedBytes ≈ width × height × 4

6000 × 4000

6000 × 4000 × 4 = 96,000,000 bytes ≈ 91.6 MiB

这还未计算 Canvas 中间缓存、GPU texture 和多份缩略图。一张“只有 5 MB”的图片可能已经突破移动设备预算,所以不能只看网络包大小。

2. 四层资源,四种保留规则

层次 例子 普通 LRU 可直接淘汰
Document 引用 assetId 否,作品真相
AssetStore original 原始 Blob 被作品引用时通常否
派生 variant 256/1024/2048 WebP 是,可重建
运行时资源 ImageBitmap、texture、player 是,但活跃 lease 期间否

Document 不保存 Blob 或 ImageBitmap。一个素材可以被多个节点引用,因此“一个节点离屏”不代表共享资源可以立即释放。

3. LOD 根据实际投影尺寸选图

projectedPx = max(nodeWorldWidth, nodeWorldHeight) * zoom * dpr

世界尺寸 400 × 300、DPR=2:

zoom 最长边投影 可选档位
0.25 200 px 256
1 800 px 1024
4 3200 px 4096 或 original

选择“能覆盖投影尺寸的最小档位”。仅把 Konva Image 显示宽度改成 200px,不等于使用缩略图;如果 source 仍是 6000px 原图,浏览器仍可能解码完整像素。

4. LOD 从哪里来

用户通常只导入一张原图。派生档位在导入或后台低优先级任务中生成:

Original Blob
  → createImageBitmap
  → OffscreenCanvas 缩放
  → convertToBlob(WebP)
  → 256 / 1024 / 2048 variants
  → AssetStore

生成 LOD 不能放进 Camera/zoom 热路径。缩放只选择已有档位;缺少档位时显示当前资源并排队后台生成。

5. 为什么切换档位需要滞回

假设 1024 与 2048 的边界是投影 1024px。用户在 1010~1030px 间缩放时,立即切换会不断解码。

升级:需求明显超过当前档位,例如 > 1.2 × current
降级:需求明显低于下级档位,例如 < 0.7 × current

还可以 debounce 连续 zoom,在缩放稳定后再降档;导出时绕过编辑 LOD,显式请求原图。

6. Lease 表达“谁正在使用”

const lease = await pool.acquire('asset-A:1024', loader)
renderer.hydrate(node, lease.value)

// node 退出且 handle 销毁后
lease.release()

同一 key 被多个节点 acquire 时,Pool 复用资源并增加引用。引用归零后资源不会必须立刻销毁,而是进入 LRU 候选;这样短时间重访可以复用。

资源状态可以理解为:

loading → leased → unleased/LRU → evicted
             ↑          │
             └──────────┘ re-acquire

并发 acquire 同一个 key 应共享一个 in-flight Promise,避免重复解码。

7. 硬预算算法

请求新资源前:

预计 usedBytes + incomingBytes
  ≤ hardBudget:允许
  > hardBudget:先按 LRU 驱逐无 lease 资源
  仍超预算:拒绝新请求

不能为了让新资源进入而驱逐活跃 lease,否则 Renderer 仍持有失效对象。拒绝不是系统失败,而是正确的背压:调用方可以降级到更低 LOD、占位图或稍后重试。

除了字节预算,还应限制对象数和特殊资源:

decodedBytes <= 64 MiB
decodedObjects <= 128
activePlayers <= 2
inFlightDecodes <= 4

数值由宿主和设备策略决定,框架只提供机制与观测。

8. 淘汰与释放顺序

节点退出 Working Set
  → Renderer handle 销毁并解除 image 引用
  → release lease
  → 资源变为可驱逐
  → LRU 超预算时淘汰
  → ImageBitmap.close / revokeObjectURL / texture dispose

原始 Blob 仍在 AssetStore;下一次进入可以重新加载。若是派生 LOD,磁盘空间不足时也可删除后重建,但 original 的 GC 必须检查 Document、History、Checkpoint 和共享引用。

9. 操作 Demo

Demo 硬预算为 12 MiB:

  1. 获取 A=4 MiB、B=6 MiB,并保持两个 lease;
  2. 请求 C=8 MiB,观察即使有 LRU 算法也必须拒绝;
  3. 释放 A,A 进入可驱逐状态;
  4. 再请求 C,观察 Pool 先驱逐 A;
  5. 同时两次获取 B,验证只存在一份资源但 leaseCount 增加;
  6. 全部 release 后执行 trim,观察 bytes 和 object count 下降。

10. 验收指标

decodedBytes 永不突破 hardBudget
活跃 lease 不被 LRU 驱逐
同 key 并发加载只执行一次 loader
资源淘汰后可由 AssetStore 重建
LOD 与屏幕投影匹配,不长期使用超大原图
播放器、decode 并发和对象数都有单独上限

常见错误

  • 用压缩文件大小代替解码预算;
  • 只改变显示尺寸,实际仍解码原图;
  • 一个节点退出就销毁多人共享的资源;
  • release() 后立刻销毁,失去短时复用;
  • 为满足新请求驱逐活跃 lease;
  • 在 zoom 事件里生成新 LOD;
  • History 删除节点时立即永久删除 original,导致 Undo 无法恢复。

课后实验

增加 256/1024/2048 三档资源,根据 zoom 自动选择。记录连续缩放时的 decode 次数;加入滞回和 debounce 后再次比较。目标不是只减少内存,还要减少反复升降档的抖动。