1. 模型加载这件事,难的不是代码而是链路
做到第十篇,终于绕不开外部模型了。前面九篇我们折腾的都是几何体、材质、灯光、相机,顶多拿BoxGeometry拼个桌子出来。但真正进项目以后你会发现,场景里 90% 的可见物体都不是代码写出来的,而是美术在建模软件里做完导出、前端把文件拿过来塞进场景。这一步的关键词就两个:Three.js 加载 gltf 模型,和加载压缩后的模型。前者是入门必经,后者是上线必经——我见过太多项目在本地跑得好好的,一上线首屏要等十几秒,原因就是那个 40MB 的 glb 文件原封不动地扔进了生产环境。
这篇就聊这两件事。前半段是"怎么把模型正确加载进来",后半段是"怎么把模型压小、再加载进来"。中间还会穿插解码器的部署、纹理压缩的取舍、显存怎么算、加载完不释放导致的内存泄漏怎么查。
适合谁来读?如果你已经能用 Three.js 搭出一个带光照和轨道的空场景,正在准备往里面放第一个真实模型,那这篇就是给你写的。如果你已经在放模型了,但移动端卡、首屏慢、切场景几次之后标签页就崩,那后半段压缩和内存那两节会更对味。代码我全部给到可直接抄的程度,参数和目录结构也会说清楚为什么这么放,不会只丢一段loader.load(url, ...)就完事。
先给个全局认知:模型加载这条链路上其实有三道坎,格式对不对、体积大不大、解码器有没有就位。绝大多数的报错和卡顿,最后都能归到这三道坎里的某一道。后面每一节,我们基本都在拆这三道坎。
2. 先把链路想明白:格式、体积、解码器
2.1 glTF 为什么成了事实标准,以及 glb 和 gltf 的区别
建模软件导出格式一大堆,fbx、obj、dae、stl、ply,为什么最后大家默认选 glTF?原因很朴素:它是为实时渲染设计的,结构里直接带了 PBR 材质参数、节点层级、骨骼动画、相机、甚至扩展机制,几乎能和渲染管线一一对应。obj 只带几何和一张贴图,材质全靠猜;fbx 是私有二进制,解析库体积大、行为还不一致。glTF 的设计目标就是"3D 界的 JPEG",解析器轻、语义清晰、生态统一。
它有两种落盘形式,一定要分清:
.gltf+ 外部资源:一个 JSON 主文件,配一堆.bin、.png、.jpg。可读性好,方便 diff,适合调试和中间产物。.glb:把 JSON、二进制缓冲、贴图全部打进一个容器,头部是 12 字节的 magic + version + length,后面跟着若干个 chunk(JSON chunk 和 BIN chunk)。这是线上交付的标准形态,一次请求搞定,不用管相对路径。
我个人的习惯是:开发阶段用 gltf 看结构,交付阶段一律转 glb。因为 gltf 一旦贴图路径写错、或者静态服务器把.bin的 MIME 类型配错了,你会在浏览器里得到一个非常抽象的报错,排查半天最后发现是application/octet-stream没配。glb 直接规避了这类问题。
顺带提一个容易忽略的点:glTF 的单位是米,Y 轴向上。Three.js 也是 Y 轴向上,所以直接加载通常没问题。但如果模型在导出时被缩放成 0.01 或者 100 倍,你加载进来会看到一个芝麻大的点或者一间把你相机包住的房子,别急着怀疑加载器,先去看看模型的包围盒。
2.2 压缩到底压了什么:几何、索引、纹理三层拆开看
提到"压缩模型",很多人的第一反应是"把文件压小"。但 3D 模型的文件体积来源和普通资源完全不一样,得拆开看,因为每一层用的技术和加载时的配置都不同。
| 数据层 | 典型占比 | 压缩手段 | 加载时需要什么 | 压缩后还能还原吗 |
|---|---|---|---|---|
| 顶点属性(位置/法线/UV) | 20%~40% | Draco 量化 + 熵编码、Meshopt 顶点编码 | 对应解码器 wasm | 有损,量化位数决定误差 |
| 索引缓冲 | 5%~15% | Draco 索引压缩、Meshopt 索引编码 | 同上 | 无损 |
| 纹理贴图 | 40%~70% | KTX2(ETC1S / UASTC)、WebP | KTX2 转码器 wasm | 有损(ETC1S)或近无损(UASTC) |
| 动画关键帧 | 视情况 | 量化 + 采样降频 | 无额外依赖 | 有损 |
看懂这张表,很多疑惑就通了。为什么我用了 Draco 模型还是很大?因为 Draco 只压几何,纹理它一点没动,而纹理往往才是体积大头。为什么我用了 KTX2 纹理,模型加载反而变慢了?因为纹理压缩减少的是传输体积,但解码成 GPU 可用的格式需要 CPU 转码,转码本身要时间,这是笔要先付的账。
还有一个概念必须先钉死:传输体积不等于显存占用。一张 4096×4096 的 RGBA8 贴图,解压后是4096 × 4096 × 4 = 67,108,864字节,约 64MiB 显存,跟你文件是 200KB 还是 8MB 毫无关系。所以优化模型体积只解决了"下载慢",显存问题是另一个战场,第五节专门讲。
注意:Draco 和 Meshopt 是二选一的竞争关系,同一个模型上同时套两种几何压缩没有意义,加载器也只会认其中一个扩展。而 KTX2 是和它们正交的,几何压缩 + 纹理压缩可以同时用。
2.3 一个决策顺序:先砍数据,再上压缩
我踩过最蠢的一个坑,是拿到一个 60MB 的模型直接上 Draco 压到 18MB,然后觉得"优化完了",结果美术一看说这模型本来就有 300 万个面——真正的解法是减面,不是压缩。所以顺序应该是这样的:
- 清理:删掉场景里不可见的物体、合并重复材质、去掉没用的 UV 通道和顶点色。
- 减面 / 减纹理分辨率:把 4K 贴图降到 2K 往往肉眼看不出差别,体积直接砍 75%。
- 量化:位置降到 14 位、法线降到 10 位、UV 降到 12 位,这一步几乎无感但收益很大。
- 几何压缩:Draco 或 Meshopt。
- 纹理压缩:KTX2 或 WebP。
前面几步是"少传数据",后面两步是"把数据压得更狠"。如果你跳过前三步直接做后两步,压缩率会很难看,因为熵编码对已经冗余的数据无能为力。这个顺序要记住,比记住任何一条命令都有用。
3. 环境与静态资源:解码器放错位置是最常见的翻车点
3.1 目录规划:为什么我坚持把解码器放 public 根目录
先把最容易出事的地方说掉:解码器文件你不是用import引入的,而是用 URL 路径运行时去 fetch 的。这意味着打包工具管不了它,必须让它在最终产物里能以确定的路径被访问到。
所以我通常这么组织:
project/ ├── public/ │ ├── draco/ # Draco 解码器 │ │ ├── draco_decoder.js │ │ ├── draco_decoder.wasm │ │ ├── draco_encoder.js │ │ └── draco_wasm_wrapper.js │ ├── basis/ # KTX2 转码器 │ │ ├── basis_transcoder.js │ │ └── basis_transcoder.wasm │ └── models/ │ ├── robot.glb │ └── room_compressed.glb └── src/ └── main.js为什么要放public(Vite 里叫 public,其他构建工具叫 static/assets 之类)?因为这里的文件会被原样拷到产物根目录,不参与 hash 改名。你在代码里写'/draco/',线上就是https://你的域名/draco/,路径稳定。
反例是把解码器塞进src/assets/然后import dracoWasm from './assets/draco/draco_decoder.wasm?url'再传给setDecoderPath。这条路能走通,但setDecoderPath后面还要拼draco_decoder.wasm这种固定文件名,你传个文件名进去反而对不上。老老实实放 public 目录,用目录路径,不要自作聪明。
这些文件从哪来?在你的node_modules/three/examples/jsm/libs/draco/下面就有现成的,直接整目录拷贝到public/draco/。KTX2 的在node_modules/three/examples/jsm/libs/basis/。别去别处下载,版本对不上会出现很诡异的解码失败。
3.2 DRACOLoader 的三种解码配置与选择依据
DRACOLoader支持三种解码方式,选错了会直接影响兼容性和启动时间:
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js' const dracoLoader = new DRACOLoader() dracoLoader.setDecoderPath('/draco/') // 默认是 'wasm',优先用 WebAssembly 解码 dracoLoader.setDecoderConfig({ type: 'wasm' }) // 低配环境可以退回纯 JS,代价是解码慢 3~5 倍 // dracoLoader.setDecoderConfig({ type: 'js' }) // 提前把解码器下载好,避免首次加载模型时才去拉 wasm dracoLoader.preload()三档的取舍:
wasm:默认档,性能最好,现代浏览器全支持。draco_decoder.wasm大概三百来 KB,preload()之后会在空闲时段先下好。js:draco_decoder.js体积更大、解析更慢,但有兜底价值。如果你的用户群里有相当比例的老旧设备或者内嵌 WebView,可以考虑做降级。preload()不加参数:它会读setDecoderConfig的配置去预取对应文件。我强烈建议加上,尤其是首屏只有一两个模型、但用户交互后才加载的场景——你省下的那几百毫秒解码器下载时间,恰好落在用户"正在等模型出现"的焦虑区间里。
再强调一个细节:DRACOLoader实例是可以复用的,而且应该复用。每次new GLTFLoader()都配一个全新的DRACOLoader,解码器的 wasm 模块会被重复初始化,内存里就多一份。我的做法是在模块顶层创建一个单例:
// loaders/gltf.js —— 全局共享的加载器工厂 import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js' import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js' import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js' let loaderPromise = null export function getGLTFLoader(renderer) { if (loaderPromise) return loaderPromise loaderPromise = (async () => { const draco = new DRACOLoader() draco.setDecoderPath('/draco/') draco.preload() const ktx2 = new KTX2Loader() .setTranscoderPath('/basis/') .detectSupport(renderer) const loader = new GLTFLoader() loader.setDRACOLoader(draco) loader.setKTX2Loader(ktx2) loader.setMeshoptDecoder(MeshoptDecoder) return loader })() return loaderPromise }注意MeshoptDecoder不需要配置路径,它是以 ES module 形式直接 import 进来的,内部自己处理 wasm 的加载。这是它比 Draco 省心的地方之一。还要注意KTX2Loader必须拿到renderer做能力探测,因为它需要知道当前 GPU 支持哪种压缩纹理格式,再决定把 KTX2 转码成什么。这意味着 KTX2Loader 和 renderer 是强绑定的,多 renderer 的场景(比如主视图 + 缩略图预览)不能共用同一个。
4. 把模型正确加载进来:从最小可用到工程化
4.1 最小可跑通的一段代码
先别急着上压缩,用一个未压缩的小 glb 把流程走通,这是排查问题的基准线。我给一段完整可运行的:
import * as THREE from 'three' import { getGLTFLoader } from './loaders/gltf.js' const renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(window.innerWidth, window.innerHeight) document.body.appendChild(renderer.domElement) const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000) camera.position.set(3, 2, 5) scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2)) const dirLight = new THREE.DirectionalLight(0xffffff, 2) dirLight.position.set(5, 10, 5) scene.add(dirLight) const loader = await getGLTFLoader(renderer) loader.load( '/models/robot.glb', (gltf) => { const model = gltf.scene scene.add(model) // 关键一步:让相机自动框住模型,不然很容易看不到东西 const box = new THREE.Box3().setFromObject(model) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) const dist = maxDim / (2 * Math.tan((camera.fov * Math.PI) / 360)) camera.position.copy(center).add(new THREE.Vector3(dist * 1.5, dist, dist * 1.5)) camera.lookAt(center) console.log('模型尺寸(米):', size.toArray()) }, (event) => { if (event.lengthComputable && event.total > 0) { console.log('进度:', ((event.loaded / event.total) * 100).toFixed(1) + '%') } }, (err) => { console.error('加载失败:', err) } )这段里有个小技巧值得单独拎出来说:自动取景。我第一次加载模型时花了十分钟找问题,最后发现模型确实加载了,只是尺寸有 500 米,相机正好在它内部。后来我养成了习惯,加载完立刻算包围盒、算合适距离、把相机挪过去。公式是距离 = 最大边长 / (2 × tan(fov/2)),这其实是"物体刚好填满视野"的理论值,乘个 1.5 留出余量,视觉上就舒服了。
4.2 gltf 对象结构:scene、scenes、animations、cameras 各是什么
loader.load回调里的gltf不是Object3D,它是一个普通对象,你真正要加到场景里的是gltf.scene。这个区别搞清楚能省很多事。它的几个关键字段:
gltf.scene:整个模型的根节点,类型是Group,直接scene.add()它。gltf.scenes:数组,多场景文件才有意义,绝大多数情况长度是 1,和scene[0]是同一个东西。gltf.animations:动画片段数组,每个是AnimationClip。模型带不带动效看这个数组长度。gltf.cameras:模型自带相机,做室内漫游类项目时能用上,一般直接忽略。gltf.asset:元信息,包含生成器名称、glTF 版本号。查问题的时候先看这个,能判断是哪个软件导出的。
动画的播放需要单独写,很多人会漏掉:
let mixer = null const clock = new THREE.Clock() loader.load(url, (gltf) => { scene.add(gltf.scene) if (gltf.animations.length > 0) { mixer = new THREE.AnimationMixer(gltf.scene) gltf.animations.forEach((clip) => mixer.clipAction(clip).play()) console.log('动画片段:', gltf.animations.map((c) => c.name)) } }) function animate() { requestAnimationFrame(animate) const delta = clock.getDelta() if (mixer) mixer.update(delta) renderer.render(scene, camera) } animate()这里有个新手特别容易掉的坑:AnimationMixer的构造参数必须是gltf.scene,不能是gltf,也不能是渲染器的 scene。你传错了会静默失败——不报错,但动画不动。
4.3 材质替换、阴影与色调映射三件套
模型加载进来之后大概率"看着不对劲":要么惨白,要么死黑,要么没有阴影贴在地上像浮空。这三个问题一次性解决:
renderer.outputColorSpace = THREE.SRGBColorSpace // 输出色彩空间 renderer.toneMapping = THREE.ACESFilmicToneMapping // 电影感色调映射 renderer.toneMappingExposure = 1.0 model.traverse((obj) => { if (obj.isMesh) { obj.castShadow = true obj.receiveShadow = true // 有些导出器会给出双面材质,性能和阴影都会受影响 if (Array.isArray(obj.material)) { obj.material.forEach((m) => (m.side = THREE.FrontSide)) } else if (obj.material) { obj.material.side = THREE.FrontSide } } }) renderer.shadowMap.enabled = true renderer.shadowMap.type = THREE.PCFSoftShadowMap dirLight.castShadow = true dirLight.shadow.mapSize.set(2048, 2048)彩色贴图发灰发白,八成是色彩空间没设。glTF 规范里颜色贴图用的是 sRGB,法线、粗糙度、金属度这些数据贴图必须是线性空间。GLTFLoader会按规范自动设置texture.colorSpace,所以正常情况下你不需要手动改——但如果你自己替换了材质或者手动new TextureLoader().load()加载贴图,就必须自己设成SRGBColorSpace,否则就是一片灰蒙。
阴影这块的取舍:PCFSoftShadowMap柔和但采样多,移动端可以退到PCFShadowMap。方向光的shadow.camera默认范围很小(上下左右各 5),模型大了阴影就没了,得手动调:
const r = Math.max(size.x, size.z) * 1.2 dirLight.shadow.camera.left = -r dirLight.shadow.camera.right = r dirLight.shadow.camera.top = r dirLight.shadow.camera.bottom = -r dirLight.shadow.camera.far = size.y * 4 + 10 dirLight.shadow.camera.updateProjectionMatrix()注意:
shadow.camera改完必须调updateProjectionMatrix(),否则不生效,这个坑我踩过至少三次。
4.4 加载进度、取消与失败重试
onProgress有个很隐蔽的问题:如果服务器开了 gzip/brotli 压缩,event.total可能是 0 或者是压缩前的长度,你算出来的百分比会变成 NaN 或者超过 100%。稳妥的写法是优先用lengthComputable判断,拿不到 total 就展示不确定态的进度条,别硬算。
另外GLTFLoader.load不支持直接取消。如果用户在模型下完之前就切走了页面,你需要在回调用一个标记位拦住,别让已经销毁的场景被塞进已经 dispose 的 renderer 里:
let destroyed = false function loadModel(url) { const token = { aborted: false } loader.load(url, (gltf) => { if (token.aborted || destroyed) { disposeObject(gltf.scene) // 谁加载的谁清理 return } scene.add(gltf.scene) }) return token // 外部调 token.aborted = true 即可逻辑取消 }网络失败的重试也值得写两行。loader.loadAsync配合try/catch和指数退避,比回调里裸写重试干净得多:
async function loadWithRetry(url, retries = 2) { for (let i = 0; i <= retries; i++) { try { return await loader.loadAsync(url) } catch (e) { if (i === retries) throw e await new Promise((r) => setTimeout(r, 400 * 2 ** i)) } } }5. 压缩模型实战:Draco、Meshopt、KTX2 怎么选怎么接
5.1 Draco:压缩率之王,但要付解码的账
Draco 的强项是几何。它做两件事:量化(把 32 位浮点坐标压成 14 位整数)和拓扑编码(利用顶点之间的连通性做熵编码)。一个 5MB 的纯几何 glb 压到 500KB 以内很常见。
线上代码侧要做的就三行(前面单例里已经做过),关键在导出侧的参数。这里我用命令行工具举例,不同版本参数名可能有出入,敲--help确认一下:
# 量化位置到 14 位,法线 10 位,UV 12 位,压缩等级 7 npx gltf-transform draco input.glb output.glb \ --quantize-position 14 \ --quantize-normal 10 \ --quantize-uv 12 \ --compression-level 7量化位数怎么定?这是有理论依据的,不是玄学。量化误差的近似公式是:
精度误差 ≈ 包围盒尺寸 / 2^位数假设你的模型包围盒最大边长是 20 米,位置用 14 位量化:
20 / 2^14 = 20 / 16384 ≈ 0.0012 米 ≈ 1.2 毫米1.2 毫米的顶点偏移,在这个尺度下肉眼完全看不出来,但数据量少了将近一半。如果模型包围盒是 1000 米(比如整个园区),同样的 14 位量化误差就变成 6 厘米,那就得考虑提到 16 位了。位数不是越高越好,是要和模型尺度匹配。
法线为什么只给 10 位?因为法线是单位向量,本身就归一化了,10 位量化误差在 0.001 量级,对光照的影响肉眼不可见。UV 给 12 位是因为贴图采样对精度敏感,位太低会出现贴图抖动。
压缩等级 7 是个平衡点,1 到 10 可选。等级越高压得越小,但编码越慢,解压时的开销也略增。生产环境我一般 6~7,在 CI 里跑就无所谓了。
5.2 Meshopt:更省心的一档,兼容性和解码速度更好
Meshopt 走的是另一条路:不做复杂的拓扑编码,用顶点/索引缓冲的通用压缩 + 量化,胜在解码速度极快(比 Draco 快好几倍)、解码器体积小、无需额外配置文件。
代码侧只需要loader.setMeshoptDecoder(MeshoptDecoder),导出侧:
npx gltf-transform meshopt input.glb output.glb --level high两者的实际取舍,我整理成了一张表:
| 维度 | Draco | Meshopt |
|---|---|---|
| 几何压缩率 | 更高(通常再多 10%~25%) | 高 |
| 解码速度 | 较慢,移动端明显 | 很快,接近原生 memcpy |
| 解码器体积 | wasm 约 300KB | 更小 |
| 部署复杂度 | 需要拷贝 draco 目录 | import 即用 |
| 与 glTF 扩展 | KHR_draco_mesh_compression | EXT_meshopt_compression |
| 是否支持动画压缩 | 不支持 | 支持(--optimize里有动画处理) |
| 我的默认选择 | 模型大、对首包体积极敏感 | 其余情况优先 |
如果你的项目是移动端为主、模型数量多、切换频繁,Meshopt 的解码速度优势会被放大。因为 Draco 每次加载都要花几十到几百毫秒在 wasm 里解,切十个模型就是好几秒的累计卡顿。
5.3 KTX2 纹理压缩:detectSupport 那行绝对不能省
纹理才是体积大头,这句话在上一节说过,这里给实操。KTX2 是容器格式,里面装的是 Basis Universal 压缩后的数据,能在运行时转码成 GPU 原生的压缩纹理格式(ETC2、ASTC、BC7 等)。
代码侧的关键是detectSupport(renderer):
const ktx2Loader = new KTX2Loader() .setTranscoderPath('/basis/') .detectSupport(renderer) // 必须!这行的作用是根据当前 renderer 的扩展能力,决定转码目标格式。漏掉它,加载 KTX2 纹理会直接报错或者得到一片纯色——因为转码器不知道要输出成什么。
线上 CPU 侧转码是有成本的。一张 2K 的 ETC1S 贴图,转码耗时大概在几十毫秒量级,十几张贴图叠加起来就是几百毫秒。所以:
- 首屏必需的贴图尽早开始加载,别等到交互时才拉。
- 非首屏贴图不要一次性全塞进同一个模型,拆成多个 glb 按需加载。
- UASTC vs ETC1S:ETC1S 体积小、转码快、有损更明显;UASTC 质量接近原始、体积约为 ETC1S 的 2~4 倍。法线贴图和带文字的 UI 类贴图适合 UASTC,常规彩色贴图用 ETC1S。
导出命令(需要本机装了 KTX-Software 的toktx):
# ETC1S:追求小体积 npx gltf-transform etc1s input.glb output.glb --quality 200 # UASTC:追求质量,适合法线贴图 npx gltf-transform uastc input.glb output.glb --level 2 # 一步到位:Draco 几何 + KTX2 纹理 + WebP 兜底 npx gltf-transform optimize input.glb output.glb \ --compress draco \ --texture-compress ktx2最后一行那个optimize是最实用的,它会把清理、去重、量化、压缩一条龙做完。我通常把它写进构建脚本,美术给什么就转什么,不用手动一步步跑。
注意:KTX2 全链路要保证
basis_transcoder.js和.wasm同版本,且和 three 的版本匹配。换 three 版本后如果 KTX2 突然报错,第一件事就是把node_modules/three/examples/jsm/libs/basis/整个目录重新拷一遍覆盖,别只拷 js 或者只拷 wasm。
5.4 一条命令压完,看看实测能省多少
说几个我在一台中端笔记本 + 中端安卓机上量的数据,模型是一个带 12 张 2K 贴图、约 28 万面的写实场景资产。数据只作相对参考,你的模型结构不同,比例会变。
| 阶段 | glb 体积 | 首次可见耗时(桌面) | 首次可见耗时(移动) |
|---|---|---|---|
| 原始导出 | 42.6 MB | 4.8 s | 17 s 以上 |
| 清理 + 贴图降到 1K | 11.2 MB | 1.6 s | 5.2 s |
| 再加 Draco(pos 14) | 3.4 MB | 1.4 s | 4.6 s |
| 再加 KTX2 ETC1S | 1.8 MB | 1.5 s | 3.8 s |
有意思的是第三行到第四行:体积从 3.4MB 砍到 1.8MB,但桌面端耗时几乎没变,甚至略涨。原因就是前面说的转码成本——省下的下载时间被转码吃掉了。而移动端因为网络慢,下载时间的收益大于转码成本,所以还是明显变快。
结论很明确:压缩不是无脑收益,要看你卡在带宽还是卡在 CPU。桌面端宽带环境下,KTX2 的主要价值在显存(下一节讲),不在首屏时间。移动端两者都受益。
6. 加载完之后:显存、释放与并发调度
6.1 算清楚一张贴图吃多少显存
先给公式,再给例子。未压缩纹理的显存占用:
显存 = 宽 × 高 × 每像素字节数 RGBA8 每像素 4 字节;RGB565 每像素 2 字节- 2048×2048 RGBA8:
2048 × 2048 × 4 = 16,777,216B ≈16 MiB - 4096×4096 RGBA8:
4096 × 4096 × 4 = 67,108,864B ≈64 MiB
一个模型如果带 5 张 4K 贴图(基础色、法线、粗糙度、金属度、AO),理论峰值就是 320MiB,还没算 mipmap。开 mipmap 后总量再乘 1.33。这就是为什么很多项目在桌面端好好的,一到移动端就崩标签页——移动端 GPU 的可用显存可能只有几百 MB。
KTX2 压缩纹理的价值在这里体现:GPU 原生压缩格式的每像素开销大约是 0.5~1 字节(4bpp~8bpp)。同样一张 4096 贴图走 BC7 或 ETC2,约 8MiB,是 RGBA8 的八分之一。
但如果detectSupport发现设备不支持任何压缩格式,KTX2 会被转码成未压缩的 RGBA8 上传——这时你的体积收益仍然在,显存一分不省。所以上线前一定要在目标机型上console.log(ktx2Loader)或者用扩展检测确认一下:
console.log({ s3tc: renderer.extensions.has('WEBGL_compressed_texture_s3tc'), etc2: renderer.extensions.has('WEBGL_compressed_texture_etc'), astc: renderer.extensions.has('WEBGL_compressed_texture_astc'), bptc: renderer.extensions.has('EXT_texture_compression_bptc') })这段我建议做成一个内部调试面板,在低端机上直接看。比看任何文档都直观。
6.2 释放:删了对象不等于释放了显存
这是 Three.js 里最经典的误解。scene.remove(model)只是把它从场景树上摘下来,GPU 里的 buffer 和 texture 一个都没少。真正的释放必须手动调dispose():
function disposeObject(root) { const geometries = new Set() const materials = new Set() const textures = new Set() root.traverse((obj) => { if (obj.geometry) geometries.add(obj.geometry) const mats = Array.isArray(obj.material) ? obj.material : [obj.material] mats.forEach((m) => { if (!m) return materials.add(m) // 把材质上所有贴图属性都扫一遍,别只处理 map Object.keys(m).forEach((key) => { const val = m[key] if (val && val.isTexture) textures.add(val) }) }) }) geometries.forEach((g) => g.dispose()) materials.forEach((m) => m.dispose()) textures.forEach((t) => t.dispose()) }为什么要用Set?因为多个 mesh 经常共享同一个 geometry 和 material,重复 dispose 虽然不会崩,但会影响 GC 的判断逻辑,而且在有引用计数的场景里容易出问题。
还有两个容易漏的:
renderer.renderLists.dispose():渲染列表里还持有引用,大量增删模型后建议调一次。AnimationMixer要stopAllAction()并uncacheRoot(root),否则内部缓存会一直存在。
写到这里我想起一个排查经历:某次切场景二十次之后内存涨到 1.8G,查了半天怀疑是贴图没释放,最后发现是法线贴图对象在材质里的属性名是normalMap,但我只处理了map,遍历属性这个写法才彻底解决。
6.3 并发与缓存:别让十个模型同时开始下载
浏览器同域名并发连接有限(HTTP/1.1 下是 6 个),十个 glb 同时发起请求,反而会互相挤占。我的做法是做一个简单的并发池,同时最多 3 个:
async function loadQueue(urls, limit = 3) { const results = [] let index = 0 async function worker() { while (index < urls.length) { const i = index++ results[i] = await loadWithRetry(urls[i]) onProgress?.(i) } } await Promise.all(Array.from({ length: Math.min(limit, urls.length) }, worker)) return results }同时做一个内存级的已加载缓存,用 URL 做 key。重复进入同一个场景时直接复用对象,但要小心:Three.js 的对象有父子关系,一个Object3D不能同时挂在两个父节点下。需要多份实例时,用clone(),带骨骼的模型要用SkeletonUtils.clone():
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js' const instance = skeletonClone(cachedGltf.scene)普通Object3D.clone()对带 Skin 的模型会共享骨骼,导致多个实例的动画互相干扰,这是另一个隐藏很深的坑。
7. 问题速查:这些报错我基本都遇到过
7.1 解码器与纹理类问题
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
No DRACOLoader instance provided | 加载了 Draco 压缩模型但没配解码器 | loader.setDRACOLoader(...) |
404 draco_decoder.wasm | 解码器目录路径错或文件没拷全 | 检查public/draco/四个文件是否齐全 |
THREE.KTX2Loader: Failed to load | 转码器路径错或版本不匹配 | 重新拷贝整个 basis 目录 |
| 纹理纯黑 / 纯白 | 漏了detectSupport(renderer) | 补上并确保 renderer 已创建 |
MeshoptDecoder is not defined | 忘了setMeshoptDecoder | import 后设置,注意是 module 版 |
第一条和第三条占了实际问题的绝大多数。有个通用排查手法:打开浏览器网络面板,过滤 "wasm",看看请求是不是 404,或者返回的 content-type 是不是application/wasm。某些静态服务器会把.wasm当未知类型返回text/plain,导致 wasm 实例化失败。这个错误在控制台里长得像跨域问题,很容易误判。
7.2 模型本身的问题
模型看不见但面数在涨。先查三件事:包围盒是不是异常大/小、相机是不是在模型内部、材质是不是transparent: true且opacity为 0。我有一次折腾了半小时,最后是给的模型整体缩放了 0.001。
模型全黑。分情况:如果是MeshStandardMaterial且场景里只有环境光没有方向光,金属度高的材质会大面积变黑,因为金属需要环境反射。解法是加一个PMREMGenerator生成的环境贴图,或者直接降低材质的metalness。
模型闪烁、面片撕裂。典型的深度冲突(z-fighting),两个面几乎共面。调camera.near和far的比值,让 near 别设成 0.0001 这种数值,深度精度会崩掉。一般near = 0.1, far = 1000是比较稳的比例。
模型背面对你、或者内外翻。说明法线方向反了,或者你强行设了side: THREE.BackSide。有些导出器在镜像变换后会翻转绕序,可以在导出时勾选"统一法线"。
用了 Draco 之后模型表面出现细小裂纹。这是量化误差导致的顶点不重合。检查是不是quantize-position给得太低(比如给到 10 位),或者模型本身有未合并的重合顶点,建议先跑一遍合并顶点再压缩。
7.3 我个人的三条避坑习惯
第一条:先跑未压缩版本。任何模型出问题,第一步是把未压缩版放进去跑通,确认是"加载链路问题"还是"压缩链路问题",别一上来就怀疑解码器。
第二条:常驻一个调试面板。显示 FPS、当前 drawcall、renderer.info.memory.geometries和textures两个数字。这两个数字只涨不降,就说明你在泄漏,比任何内存分析工具都及时。
第三条:压缩参数进构建脚本,别手动跑。手动跑的命令迟早会忘,或者某个同事用了不一样的参数,产物就不一致。把gltf-transform optimize写进package.json的 script 里,美术给的原始文件进assets-src/,产物出到public/models/,中间过程可复现。
模型加载这块,真正花时间的从来不是那几行loader.load,而是压缩选型、路径部署、显存控制和后期排查这四件事。前面九篇我们一直在跟"确定的东西"打交道——一个 Box 就是 8 个顶点,不会变;模型不一样,它是外来的、不透明的、参数一堆的黑盒,你得一层层把它拆开来看。这也是为什么我把它放到第十篇:它是从"画几何体"到"做真实项目"的分水岭。