Skip to main content

模型加载

在真实的 Web3D 项目中(如智慧城市、汽车展厅、Web 游戏或 Spline 等 3D 编辑器应用),绝大部分模型都是由建模师在 Blender、Maya 中制作好,再导出交由前端加载渲染的。

如何高效加载模型,并在加载后对其进行材质替换、动画提取、结构解析,是高级前端图形开发的核心技能。


1. 主流模型格式分类与选型

  • GLTF / GLB(Web3D 的“JPEG”)
    • 特点:现阶段绝对的行业标准。它不仅仅是模型,而是一整个场景的描述。它可以包含网格(Mesh)、材质(PBR)、骨骼动画(Skeleton)、形变动画(Morph Targets)、灯光甚至相机。
    • GLB 是其二进制格式,将贴图、Buffer 数据全部打包在一个文件内,体积更小,网络传输更优。
    • 选型建议:实际商业项目中,95% 以上的情况应首选 GLB
  • OBJ & MTL
    • 特点:古老的纯文本格式。OBJ 只存顶点、法线、UV 坐标,必须配合 MTL 文件才能引入材质贴图。
    • 缺点:文件体积庞大(解析慢),不支持任何动画,不支持现代 PBR 材质(只支持传统 Phong 光照模型)。
    • 选型建议:仅在极简静态模型,或对接老旧工业软件导出数据时使用。

2. 核心代码示例:如何在项目中加载模型?

场景一:加载带压缩的 GLB 模型 (GLTFLoader + DRACOLoader)

为了极大降低模型体积,建模师通常会使用 Draco 算法对模型进行压缩。前端在加载时必须挂载 Draco 解码器(通过 WebAssembly 加速解码)。

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";

// 1. 初始化 Loader
const gltfLoader = new GLTFLoader();

// 2. 配置 DRACO 解码器 (WASM 路径通常放在 public 目录下)
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("/draco/gltf/");
gltfLoader.setDRACOLoader(dracoLoader);

// 3. 异步加载模型
gltfLoader.load(
"/models/sports_car.glb",
(gltf) => {
const model = gltf.scene;

// 将模型加入场景
scene.add(model);

// 如果模型带动画,提取并播放
if (gltf.animations.length > 0) {
const mixer = new THREE.AnimationMixer(model);
const action = mixer.clipAction(gltf.animations[0]);
action.play();
// 别忘了在 requestAnimationFrame 中调用 mixer.update(delta)
}
},
(xhr) => {
// 进度回调,可用于实现 Loading 进度条
console.log(`加载进度: ${(xhr.loaded / xhr.total) * 100}%`);
},
(error) => {
console.error("模型加载失败", error);
},
);

场景二:加载老旧的 OBJ + MTL

由于 OBJ 和 MTL 是分离的,必须先加载材质,再将材质传给模型加载器

import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js";

const mtlLoader = new MTLLoader();
mtlLoader.setPath("/models/"); // 设置材质贴图的相对路径

mtlLoader.load("building.mtl", (materials) => {
// 1. 预处理材质
materials.preload();

// 2. 将材质挂载到 OBJLoader
const objLoader = new OBJLoader();
objLoader.setMaterials(materials);
objLoader.setPath("/models/");

// 3. 加载对应的 OBJ 模型
objLoader.load("building.obj", (object) => {
scene.add(object);
});
});

3. 编辑器实战:模型加载后的“二次加工”

在类似 Spline、PlayCanvas 等在线编辑器,或者“汽车换装展厅”业务中,把模型加载出来仅仅是第一步。我们通常需要遍历模型的层级结构(Scene Graph)进行二次修改:

实战操作:模型遍历与材质替换(汽车换车漆换轮毂)

建模师导出的模型往往是一个层级极深的 Group。我们可以利用 traverse 递归遍历,根据节点的名称(Name)来精准寻找并替换材质。

