Skip to content

从 8.6GB 到 Draco Worker:一次 Rodin 3D 预览内存暴涨排查复盘 ​

本文记录一次真实的前端 3D 内存问题排查过程。场景是 Rodin 任务卡片中反复打开、加载 3D 模型、关闭,再手动 GC 后,Chrome Tab 内存仍持续升高,最高曾到数 GB,页面逐渐卡顿。

这次排查的关键不是“猜哪里没释放”,而是不断把现象拆成可验证的证据:Heap Snapshot、运行时日志、Chrome Task Manager、Network Initiator、Worker 生命周期,最后定位到 Three.js 资源释放和 Draco 解码 worker 泄漏两个层面的问题。

1. 问题现象 ​

最初的复现路径很简单:

  1. 页面加载成功。
  2. 打开一个 card,加载 Rodin 3D 模型。
  3. 关闭 card。
  4. 在 DevTools Memory 面板手动 GC。
  5. 多次重复打开、关闭 card。

从浏览器外部观察,Chrome Tab 内存不断升高,最高到过 8GB 以上。与此同时,页面开始明显卡顿。

第一反应很容易是:“是不是 Three.js 场景没释放?”但这里不能直接下结论,因为 Chrome Task Manager 里的“内存占用”并不等同于 JS Heap。它可能包括:

  • JS heap。
  • ArrayBuffer / TypedArray。
  • WebGL native resources。
  • Worker 进程或线程相关内存。
  • 图片解码、网络缓存、Chrome allocator 尚未归还给 OS 的内存。

所以排查目标先拆成两件事:

  • 是否存在 JS/Three 对象引用链,导致模型对象无法 GC。
  • 如果 JS heap 已经下降,Tab 总内存仍高,是否有 native / worker / GPU 侧资源未释放。

2. 第一阶段:Heap Snapshot 证明模型数据确实被保留 ​

早期 heap snapshot 的保留路径指向了一个非常典型的 Three.js 资源链:

txt
JSArrayBufferData
  -> ArrayBuffer
  -> Float32Array / Uint32Array
  -> BufferAttribute.array
  -> BufferGeometry
  -> Mesh.geometry
  -> Group.children
  -> HomeScene._model
  -> Viewer._scene.children

这个路径说明:

  • 大块 TypedArray 没有释放。
  • TypedArray 被 BufferAttribute 持有。
  • BufferAttribute 又被 BufferGeometry 持有。
  • BufferGeometry 仍挂在 Mesh / Group / Scene 上。
  • 最终模型还被 HomeScene._model 和 Viewer.scene.children 引用。

这一步非常重要。它说明当时的内存增长里,至少有一部分不是 Chrome 统计噪声,而是真实的 JS 可达对象。

同时日志也证明了一个容易踩坑的点:仅调用 geometry.dispose() 不等于 TypedArray 立刻不可达。geometry.dispose() 释放的是 WebGL 侧资源,BufferAttribute.array 仍然是 JS 对象,如果还有引用链,它不会被 GC。

因此第一轮修复目标是:不仅 dispose Three resource,还要打断 JS 引用链。

3. 增加可下载的内存 Probe ​

控制台日志有两个问题:

  • 高频日志很容易丢。
  • Chrome Console 导出的日志不完整。

所以我们加了一个专门的 probe:window.rodinTabMemoryProbe。

典型使用方式:

js
rodinTabMemoryProbe.enable({
  includeUserAgentSpecificMemory: true,
  trackWorkers: true,
  sampleIntervalMs: 1000
});
rodinTabMemoryProbe.clear();
location.reload();

复现结束并手动 GC 后下载:

js
rodinTabMemoryProbe.mark('after-manual-gc');
setTimeout(() => rodinTabMemoryProbe.download(), 1500);

这个 probe 记录:

  • performance.memory 中的 JS heap。
  • 当前 viewer / scene 的资源统计。
  • renderer.info.memory。
  • canvas 是否还在 DOM 中。
  • WebGL context 是否 lost。
  • 当前是否还有自定义 render callback。
  • Worker 创建和 terminate 记录。
  • 当前 active blob worker URL。

这一步把问题从“感觉内存没降”变成了可对比的结构化数据。

4. 第一轮修复:释放 Three 对象和 JS TypedArray ​

