Babylon.js一帧之旅(番外一):大模型加载实战——压缩、拆分与渐进式加载

「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 加载,离线部署时需自行配置):

typescript 复制代码
// 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",
    },
};

压缩在制作管线完成,不在运行时:

bash 复制代码
# 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。

typescript 复制代码
// 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)描述依赖关系:

json 复制代码
{
    "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 运行时按需加载

typescript 复制代码
// 首屏:只加载核心区
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

如果模型已经是一个文件,但首屏只需要其中几个网格:

typescript 复制代码
// 只导入指定名字的网格,其余不解析
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:手动多级加载(通用做法)

管线侧为每个模型导出低/高两个文件,运行时先低后高:

typescript 复制代码
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,远处画低层、近处画高层。

四、加载后的性能收尾

大模型进来只是第一步,让它跑得动还有几个标准动作(与第(五)(六)篇呼应):

typescript 复制代码
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 等)。动态物体不要冻结。

五、实战:完整的大模型加载管线

把全篇手段组合成一个生产级加载流程:

typescript 复制代码
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 一帧之旅」番外篇一,与正篇第(二)(五)(六)篇互为补充。

相关推荐
ttod_qzstudio21 小时前
Babylon.js 一帧之旅(四):相机系统——输入、视图矩阵与多相机
babylon
ttod_qzstudio1 天前
Babylon.js一帧之旅(十):实战——事件挂载点选择指南与性能优化
babylon