gltfLoader.load("/models/car.glb", (gltf) => {
const carModel = gltf.scene;

// 递归遍历模型的所有子节点
carModel.traverse((child) => {
// 判断当前节点是否是网格模型 (Mesh)
if (child.isMesh) {
// 1. 开启阴影投射和接收(GLTF 默认不开启阴影)
child.castShadow = true;
child.receiveShadow = true;

// 2. 根据建模师命名的节点名字,精准替换材质
if (child.name === "CarBody") {
// 汽车外壳:替换成红色的车漆材质 (高光感 PBR)
child.material = new THREE.MeshPhysicalMaterial({
color: 0xff0000,
metalness: 0.8,
roughness: 0.1,
clearcoat: 1.0, // 模拟车漆清漆层
});
} else if (child.name.includes("Wheel")) {
// 轮毂:替换为暗色金属材质
child.material = new THREE.MeshStandardMaterial({
color: 0x333333,
metalness: 1.0,
roughness: 0.5,
});
}

// 3. 释放不需要的原有材质,避免内存泄漏
// 假设原材质不再使用,需调用 dispose
if (child.material.dispose) {
// 注意:实际项目中需判断该材质是否被其他网格复用
}
}
});

scene.add(carModel);
});

4. 性能优化手段

  1. Draco 几何体压缩:通过 WASM 极速解压,模型体积缩减至 1/5,极大提升网络加载速度。
  2. 纹理压缩 (Basis / KTX2):将 PNG/JPG 转换为 GPU 直接支持的压缩格式。不仅减小了下载体积,更重要的是彻底解决了图片解压到 GPU 时导致的显存爆炸问题
  3. 按需加载与 LOD:对于庞大的开放世界,优先加载玩家视野近处的精细模型,远处的建筑使用低模或者延迟加载(配合 requestIdleCallback 避免阻塞主线程)。

🔬 Draco 原理深挖:1/5 体积是怎么"挤"出来的?

Draco 的 1/5 不是"压缩率高",而是"本来就存了太多冗余"——它靠三层算法叠加把这些冗余一层层剥掉。

1️⃣ 顶点量化(Quantization):体积减半的最大功臣

原始顶点位置 (Float32):  (1.234567, -0.876543,  2.345678)    12 字节
↓ 量化到 [-1, 1] 区间
量化后 (Int16): ( 12345, -8765, 23456) 6 字节 (-50%)
↓ 还能再压
极端量化 (Int8): ( 123, -88, 234) 3 字节 (-75%)

为什么能这么干? 3D 模型顶点的精度需求远低于 Float32

  • 屏幕显示最多 2K×2K = 8M 像素
  • 每个像素 0.001 精度就足够
  • Float32 的小数点后 6 位是浪费

精度损失:量化会引入 ±0.0001 量级误差,但人眼基本看不出(除非做精密测量/CAD)。

2️⃣ Edge Breaker:连接关系的"链式编码"

传统 GLTF 存法:每个三角面独立存 3 个顶点索引

原始: [F1: v1,v2,v3] [F2: v1,v3,v4] [F3: v1,v4,v5] ...
每个面 = 3 × 4 字节 = 12 字节索引
1 万个面 = 120 KB

Draco 用 Edge Breaker 算法——从某个起始面开始,每加一个新面只记录"它和前面共享哪条边"(5 种符号:C/L/R/E/S)。

编码: F1 → C → R → C → L → S → C → L ...
每符号 1 bit
1 万个面 ≈ 10 KB

效果:连接关系体积缩减到 1/10

3️⃣ 熵编码(Entropy Coding):最终压一遍

用类似 Brotli / Zstd 的算法,对量化值 + Edge Breaker 符号流做最终压缩。这一步再减 20%~30%

📊 综合效果(10000 顶点 + 20000 面的典型模型)

数据原始 GLTFDraco缩减比
顶点位置(Float32 × 3)120 KB24 KB1/5
三角面索引(Uint32 × 3)240 KB24 KB1/10
法线 / UV~50 KB~10 KB1/5
总计~410 KB~70 KB≈ 1/6

💡 关键认知:Draco 的 1/5 不是"压缩率高",是"本来就存了太多冗余"——Float32 精度过剩、独立面片不共享、文本格式浪费空格……这些冗余被 Draco 一层一层剥掉。