新增 disposeObject3DResources,核心做几件事:

  • 遍历 Object3D tree。
  • dispose geometry。
  • dispose material。
  • dispose material 上的 texture。
  • 清理 BufferAttribute.array 和 interleaved buffer 的 array。
  • 清理 userData 中常见的大对象引用。

示意代码:

ts
disposeObject3DResources(model, {
  clearAttributeArrays: true,
});

这类释放不是为了“看起来彻底”,而是针对 heap snapshot 的保留链精确打断:

txt
BufferAttribute.array -> TypedArray -> JSArrayBufferData

对应改动集中在:

  • src/rodinrender/utils/disposeThreeResources.ts
  • src/rodinrender/scenes/HomeScene.ts
  • src/rodinrender/widgets/Canvas.tsx

5. 第二轮修复:调整 Canvas cleanup 顺序 ​

早期 cleanup 顺序里,SceneManager.clear() 调用太晚。结果是:

  • React unmount 了 Canvas。
  • 但 HomeScene 仍可能持有 _model。
  • viewer 的 render callback 仍可能存在。
  • late async load 还有机会把模型重新塞回来。

调整后的 cleanup 思路:

  1. 先 SceneManager.clear(),触发 active scene 的 onExit()。
  2. 停止 loading listener 和 state manager。
  3. 清理 AssetManager cache。
  4. 清空 viewer render callback。
  5. 深度 dispose scene。
  6. dispose loaders。
  7. renderer.renderLists.dispose()。
  8. renderer.dispose()。
  9. forceContextLoss() 和 WEBGL_lose_context.loseContext()。
  10. viewer.destroy()。
  11. 移除 canvas DOM 引用。

关键日志点:

txt
Canvas.cleanup:start
HomeScene.onExit:disposeModel
AssetManager.clear:end
Canvas.cleanup:afterDeepDispose
Canvas.cleanup:afterLoaderDispose
Canvas.cleanup:afterRendererDispose
Canvas.cleanup:end

这一轮之后,probe 里可以看到一个重要变化:

txt
scene.totalBufferMB = 0
sceneChildren = 0
canvasConnected = false
contextLost = true
hasCustomRenderCallback = false

这说明旧的 HomeScene._model -> Viewer.scene.children -> BufferGeometry 保留链已经基本被切断。

6. 第三轮修复:处理 stale asset load ​

快速切换模型或快速关闭 card 时,资源加载 Promise 可能在 cleanup 之后才 resolve。这个时候如果没有保护,late resolved asset 可能重新进入 cache 或 scene。

所以 AssetManager 加了 cacheEpoch:

ts
const loadEpoch = AssetManager.cacheEpoch;

return viewer.loadAsset(props).then((asset) => {
  if (loadEpoch !== AssetManager.cacheEpoch) {
    AssetManager.disposeLoadedAsset(asset);
    return asset;
  }

  AssetManager.resource.set(key, asset);
  return asset;
});

当 AssetManager.clear() 调用时:

ts
AssetManager.cacheEpoch += 1;
AssetManager.resource.clear();

这样 late resolved asset 会进入 AssetManager.loadAsset:skipStaleStore 分支,被立即 dispose,而不是重新写回 cache。

这一步解决的是异步竞态,不是常规同步释放。

7. 新现象:JS Heap 降了,但 Tab 总内存仍高 ​

经过前几轮后,JS heap 和 scene buffer 已经明显改善,但 Chrome Task Manager 仍显示 Tab 内存很高。

这时用户提供了 Chrome Task Manager 截图:

Chrome Task Manager 中大量 blob Dedicated Worker

这张图里有几个关键信息:

  • 页面内存占用约 5.7GB。
  • 页面 JavaScript 内存实际只有约 313MB。
  • 页面 GPU 内存只有几十 MB。
  • 页面下面挂了大量 Dedicated Worker: blob:http://localhost:3000/...。

这说明剩余的大头不在普通 JS heap,也不主要在页面 GPU memory,而更像 worker / native 侧资源。

此时排查方向从 “Three Object3D 是否释放” 转向 “Dedicated Worker 是否泄漏”。

8. 定位 Draco Worker 泄漏 ​

Three.js 的 DRACOLoader 会创建 Worker 来做 Draco 解码。源码行为大致是:

