大型工程模型并发加载:别只调 Promise.all,先做背压与取消

2 阅读3分钟

网页三维同时加载多个 BIM、机械装配或工厂模型时,把 URL 交给 Promise.all() 往往只是把问题推迟到后面的解码和 GPU 上传阶段。真正需要控制的不是“同时发多少个请求”,而是整条加载链路的工作集。

一、先把加载拆成四个阶段

建议至少分别观测:

  1. I/O:网络或本地文件读取;
  2. CPU:解压、解析、三角化和属性整理;
  3. GPU:buffer、texture、pipeline 与 bind group 准备;
  4. Publish:把完整结果切换进场景,并建立结构树、拾取和业务映射。

每个阶段都记录排队数、活动数、平均耗时和取消数。这样才能知道瓶颈在带宽、Worker、主线程还是 GPU 队列,而不是看到 FPS 下降就机械地把所有并发调小。

二、用背压控制下游预算

如果解码阶段不断生产结果,而 GPU 上传已经饱和,继续增加请求只会制造更长的等待队列。可以同时约束解码任务数和预计上传字节数:

class LoadBudget {
  constructor({ maxDecodeJobs, maxUploadBytes }) {
    this.maxDecodeJobs = maxDecodeJobs;
    this.maxUploadBytes = maxUploadBytes;
    this.decodeJobs = 0;
    this.uploadBytes = 0;
  }

  canDecode() {
    return this.decodeJobs < this.maxDecodeJobs;
  }

  canUpload(bytes) {
    return this.uploadBytes + bytes <= this.maxUploadBytes;
  }
}

工程模型还应该按视角和业务优先级调度:当前可见块、用户刚选中的构件和结构树展开对象优先;屏幕外的远距离 LOD、隐藏专业和预加载任务延后。优化目标是让当前工作集尽快进入可交互状态,而不是把整个文件一次性塞进显存。

三、取消必须贯穿网络、Worker 和 GPU 上传

用户切换项目后,上一批任务已经没有业务价值。只取消 fetch 不够,Worker 可能仍在解码,主线程也可能已经排队创建 GPU 资源。

为每次查看会话分配 generation,所有异步结果进入公共缓存或正式场景前都检查当前代际:

const generation = ++viewerGeneration;
const decoded = await decodeInWorker(source, signal);

if (signal.aborted || generation !== viewerGeneration) {
  decoded.dispose();
  return;
}

await uploadWithBudget(decoded, generation);

旧代际结果不能回写当前场景;已经创建的 buffer、texture、ImageBitmap 和临时数组要按所有权完整释放。共享纹理或材质则用引用计数,避免误删仍在使用的资源。

四、GPU 上传也要分帧

即使解码放进 Worker,大批 writeBuffer、纹理上传、pipeline 创建和场景挂载仍可能集中造成长任务。可以按每帧时间预算或字节预算分批提交,并在批次之间让出事件循环。

验收时同时记录:

  • 首个可见块和首个可交互视图的时间;
  • 每帧上传字节、长任务和最差帧时间;
  • 活动 Worker、等待上传的 CPU 数据与 GPU 资源数量;
  • 连续切换模型后,旧会话队列是否归零、资源基线是否稳定。

如果只统计文件下载完成时间,会漏掉用户真正感受到的等待和卡顿。

五、别让调度破坏对象身份

为了减少 Draw Call 而合批、为了省显存而切换 LOD,都不能让结构树节点、属性、选择和协同状态失去对象映射。源对象 ID、查看缓存 ID、GPU 索引和业务资产 ID 应分层管理;加载调度改变的是几何表示和当前工作集,不应该改变业务对象身份。

我们做 ModelView 时,把首次源格式转换、可复用查看缓存、分块/LOD、WebGPU 调度和稳定对象身份放在同一条工程链路。说明一下,我是项目开发者。Windows 标准版可以用真实 BIM、机械 CAD 或工业装配模型验证多模型加载与连续切换:

gitee.com/onedraw/mod…

参考: