copper3d — 场景资源管理
释放场景持有的资源、限制常驻内存总量,以及把一个模型交叉淡变成另一个。全部从包入口导出 (copper3d)。
3.8.0 新增
纯新增。驻留预算默认关闭——不主动创建并处理它的返回值,就不会有任何东西被逐出。
1. disposeObject3D() 与 disposeMaterial()
disposeObject3D(root: DisposableObject3D): void
disposeMaterial(material: DisposableMaterial | DisposableMaterial[] | null | undefined): voiddisposeObject3D(root: DisposableObject3D): void
disposeMaterial(material: DisposableMaterial | DisposableMaterial[] | null | undefined): voidscene.remove() 只是解除挂接。three.js 会一直保留几何体的 buffer 和材质的贴图直到显式释放, 而 copper3d 从不释放自己创建的东西——所以任何会替换模型或逐出场景的代码都需要这两个函数, 否则内存占用没有上界。
scene.scene.remove(oldModel);
disposeObject3D(oldModel);scene.scene.remove(oldModel);
disposeObject3D(oldModel);两个值得知道的点:
Material.dispose()不会级联到它的贴图。 canvas backed 的切片贴图、或 glTF 的 baseColor map,不显式调用就会一直留在 GPU 上。disposeMaterial会遍历 three 内置材质使用 的贴图槽位(map、normalMap、roughnessMap、metalnessMap、aoMap、emissiveMap、alphaMap、bumpMap、displacementMap、envMap、lightMap、specularMap)。- 它支持数组形式。 多材质网格否则会走到
Array.prototype.dispose,而那个方法并不存在。
disposeObject3D 既能处理整棵子树,也能处理单个网格——traverse 会访问根节点自身——所以 一个从未加入场景的切片平面用同一个调用就能释放。这对 loadNrrd 很要紧:它会为 x、y、z 三个方向各构建一个完整的 VolumeSlice(各自的几何体、材质和 canvas backed 贴图),无论你是否 显示全部三个:
scene.loadNrrd(url, bar, true, (volume, meshes, slices) => {
scene.addObject(meshes.z);
// 只看轴位的 viewer 永远不会显示这两个,而不释放的话它们会在
// `volume.sliceList` 里跟着体数据活一辈子。
disposeObject3D(meshes.x);
disposeObject3D(meshes.y);
});scene.loadNrrd(url, bar, true, (volume, meshes, slices) => {
scene.addObject(meshes.z);
// 只看轴位的 viewer 永远不会显示这两个,而不释放的话它们会在
// `volume.sliceList` 里跟着体数据活一辈子。
disposeObject3D(meshes.x);
disposeObject3D(meshes.y);
});2. disposeScene() 与 removeSceneFromMap()
disposeScene(host: SceneDisposalHost, name: string): DisposableScene | undefined
removeSceneFromMap(host: SceneDisposalHost, name: string): void
// 或作为方法
copperRendererOnDemond.disposeScene(name)disposeScene(host: SceneDisposalHost, name: string): DisposableScene | undefined
removeSceneFromMap(host: SceneDisposalHost, name: string): void
// 或作为方法
copperRendererOnDemond.disposeScene(name)在 3.8.0 之前,根本没有任何办法从 sceneMap 里移除条目——没有任何代码删除它,所以一个 长生命周期的 renderer 会累积它建过的每一个场景,包括已解码的体数据。
disposeScene 会注销该场景、禁用它的 controls、摘掉 change 监听,并释放每一个子对象的几何体、 材质和贴图。返回被释放的场景,若该名字下没有场景则返回 undefined。
removeSceneFromMap 只注销、不释放任何资源,供需要把场景改名重新注册的调用方使用。
它故意不调用 controls.dispose()
那看上去是切断最后一处引用的显然做法,其实是个陷阱。同一个 renderer 建出的所有场景共用一块 canvas,而 OrbitControls.dispose() 会调 disconnect(),后者的最后一步是 domElement.style.touchAction = ''——而只有 connect() 会把它设回 'none'。于是释放一个被逐出 场景的 controls,会为当前真正显示在屏幕上的那个场景重新打开浏览器的触摸滚动:逐出一次, 整个会话剩余时间里的触摸旋转就都坏了。
只摘掉 change 监听同样能切断那条引用链,而且不动任何共享的东西。
移除没生效时会大声报错
delete 一个不存在的属性是静默无操作,所以如果哪天把 sceneMap 改成真正的 Map,逐出会 悄无声息地失效——不崩溃、不报类型错,而 map 会重新无界增长且完全不可见。因此移除后会通过宿主 自己的访问器确认,条目仍在就抛异常。
它遍历子对象,而不是按名字清理
用遍历 scene.children 而不是一份写死的名字列表:名字列表一定会漏掉后来有人以别的名字加进去的 东西。
2a. copperSceneOnDemond.dispose() 3.9.0
scene.dispose(): voidscene.dispose(): void释放场景挂在自己之外的东西 —— 具体说就是构造函数往 window 上挂的那个 resize 监听。
3.9.0 之前这个监听没有任何对应的摘除动作。每一个创建过的场景都会在整个页面生命周期里 保持订阅,被闭包吊着不放,并且在它早已不再显示之后,仍然继续调用 requestRenderIfNotRequested —— 进而调用 onWindowResize,而那会去改共享的 renderer 尺寸。一个"每个病例建一个场景"的 app,就会每个病例泄漏一个,而且它们在每次窗口 resize 时全部触发。
disposeScene() 现在会替你调它,所以逐出这条路已经是完整的。只有当你不经过 renderer 的 map 直接丢弃一个场景时,才需要自己调:
scene.dispose();scene.dispose();它不碰场景图,也不碰 renderer。场景里的东西值不值得留是调用方的决定,而 renderer 是和 其他所有场景共享的 —— 拆内容是 disposeScene 的活。可以重复调用;场景类没有这个方法也 没关系(disposeScene 是可选调用)。
3. 驻留预算
createSceneBudget(limitBytes: number): SceneBudget
defaultBudgetBytes(deviceMemoryGiB?: number): numbercreateSceneBudget(limitBytes: number): SceneBudget
defaultBudgetBytes(deviceMemoryGiB?: number): number一个按字节计的 LRU 预算,带 pin。它只负责决定该逐出哪些场景;你拿它给出的名字去调 disposeScene。
const budget = createSceneBudget(defaultBudgetBytes(navigator.deviceMemory));
// 场景加载完成后
budget.register(name, volume.data.byteLength);
budget.pin(name); // 它正显示在屏幕上
for (const victim of budget.overflow()) {
renderer.disposeScene(victim);
budget.release(victim);
}const budget = createSceneBudget(defaultBudgetBytes(navigator.deviceMemory));
// 场景加载完成后
budget.register(name, volume.data.byteLength);
budget.pin(name); // 它正显示在屏幕上
for (const victim of budget.overflow()) {
renderer.disposeScene(victim);
budget.release(victim);
}| 方法 | 含义 |
|---|---|
register(key, bytes) | 记录(或更新)一个常驻场景的解码后大小,并标记为最近使用。 |
touch(key) | 把一个已注册的场景标记为最近使用。未注册则空操作。 |
pin(key) / unpin(key) | 场景显示在屏幕上期间保护它不被逐出。 |
release(key) | 彻底忘记一个场景。在它的 GPU 资源释放之后调用。 |
rename(from, to) | 把字节数、队列位置和 pin 状态一并转移,供替换场景内容并改键的调用方使用。 |
overflow() | 需要逐出的 key,最久未使用的在前,永不包含被 pin 的。总量在预算内、或已无可逐出者时返回空数组。 |
bytes() | 当前总量。 |
为什么按字节而不是按场景数量
数量不是真正有风险的东西。一个 NRRD 体数据在磁盘上可以是 51 KB,也可以是 53 MB,而 NRRD 解码 后的原始类型化数组比压缩尺寸还要大——所以"三个场景"可能是 150 KB,也可能是 250 MB。
为什么需要 pin
当多个视口共用一个 renderer 时,同时可见的场景不止一个。屏幕上的那些应当被 pin 住,并且永远 不作为逐出对象,无论这让总量超出限额多少——把读者正在看的画面清空,比它省下的那点内存 糟糕得多。overflow() 宁可返回空数组,也不会逐出被 pin 的场景。
defaultBudgetBytes
设备内存 ≥ 8 GiB 返回 500 MB,否则 250 MB。navigator.deviceMemory 是 Chromium 独有的提示值, Safari 和所有 iOS 浏览器都没有——而**"未知"必须对应小预算**:iPad 既是不提供这个 API 的设备, 也是最可能因为占用过多而被系统杀掉标签页的设备。
4. 模型交叉淡变
collectFadeTargets(root: DisposableObject3D): FadeTarget[]
setFade(targets: FadeTarget[], factor: number): void
restoreFade(targets: FadeTarget[]): voidcollectFadeTargets(root: DisposableObject3D): FadeTarget[]
setFade(targets: FadeTarget[], factor: number): void
restoreFade(targets: FadeTarget[]): void拆成"采集 / 施加 / 还原"三步,时序由调用方掌握——淡变由你已有的动画循环逐帧驱动,而不是由这里 再起一个循环。
const outgoing = collectFadeTargets(previous);
const incoming = collectFadeTargets(next);
setFade(incoming, 0);
// 每帧,t 从 0 到 1
setFade(incoming, t);
setFade(outgoing, 1 - t);
// 结束时
restoreFade(incoming);
scene.scene.remove(previous);
disposeObject3D(previous);const outgoing = collectFadeTargets(previous);
const incoming = collectFadeTargets(next);
setFade(incoming, 0);
// 每帧,t 从 0 到 1
setFade(incoming, t);
setFade(outgoing, 1 - t);
// 结束时
restoreFade(incoming);
scene.scene.remove(previous);
disposeObject3D(previous);两条不显然的规则,都编码在 setFade 里:
- opacity 是按比例缩放,不是直接赋值。
opacity = factor会让本来就半透明的材质在淡变结束 时变成完全不透明——一层 40% 透明的外壳会停在 1.0,把它本该透出来的东西全遮住。 depthWrite在整个淡变过程中被压掉,只在完全不透明的那一端才恢复。半透明面片如果还写 深度,就会遮挡画在它后面的一切;淡变时若不关掉,会看到淡出的模型在淡入的模型上"打洞"。 本来就没有depthWrite的材质不会被恢复出来。
restoreFade 会把每一个采集到的属性精确还原成 collectFadeTargets 当初看到的样子。即使淡变 被中断也要在结束时调用它——直接跳到终态,好过让两个半透明模型冻结在屏幕上。