js
const worker = new Worker(this.workerSourceURL);
this.workerPool.push(worker);

而释放 worker 的入口是:

js
dracoLoader.dispose();

它会:

  • terminate workerPool 中的 worker。
  • revoke workerSourceURL。

问题在于项目里的自定义 loader:

  • bDRCLoader
  • bGLTFLoader
  • aBINLoader

内部都持有 DRACOLoader,但最初没有暴露 dispose()。

同时 @xviewer.js/core 的 ResourceManager.destroy() 只是:

js
this._loaders.clear();

它并不会主动调用 loader 的 dispose()。所以即使 viewer destroy 了,loader map 被清空了,Draco worker 仍可能继续活着。

第一轮 worker 修复是给三个自定义 loader 加 dispose():

ts
dispose() {
  disposeDracoLoader(this._dracoLoader);
  this._loader = undefined;
  this._dracoLoader = undefined;
}

并在 Canvas cleanup 里补一层:

ts
const loaderDisposeStats = disposeViewerLoaders(viewer);
recordRodinTabMemory(
  'Canvas.cleanup:afterLoaderDispose',
  () => ({ loaderDisposeStats }),
  viewer
);

这一轮后,Chrome Task Manager 明显改善,从大量 worker 变成只剩少量 worker:

修复后只剩少量 blob worker

这说明主 worker 泄漏已经被修掉,但还不是全部。

9. 残留 worker:60dd91 ​

之后又发现一个残留 worker:

Network 中的 60dd91 Draco worker

Chrome Task Manager 里同样能看到它:

Chrome Task Manager 中残留 60dd91 worker

对应日志证明它不是 GC 延迟:

txt
Canvas.cleanup:start
activeBlobWorkers = 2
urls = d8bd6e..., 60dd91...

Canvas.cleanup:afterLoaderDispose
activeBlobWorkers = 1
urls = 60dd91...

after-manual-gc
activeBlobWorkers = 1
urls = 60dd91...

这说明:

  • cleanup 确实杀掉了一个 worker。
  • 60dd91... 没有被当前 dispose 链覆盖。
  • 手动 GC 后它仍 active,所以不是 GC 时机问题。

进一步看 Network Initiator,调用栈指向:

txt
bDRCLoader.ts:25
AssetManager.ts:85
HomeScene.ts:3198

所以它属于 Rodin 模型加载链路中的 Draco 解码 worker。

当时的修复策略是新增 disposeDracoLoader,对 Draco loader 做更强的释放:

  • 立即 dispose。
  • 如果 decoderPending 还没完成,在 Promise settle 后再 dispose 一次。
  • 记录已知 worker。
  • dispose 后禁止 _getWorker 再创建 worker。

这是为了覆盖 DRACOLoader 的一个竞态:如果 decoder 初始化还没完成,dispose() 时 workerPool 可能还是空;之后 _initDecoder().then(...) 仍可能继续创建 worker。

10. 残留 worker:c1a66,快速切换触发的 stale load ​

最后一份日志里又出现:

txt
blob:http://localhost:3000/c1a66c36-c957-4355-beb3-9aa33c541f6c

关键时序:

txt
seq42 HomeScene.ModelAssets:skipStale
activeBlobWorkers = 3
urls = 4cc037de..., c1a66..., 4cc037de...

seq50 Canvas.cleanup:afterLoaderDispose
activeBlobWorkers = 1
urls = c1a66...

seq66 after-manual-gc
activeBlobWorkers = 1
urls = c1a66...

这个时序说明:

  • skipStale 证明快速切换模型确实触发了过期加载。
  • 4cc037de... 出现两次,说明某个 Draco loader pool 同时创建了多个 worker。
  • cleanup 能杀掉 4cc037de... 相关 worker。
  • 但 c1a66... 属于另一个未被当前 tracked loader 集合覆盖的 Draco loader 实例。

所以更准确的结论不是:

txt
快速切换导致 worker 来不及 GC

而是:

txt
快速切换触发 stale 异步模型加载;
该 stale load 创建了另一个 Draco worker;
这个 worker 来自未纳入统一登记的 DRACOLoader 实例;
所以常规 loader dispose 没有覆盖它。

