「Babylon.js 一帧之旅」番外篇。正篇第(二)篇讲过就绪机制:资源不就绪,帧循环整帧跳过。但当时我们回避了一个更现实的问题——如果资源本身就很大呢? 一个 200MB 的 glb 摆在面前,等待它的不是"就绪后渲染"的美好结局,而是漫长的白屏、浏览器崩溃和流失的用户。本篇系统讲解大模型加载的三大工程手段:压缩、拆分、渐进式加载。
引言:大 glb 的成本到底花在哪
在动手优化之前,先看清一个大文件从网络到屏幕的完整成本链:
网络下载(带宽 × 文件大小)
→ 解析与解压(CPU:glTF 解析、Draco/Meshopt 解码、纹理解码)
→ JS 堆内存(几何 ArrayBuffer、解码后的纹理位图)
→ 上传显存(顶点缓冲、纹理——KTX2 可以直接上传压缩格式)
→ 着色器编译(材质就绪,第(二)篇讲过,往往是最慢的一环)
→ 首帧渲染
Babylon.js 本身不设文件大小上限,真正的天花板是浏览器内存(桌面端 2GB 量级即为危险线,iOS Safari 更苛刻)。而且注意:解压后的内存占用远大于文件本身——glb 是打包格式,解压时一份数据可能同时躺在 JS 堆和显存里。
三大手段分别作用于这条链的不同环节:
| 手段 | 作用环节 | 效果 |
|---|---|---|
| 压缩(Draco/Meshopt/KTX2) | 网络、内存、显存 | 传输和解压后的体积同时缩小 |
| 拆分(多文件 + 按需加载) | 网络、首屏时间 | 首屏只下载必需部分 |
| 渐进式加载(LOD 流式) | 首屏时间、体验 | 先有画面,再逐步精细 |
一、压缩:把 200MB 变成 20MB 的正规军
1.1 几何压缩:Draco 与 Meshopt
glTF 生态有两个主流通用几何压缩方案,Babylon.js 都原生支持:
- Draco(Google):压缩率更高,解码稍慢,扩展名
KHR_draco_mesh_compression; - Meshopt(meshoptimizer):压缩率略低,解码极快,还为 GPU 做了顶点缓存优化,扩展名
EXT_meshopt_compression。
Babylon 侧的接入只需保证解码器可用(新版默认从 Babylon CDN 加载,离线部署时需自行配置):
// Draco 解码器配置(自建/CDN 皆可)
BABYLON.DracoCompression.Configuration = {
decoder: {
wasmUrl: "https://cdn.babylonjs.com/draco_wasm_wrapper_gltf.js",
wasmBinaryUrl: "https://cdn.babylonjs.com/draco_decoder_gltf.wasm",
fallbackUrl: "https://cdn.babylonjs.com/draco_decoder_gltf.js",
},
};
// Meshopt 解码器配置
BABYLON.MeshoptCompression.Configuration = {
decoder: {
url: "https://cdn.babylonjs.com/meshopt_decoder.js",
},
};
压缩在制作管线完成,不在运行时:
# gltf-transform(推荐,Node 生态)
npx @gltf-transform/cli optimize model.glb model.min.glb \
--compress draco --texture-compress ktx2
# 或 gltfpack(meshoptimizer 官方工具,输出 Meshopt 压缩)
gltfpack -i model.glb -o model.min.glb -cc -tc
1.2 纹理压缩:KTX2 是显存优化的关键
很多人只压几何不压纹理,结果文件小了、显存照样爆——因为 PNG/JPG 加载后必须解码成 RGBA 位图,显存占用 = 宽 × 高 × 4 字节 × 1.33(mipmap),与文件格式无关。一张 4K PNG 无论压到多小,显存里都占约 85MB。
KTX2/Basis Universal 改变了游戏规则:纹理以 GPU 原生压缩格式(ASTC/BC7 等)直接上传显存,不解码成位图。显存占用通常降到 1/4~1/8。
// KTX2 解码器配置(同样需要转码器,把中间格式转到目标 GPU 格式)
BABYLON.KhronosTextureContainer2.URLConfig = {
jsDecoderModule: "https://cdn.babylonjs.com/babylon.ktx2Decoder.js",
wasmUASTCToASTC: "https://cdn.babylonjs.com/uastc_astc.wasm",
wasmUASTCToBC7: "https://cdn.babylonjs.com/uastc_bc7.wasm",
// ...其余转码 wasm 按需配置
};
1.3 传输层压缩:gzip / Brotli
glTF 的 JSON 部分和未压缩几何对文本压缩很友好。服务器开启 gzip 或 Brotli,传输体积再降一档。注意:已用 Draco/KTX2 压缩的部分几乎不会再受益(高熵数据),所以这不是替代方案,而是补充。
压缩组合拳的经验值:原始 glb → Draco/Meshopt + KTX2 + Brotli,总体积降到 1/10 很常见;同时显存占用因 KTX2 再降一个量级。
二、拆分:首屏不需要整个世界
压缩有极限。当一个场景"再怎么压也有 100MB"时,思路要从"变小"转向"分而治之"——首屏根本不需要整个场景,只需要用户第一眼看到的东西。
2.1 按空间/部件拆分资产
制作侧把场景拆成多个 glb,配合一份清单(manifest)描述依赖关系:
{
"core": ["hero.glb", "lobby.glb"],
"zones": [
{ "id": "showroom", "file": "showroom.glb", "trigger": { "x": 50, "z": 0, "radius": 30 } },
{ "id": "garden", "file": "garden.glb", "trigger": { "x": -80, "z": 40, "radius": 40 } }
]
}
2.2 运行时按需加载
// 首屏:只加载核心区
await BABYLON.SceneLoader.AppendAsync("./assets/", "hero.glb", scene);
await BABYLON.SceneLoader.AppendAsync("./assets/", "lobby.glb", scene);
// 之后:按玩家位置异步加载邻近区域
const loadedZones = new Set<string>();
scene.onBeforeRenderObservable.add(() => {
for (const zone of manifest.zones) {
if (loadedZones.has(zone.id)) continue;
const d = BABYLON.Vector3.Distance(
player.position,
new BABYLON.Vector3(zone.trigger.x, 0, zone.trigger.z)
);
if (d < zone.trigger.radius) {
loadedZones.add(zone.id);
// 不 await——后台加载,不阻塞帧循环
BABYLON.SceneLoader.AppendAsync("./assets/", zone.file, scene);
}
}
});
注意一个时序细节(呼应第(二)篇):后台 Append 会让 scene.isReady() 再次变为 false,但已就绪的内容不受影响,画面不会冻结——只有新加载部分的材质就绪前,那部分网格不进入渲染。这正是拆分方案体验流畅的原因。
2.3 只导入需要的部分:ImportMesh
如果模型已经是一个文件,但首屏只需要其中几个网格:
// 只导入指定名字的网格,其余不解析
const result = await BABYLON.SceneLoader.ImportMeshAsync(
["chassis", "wheel_FL", "wheel_FR", "wheel_RL", "wheel_RR"], // 只要车身和轮子
"./assets/",
"car_full.glb",
scene
);
适合"同一个模型文件,不同页面用不同部分"的场景,避免为首屏解析整个大文件。
三、渐进式加载:先有画面,再求精致
拆分解决"加载谁",渐进式解决"先加载哪个版本"——同一个物体,先来低模撑住画面,高模后台替换。
3.1 方案 A:MSFT_lod 扩展(格式内建)
glTF 的 MSFT_lod 扩展允许在单个文件内按屏幕占比打包多级 LOD,加载器按覆盖屏幕面积自动切换。Babylon 原生支持该扩展,零代码获得渐进式体验——前提是制作管线支持导出。
3.2 方案 B:手动多级加载(通用做法)
管线侧为每个模型导出低/高两个文件,运行时先低后高:
async function loadProgressive(
name: string, scene: BABYLON.Scene, position: BABYLON.Vector3
) {
// 第一步:低模先行(几十 KB,秒出画面)
const low = await BABYLON.SceneLoader.ImportMeshAsync(
"", "./assets/", `${name}_low.glb`, scene
);
low.meshes.forEach(m => m.position.addInPlace(position));
// 第二步:高模后台加载,完成后无缝替换
BABYLON.SceneLoader.ImportMeshAsync("", "./assets/", `${name}_high.glb`, scene)
.then(high => {
high.meshes.forEach(m => {
m.position.addInPlace(position);
m.setEnabled(false); // 先藏好,等就绪
});
// 等高模材质真正就绪再切换(呼应第(二)篇的就绪机制)
scene.executeWhenReady(() => {
high.meshes.forEach(m => m.setEnabled(true));
low.meshes.forEach(m => m.dispose()); // 低模退场,释放内存
});
});
}
要点:切换时机挂 executeWhenReady 而不是 .then——.then 只代表数据解析完,高模的着色器可能还在编译,贸然切换会看到"闪现消失再出现"。
3.3 与第(五)篇的运行时 LOD 的关系
注意区分两个 LOD:加载期 LOD(本篇)解决"下载什么",渲染期 LOD(第(五)篇 addLODLevel)解决"画哪个"。两者可以叠加:渐进式加载完成后,高模自身仍带运行时 LOD,远处画低层、近处画高层。
四、加载后的性能收尾
大模型进来只是第一步,让它跑得动还有几个标准动作(与第(五)(六)篇呼应):
scene.executeWhenReady(() => {
// 静态场景:冻结活动网格列表,跳过每帧剔除重算
scene.freezeActiveMeshes();
// 静态网格:冻结世界矩阵,省掉每帧矩阵计算
scene.meshes.forEach(m => {
if (!m.metadata?.isDynamic) {
m.freezeWorldMatrix();
m.doNotSyncBoundingInfo = true;
}
});
// 材质不再变化:冻结材质,跳过每帧 dirty 检查
scene.materials.forEach(mat => mat.freeze());
scene.blockMaterialDirtyMechanism = true;
});
注意:冻结是承诺——冻结后再动这些对象需要对应的解冻(unfreezeWorldMatrix 等)。动态物体不要冻结。
五、实战:完整的大模型加载管线
把全篇手段组合成一个生产级加载流程:
async function loadLargeScene(canvas: HTMLCanvasElement) {
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
// ① 加载 UI 与超时兜底(第(二)篇的机制)
engine.displayLoadingUI();
scene.onReadyTimeoutDuration = 60000;
scene.onReadyTimeoutObservable.addOnce(() => {
showError("加载超时,请检查网络后刷新");
});
// ② 配置解码器(压缩资产的前提)
configureDecoders(); // Draco / Meshopt / KTX2,见前文
// ③ 首屏资产:低模 + 核心区,带进度
const progressEl = document.getElementById("progress")!;
let loaded = 0;
const firstScreen = ["hero_low.glb", "lobby.glb"];
for (const file of firstScreen) {
await BABYLON.SceneLoader.AppendAsync("./assets/", file, scene, (e) => {
if (e.lengthComputable) {
progressEl.textContent =
`首屏资源 ${loaded + 1}/${firstScreen.length}:` +
`${Math.floor((e.loaded / e.total) * 100)}%`;
}
});
loaded++;
}
// ④ 首屏就绪:关 loading,启动渲染——用户此刻已看到画面
await scene.whenReadyAsync();
engine.hideLoadingUI();
engine.runRenderLoop(() => scene.render());
// ⑤ 后台渐进:高模替换 + 邻近区域按需加载
upgradeToHighPoly("hero", scene);
startZoneStreaming(scene);
// ⑥ 全部就绪后做性能收尾
scene.executeWhenReady(() => optimizeStaticScene(scene));
return { engine, scene };
}
这个流程的体验节奏:第 1~2 秒低模画面出现(loading 结束)→ 随后几秒高模无缝替换 → 探索过程中新区域无感加载。用户从"盯着进度条"变成"已经在场景里"。
六、常见误区
误区一:只压几何不压纹理。
文件确实小了,但 PNG/JPG 进显存一律解码成 RGBA 位图,显存照样爆。纹理必须上 KTX2。
误区二:以为 gzip 能替代 Draco。
传输压缩不解压后的内存问题:Draco 减小的是解码后顶点数据的体积,gzip 只压缩传输。两者解决不同环节,都需要。
误区三:高模 .then 里立刻替换低模。
数据解析完 ≠ 着色器编译完。切换挂 executeWhenReady,否则会看到模型短暂消失。
误区四:后台加载时认为"场景不就绪 = 画面冻结"。
只有新加载部分延迟渲染,已就绪内容照常绘制——这正是流式加载可行的基础。
误区五:低模加载后不 dispose。
渐进替换完成后,低模必须 dispose() 释放内存,否则"渐进"变成了"双份"。
误区六:拆得越碎越好。
每个文件都有网络往返、解析、材质编译的固定开销,几十 KB 一个的碎片文件会拖垮加载。经验上单个资产包保持在 2~15MB 区间,按"空间区域 + 使用时机"划分,而不是按物体。
七、小结
- 大模型加载的成本链:下载 → 解压 → JS 堆 → 显存 → 着色器编译,优化要逐环对症;
- 压缩:几何用 Draco/Meshopt,纹理用 KTX2(显存优化的真正关键),传输层 gzip/Brotli 兜底;
- 拆分:manifest + 按需 AppendAsync,首屏只加载必需区域,已就绪内容不受后台加载影响;
- 渐进式:MSFT_lod 或手动低模先行,高模替换的时机挂
executeWhenReady; - 收尾:静态内容 freeze 三件套(activeMeshes / worldMatrix / materials);
- 体验目标一句话:让用户先看到,再看好。
本篇为「Babylon.js 一帧之旅」番外篇一,与正篇第(二)(五)(六)篇互为补充。
:大模型加载实战——压缩、拆分与渐进式加载&spm=1001.2101.3001.5002&articleId=164358260&d=1&t=3&u=8122dbe616aa433a9969f8cbaf8f90e0)

被折叠的 条评论
为什么被折叠?