🎨 Basis vs KTX2:不是同一个东西,但 KTX2 装 Basis

这俩经常一起出现Basis / KTX2),但根本不是并列方案。先分开看,再看它们的关系。

1️⃣ KTX2 = 容器格式(Khronos Texture 2)

本质:一个能装各种 GPU 纹理格式的标准容器(类比 ZIP,但专门给纹理用)。

KTX2 文件结构:
┌────────────────────────────┐
│ Header (元数据) │ ← 宽/高/格式/Mipmap 数量
├────────────────────────────┤
│ Level 0 (最大图) │ ← GPU 原生数据 (BC1/BC7/ETC2/ASTC...)
│ Level 1 (1/2 大小) │
│ Level 2 (1/4 大小) │ ← Mipmap 链
│ ... │
└────────────────────────────┘

关键特性一个 KTX2 文件 = 一张 GPU 可直接上传到显存的纹理(无需 CPU 解压)。

2️⃣ Basis Universal = 中间压缩算法

本质:把任意 PNG/JPG 压缩成"小中间格式",运行时在 GPU 着色器里实时解压到目标 GPU 格式。

PNG/JPG (原始, 大)
↓ basisu 工具压缩
.basis 文件 (中间格式, 小)
↓ GPU 着色器运行时解压
BC7 / ETC2 / ASTC (GPU 原生格式)

关键特性一个 Basis 文件 → 适配所有硬件平台(PC/Android/iOS 都能解压)。

3️⃣ 关键区别对照

维度KTX2Basis Universal
本质容器格式(装 GPU 格式)压缩算法(产生中间格式)
输入已经是 GPU 格式的纹理任意 PNG/JPG
输出GPU 可直接上传需着色器实时解压
跨平台需生成多个文件(每平台一个)一个文件自适应所有平台
运行时gl.texImage2D() 直接上传basisu WASM + GPU 解码着色器
生成工具toktx (KTX-Software)basisu (Binomial)
glTF 扩展KHR_texture_basisu 容器KTX2 v2 内部装 Basis 编码

4️⃣ 工业界现状:KTX2 已"吸收" Basis

它俩已经合并成一个东西了

  • KTX2 v2 标准支持 Basis Universal 作为内部压缩算法
  • glTF 2.0 的 KHR_texture_basisu 扩展 = KTX2 容器 + Basis 编码
  • 工具链gltf-transform 一键把 PNG 转 KTX2(内部用 Basis 编码)
实际项目流程:

PNG 纹理资产
↓ gltf-transform optimize (调用 basisu)
单文件 .ktx2 (内部 = Basis Universal 压缩 + GPU 格式头)
↓ THREE.KTX2Loader 加载
GPU (自动选 BC7 / ETC2 / ASTC)

💡 关键认知实际项目里你看到的 .ktx2 文件,内部大概率是 Basis Universal 压缩。所以"Basis / KTX2"经常并列出现——是一个东西(KTX2 容器装 Basis 编码),不是两个并列方案。

5️⃣ 为什么说"彻底解决了显存爆炸"?

PNG 流程(问题):

PNG 文件 (1 MB)
↓ CPU 解码(ImageDecoder / Canvas) ← 临时占用 4-8 MB 内存
Raw RGBA 像素 (4 MB)
↓ 上传到 GPU
GPU 显存 (4 MB 同样大小)

结果:CPU 内存 + GPU 显存 同时占用,1 MB 图像峰值临时占用 8-12 MB(100 张图就是 GB 级)。

KTX2/Basis 流程(解法):

.ktx2 文件 (200 KB)
↓ GPU 直接读取上传(无需 CPU 解码) ← CPU 零内存占用
GPU 显存 (200 KB 压缩格式,GPU 内部自动解压)

结果:CPU 不占内存,GPU 显存只占原始 PNG 的 1/10 不到

这就是"GPU 直接支持的压缩格式"的真正含义——连 CPU 解码这一步都省了。PNG 之所以"显存爆炸",是因为 CPU 必须先解码出未压缩的 RGBA 才能上传,这个中间产物是元凶。