在浏览器里渲染一个精细的3D模型从来不是难事,难的是把它快速送到用户眼前。一个工业级CAD模型或者高精度扫描 mesh 动辄几百MB,如果按照传统方式等整个文件下载完再解析渲染,用户面对的可能就是长达半分钟的白屏。流式传输与渐进式加载就是为了解决这个问题而生的:让用户在第一秒就能看到一个低精度但完整的模型轮廓,随后细节一点点补全,整个过程用户始终有东西可看、有东西可交互。

渐进式加载的核心原理与数据格式选择
渐进式加载的本质是把一个大的几何数据切分成多个层级或者多个分块,按需或者按优先级逐步传输。业界主要有两条技术路线:一是渐进式网格,即模型文件本身被组织成基础网格加一系列细化补丁的结构,基础网格很小,补丁按顺序叠加后精度逐步提升;二是空间分块,把大模型按空间区域或八叉树切成小块,前端根据相机位置只加载视野内的分块。
在数据格式上,glTF 几乎是目前 Web 3D 的事实标准,它对渐进式加载有天然的支持。glTF 支持 buffer 分离,几何数据可以放在外部 .bin 文件甚至拆成多个二进制块;再配合 EXT_meshopt_compression 或 Draco 压缩扩展,单个分块体积可以压缩到原来的十分之一左右。另一条路线是自建二进制格式,用魔数加版本号开头,后面紧跟层级表和数据块偏移量,前端收到前几KB就能开始渲染基础层。自建格式灵活但要自己处理兼容性,除非有特殊需求,优先推荐 glTF 生态。
需要强调的一点是,渐进式加载不只是网络层的事情。如果低精度层和细化层是两个独立的 mesh,前端在切换时要做几何过渡,否则用户会看到模型跳变。Three.js 里可以在加载细化层后对两个 mesh 做短暂的交叉淡化,或者直接用顶点着色器做位移插值,视觉上会平滑得多。
用 Three.js 实现分层加载与流式渲染
下面通过一个具体例子演示分层加载的流程。假设模型被预处理成了三个精度层级:L0 是低模大约 50KB,L1 是中模约 2MB,L2 是完整精度约 40MB。加载策略是立刻拉取 L0 渲染,同时在后台用 fetch 发起 L1 和 L2 的请求,每完成一层就替换或叠加几何体。
// 使用 fetch 流式读取 glTF 二进制分块
async function progressiveLoad(baseUrl, scene) {
// 第一层:低精度模型,体积小,秒开
const lod0 = await loadGLTF(baseUrl + '/model_lod0.gltf');
const mesh = lod0.scene.children[0];
scene.add(mesh);
// 第二层和第三层并行请求,不阻塞首屏
loadGLTF(baseUrl + '/model_lod1.gltf').then(lod1 => {
swapGeometry(mesh, lod1.scene.children[0].geometry);
});
loadGLTF(baseUrl + '/model_lod2.gltf').then(lod2 => {
swapGeometry(mesh, lod2.scene.children[0].geometry, true);
});
return mesh;
}
// 平滑替换几何体,避免视觉跳变
function swapGeometry(mesh, newGeometry, isFinal = false) {
const oldGeometry = mesh.geometry;
mesh.geometry = newGeometry;
oldGeometry.dispose(); // 释放旧几何体的显存
}这段代码有几个细节值得注意。首先是 dispose 的调用,三层模型来回替换时如果不及时释放旧几何体,显存会迅速膨胀,在移动端直接导致上下文丢失。其次是加载顺序,L1 和 L2 虽然是并行发起的,但浏览器对同一域名的并发连接有限制,如果同域还有纹理在下载,建议给高精度层设置较低的优先级,比如等首屏纹理加载完再发起 L2 请求。
对于真正的大文件,还可以配合 ReadableStream 做分块解析。glTF 的二进制容器格式前 12 字节是头部,包含 JSON 块长度信息,前端拿到头部后就能先解析出节点结构,等几何数据块陆续到达再逐个上传 GPU。这种边下边解析的方式能把首屏时间进一步压缩到几百毫秒级别,代价是需要自己写解析器或者魔改 loader,工程量不小,适合对性能有极致要求的团队。
LOD 策略、断点续传与服务端配合
渐进式加载落地时,服务端的配合同样关键。第一件事是开启 HTTP Range 请求支持,这样前端可以按字节区间拉取模型的指定分块,配合 Service Worker 还能实现断点续传:用户中途关掉页面,下次打开时从本地缓存已经下载的部分继续,而不是从头再来。第二件事是 CDN 缓存策略,模型文件是静态资源,务必设置长缓存加内容哈希文件名,避免每次发布都让用户重新下载几十MB。
// 利用 Range 请求按需拉取指定分块
async function fetchChunk(url, start, end) {
const resp = await fetch(url, {
headers: { 'Range': `bytes=${start}-${end}` }
});
if (resp.status !== 206) {
throw new Error('服务器不支持 Range 请求');
}
return await resp.arrayBuffer();
}
// 简单的视锥剔除式分块调度
function updateChunks(camera, chunkMap) {
for (const [id, chunk] of chunkMap) {
const visible = frustumIntersects(camera, chunk.boundingBox);
const dist = camera.position.distanceTo(chunk.center);
// 视野内且距离近的分块优先加载高精度版本
if (visible && dist < 50) {
chunk.requestLOD(2);
} else if (visible) {
chunk.requestLOD(1);
} else {
chunk.unloadHighLOD(); // 视野外的分块释放高精度几何体
}
}
}视锥剔除式调度是空间分块方案的核心:相机看到哪里就加载哪里,离得近就换高精度,离得远或者转到视野外就降级甚至卸载。这套逻辑在数字孪生、BIM 这类超大场景里几乎是标配。实现时要注意分块粒度的取舍,切得太碎会导致请求数暴增,切得太大又失去了按需的意义,一般以单块压缩后 200KB 到 1MB 之间比较合适,具体可以通过对目标模型的统计分析来确定。
最后做一个策略对比。分层替换方案实现简单,首屏体验好,适合模型总量在 100MB 以内的场景;空间分块方案工程复杂度高,但能支撑 GB 级别的超大规模场景,且带宽随视角动态变化,节省流量;纯流式解析方案首屏最快但开发和维护成本最高。实际项目中常常是混合使用:整体用分层加载保证首屏,局部用分块加视锥剔除控制带宽,再把 Draco 压缩和 HTTP/2 多路复用叠上去,基本就能做到大模型秒开、细节渐进补全的体验。选型时先评估模型的规模和用户的网络环境,再决定投入多少工程量,切莫为了炫技而过度设计。