模型加载
在真实的 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. 性能优化手段
- Draco 几何体压缩:通过 WASM 极速解压,模型体积缩减至 1/5,极大提升网络加载速度。
- 纹理压缩 (Basis / KTX2):将 PNG/JPG 转换为 GPU 直接支持的压缩格式。不仅减小了下载体积,更重要的是彻底解决了图片解压到 GPU 时导致的显存爆炸问题。
- 按需加载与 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 面的典型模型)
| 数据 | 原始 GLTF | Draco | 缩减比 |
|---|---|---|---|
| 顶点位置(Float32 × 3) | 120 KB | 24 KB | 1/5 |
| 三角面索引(Uint32 × 3) | 240 KB | 24 KB | 1/10 |
| 法线 / UV | ~50 KB | ~10 KB | 1/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️⃣ 关键区别对照
| 维度 | KTX2 | Basis 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 才能上传,这个中间产物是元凶。