为了解决这个盲区,最终把追踪从“我们创建的 Draco loader 实例”升级为“全局 patch DRACOLoader.prototype._getWorker”。

核心思想:

ts
const originalGetWorker = DRACOLoader.prototype._getWorker;

DRACOLoader.prototype._getWorker = function (...args) {
  const state = ensureManagedDracoState(this);

  if (state.disposed) {
    return Promise.reject(new Error('DRACOLoader has been disposed.'));
  }

  return originalGetWorker.apply(this, args).then((worker) => {
    state.workers.add(worker);

    if (state.disposed) {
      worker.terminate();
      throw new Error('DRACOLoader has been disposed.');
    }

    return worker;
  });
};

这样不管 worker 来自哪里,只要它通过 Three.js DRACOLoader 创建,就会进入统一登记表:

  • bDRCLoader
  • bGLTFLoader
  • aBINLoader
  • @xviewer.js/core 默认 aGLTFLoader
  • 其他直接 new DRACOLoader() 的路径

cleanup 时统一调用:

ts
disposeTrackedDracoLoaders();

这一步是最后一轮补丁,已经通过构建,但仍需要下一份用户实际复现日志确认:

txt
after-manual-gc.workerStats.activeBlobWorkers === 0

11. 代码改动总览 ​

11.1 Three 资源深度释放 ​

文件:

txt
src/rodinrender/utils/disposeThreeResources.ts

解决:

  • BufferGeometry.dispose() 不清 JS TypedArray 的问题。
  • material / texture / geometry 多处引用导致重复释放或漏释放的问题。

关键点:

ts
disposeObject3DResources(object, {
  clearAttributeArrays: true,
});

11.2 Canvas cleanup 顺序 ​

文件:

txt
src/rodinrender/widgets/Canvas.tsx

解决:

  • SceneManager 清理过晚。
  • viewer render callback 未断开。
  • renderer / WebGL context / canvas DOM 清理不完整。

关键阶段:

txt
SceneManager.clear()
stateManager.destroy()
clearRes()
disposeObject3DResources(viewer.scene)
disposeViewerLoaders(viewer)
renderer.renderLists.dispose()
renderer.dispose()
renderer.forceContextLoss()
viewer.destroy()
canvasRef.remove()

11.3 AssetManager stale load 防护 ​

文件:

txt
src/rodinrender/managers/AssetManager.ts

解决:

  • cleanup 后异步资源 resolve,又重新写入 cache。

关键机制:

ts
cacheEpoch
loadEpoch !== cacheEpoch -> disposeLoadedAsset(asset)

11.4 Loader dispose ​

文件:

txt
src/rodinrender/loaders/aBINLoader.ts
src/rodinrender/loaders/bDRCLoader.ts
src/rodinrender/loaders/bGLTFLoader.ts
src/rodinrender/utils/disposeViewerLoaders.ts

解决:

  • 自定义 loader 内部的 DRACOLoader 没有被 dispose。
  • ResourceManager.destroy() 只 clear map,不调用 dispose。

11.5 Draco Worker 全局追踪 ​

文件:

txt
src/rodinrender/loaders/disposeDracoLoader.ts

解决:

  • stale load 创建的 DRACOLoader 不在当前 viewer loader map 中。
  • 同一个 Draco loader pool 多 worker。
  • decoder 初始化中途 dispose 的竞态。
  • 第三方或默认 loader 直接创建 worker 但没有进入自定义 dispose 链。

12. 关键数据变化 ​

阶段主要证据结论
初始 heap snapshotHomeScene._model -> BufferGeometry -> TypedArray模型 JS 数据确实被保留
Three cleanup 后scene.totalBufferMB = 0,sceneChildren = 0场景对象保留链基本切断
Task Manager 5.7GBJS 内存约 313MB,但大量 blob Dedicated Worker剩余问题不主要在 JS heap
loader dispose 后大量 worker 降到少量 worker主 Draco worker 泄漏修复有效
60dd91 日志cleanup 后仍 active,手动 GC 后仍 active有 loader/worker 未被 dispose 链覆盖
c1a66 日志首次出现在 HomeScene.ModelAssets:skipStale快速切换触发 stale load,产生未登记 worker
最终补丁patch DRACOLoader.prototype._getWorker覆盖所有 Draco worker 创建路径,待最终复测

最新两份可用 probe 的关键摘要:

Probecleanup 次数after GC JS heapafter GC active blob worker关键残留
09-07-105404.36 MB160dd91...
09-16-554405.56 MB1c1a66...

注意:这两份日志是在最后一次全局 prototype 追踪补丁之前采集的。它们用于证明残留 worker 的根因,不代表最终补丁后的结果。

13. 验证方法 ​

建议每轮都使用同一套复现步骤:

js
rodinTabMemoryProbe.enable({
  includeUserAgentSpecificMemory: true,
  trackWorkers: true,
  sampleIntervalMs: 1000
});
rodinTabMemoryProbe.clear();
location.reload();

然后快速切换模型、反复开关 card,最后手动 GC,再执行:

js
rodinTabMemoryProbe.mark('after-manual-gc');
setTimeout(() => rodinTabMemoryProbe.download(), 1500);

最终应该重点看:

txt
Canvas.cleanup:afterLoaderDispose.workerStats.activeBlobWorkers
after-manual-gc.workerStats.activeBlobWorkers

期望结果:

txt
activeBlobWorkers = 0

Chrome Task Manager 中也不应继续残留:

txt
Dedicated Worker: blob:http://localhost:3000/...

14. 排查经验总结 ​

14.1 不要只看 Chrome Tab 总内存 ​

Tab 总内存高不等于 JS 泄漏。必须拆开看:

  • JS heap 是否下降。
  • scene buffer 是否归零。
  • GPU memory 是否异常。
  • Dedicated Worker 是否累积。

本次中后期最关键的证据就是:JS heap 已经降到约 400MB,但 Tab 仍然 1GB 到 2GB 以上,并且 Task Manager 下挂着 blob worker。

14.2 Three.js dispose 不等于 JS 引用断开 ​

geometry.dispose() 主要释放 GPU resource,不负责帮你断开所有 JS 引用。

如果 heap snapshot 里看到:

txt
BufferAttribute.array -> TypedArray

就要检查是否仍有 model / geometry / attribute 的引用链。

14.3 Worker 泄漏要看 Worker 生命周期,不要只看 Heap ​

Draco worker 是 blob worker。它不一定在 JS heap 里表现为一个大对象,但会在 Chrome Task Manager 里非常明显。

因此这类问题需要:

  • patch window.Worker 记录创建和 terminate。
  • 在 probe 中记录 active worker URL。
  • 用 Network Initiator 辅助定位创建来源。

14.4 快速切换模型一定要考虑 stale async ​

用户快速切换、关闭 modal、切换 task,都会制造 stale Promise。

可靠做法不是等 Promise 自己结束,而是:

  • 请求有 request id。
  • cache 有 epoch。
  • late result 必须 dispose。
  • loader / worker 必须可以在 cleanup 时统一扫尾。

14.5 第三方 ResourceManager 的 destroy 不一定真的 destroy ​

这次 @xviewer.js/core 的 ResourceManager.destroy() 只是清空 loader map。它并不知道 loader 内部还持有什么 native / worker 资源。

所以集成第三方库时,不能只看外层 destroy() 名字,还要看它是否调用了子资源的 dispose()。

15. 当前状态 ​

已经验证成立的结论:

  • Three 模型对象保留链存在,且已通过 deep dispose 和 cleanup 顺序修复。
  • JS heap 已能在手动 GC 后回落到约 400MB。
  • 大量 Dedicated Worker 是 Tab 总内存不降的主要嫌疑之一。
  • 自定义 loader 没有 dispose Draco worker 是明确代码问题。
  • 快速切换会触发 stale load,残留 worker 会出现在 HomeScene.ModelAssets:skipStale 附近。

已经实现但需要下一轮实际复测确认的结论:

  • 全局 patch DRACOLoader.prototype._getWorker 后,60dd91... / c1a66... 这类未登记 worker 应被统一清理。
  • 最终目标是 after-manual-gc.workerStats.activeBlobWorkers = 0。

这次排查的最大价值在于,它不是一次“看到内存高就到处 dispose”的修复,而是把每一轮怀疑都变成日志和截图证据,再让代码改动只针对已经被证明的保留链和生命周期漏洞。