Three.js 加载 GLB 压缩模型:Draco/Meshopt/KTX2
做 Three.js 项目做到第十篇,前面几篇基本都在跟几何体、材质、相机、灯光这些“自己造”的东西打交道,而真实项目里 90% 的模型资源都不是你亲手写的代码生成的,而是美术那边导出的 glTF 或 GLB 文件。所以这一步迟早要跨过去:把外部模型塞进场景,并且是在体积被压过一轮、纹理被转码过一轮的前提下塞进去。这两个动作在浏览器里能不能顺利跑起来,差别非常大,我第一次接压缩模型的时候,模型是加载出来了,但是一坨纯黑,控制台干干净净,折腾了快两个小时才定位到问题出在转码器路径上。这篇就把加载 gltf/glb 和加载压缩模型这条链路完整讲一遍,包括 Draco、Meshopt 这两种几何压缩,KTX2 + Basis 的纹理压缩,还有加载之后的进度管理、显存释放和一堆容易踩的细节。适合已经能把 Three.js 基础场景跑起来、准备接真实美术资源的读者。
1. GLB 不是"一个大号模型文件",先搞清楚里面装了什么
1.1 glTF 的三层结构决定了加载方式
很多人第一次接触 glTF,会下意识把它当成一个黑盒二进制文件丢给加载器,加载成功就完事,加载失败就换一个文件再试。这种盲试的做法效率极低,因为 glTF 的加载路径和文件内部结构是强绑定的。它的规范里其实拆得很清楚:一层是 JSON 描述(场景节点树、网格引用、材质定义、动画轨道、扩展声明),一层是二进制缓冲(顶点属性、索引、蒙皮矩阵),一层是外部资源引用(图片纹理、bin 文件)。.gltf是 JSON 和外部资源分离的形态,.glb是把 JSON 和二进制打包进同一个容器的形态,纹理可以作为内嵌的 bufferView 存在。
这个结构差异直接影响你部署时的文件组织。用.gltf加一堆分离的 png 和 bin,意味着模型加载会触发 N 个 HTTP 请求,任何一张贴图 404 都会让材质显示异常,而且路径是相对 JSON 文件位置计算的,你把模型目录挪一层,可能整片贴图全丢。用.glb单文件,请求数降到 1 个,部署简单,代价是单文件体积大、没法针对单张贴图做缓存策略。
真正需要关心的是 JSON 里那个extensionsUsed和extensionsRequired数组。这个数组直接告诉你这个模型需要哪些解码器。如果里面出现了KHR_draco_mesh_compression,你就必须给 GLTFLoader 挂 DracoLoader,否则加载器会直接抛错或者说"这个模型需要扩展支持"。如果出现KHR_texture_basisu,就必须挂 KTX2Loader。如果出现EXT_meshopt_compression,就必须配 MeshoptDecoder。这是压缩模型加载失败的第一个高频原因,也是最容易被忽略的一个——因为错误信息藏在 loader 的 parse 阶段,有时候只在控制台留一行不起眼的警告。
我现在的习惯是,拿到一个新模型,先用文本编辑器或者命令行把 JSON 部分打出来看一眼扩展列表,再决定挂哪些加载器。GLB 的话可以用工具把 header 之后那段 JSON chunk 抽出来,或者干脆用一个在线的 glTF 查看器看一眼有没有报扩展缺失。这一步花 30 秒,能省掉后面半小时的排查。
1.2 三种压缩的本质区别:减顶点、减纹理、减编码
经常有人把 Draco 和 KTX2 混着叫"模型压缩",其实它们压的根本不是同一个东西,作用点完全不同,接入方式也完全不同。
Draco 压的是几何数据,也就是顶点位置、法线、UV、索引这些属性。它用的是一套专门的网格编码算法,把浮点数量化成整数,再做熵编码,压缩比通常能做到 5 到 10 倍。代价是解码需要在主线程(或者 worker)里跑一遍计算,模型越复杂解码越慢,而且解码后的顶点数据精度是有损的。
Meshopt 压的也是几何,但算法路线不一样,它更偏向于"快速解码",压缩比不如 Draco 极致,但解压速度能快好几倍,特别适合移动端。它还顺带做了顶点缓存优化和量化,其实很多项目里 Meshopt 的综合体感更好,因为它不会在首屏卡住主线程。
KTX2 + Basis Universal 压的是纹理。普通 png/jpg 是浏览器解码成 RGBA 再上传显存,一张 2048 的贴图在显存里就是 2048×2048×4 字节 ≈ 16MB,完全不压。KTX2 走的是 GPU 原生压缩格式(桌面端转 BC7/BC1,移动端转 ETC2/ASTC),显存占用能降到原来的四分之一到八分之一,而且 GPU 采样时直接解压,不占额外的解码时间。代价是需要一个 transcoder 的 wasm 文件在运行时把 Basis 数据转成当前设备支持的格式。
理解了这个分层,你才能判断自己的瓶颈在哪。如果模型顶点数很夸张(几十万面),先上几何压缩;如果模型面数不多但贴图一堆 4K,先上纹理压缩;两个都占,就两个都上。我见过有人给一个 800 面的道具模型做 Draco 压缩,省下来 40KB,然后还在纠结为什么首屏没变快——因为瓶颈根本不在几何上。
1.3 加载耗时到底花在哪一步
这是我在做加载进度条时被教育得最狠的一次。我原以为模型加载的时间主要在下载,后来用 Performance 面板一扒,发现真正的分布是:下载占一部分,解析 JSON 占一小部分,Draco 解码能占到 30% 到 50%,纹理转码和小图上传能占到 20% 到 30%,最后的场景节点构建反而很快。
这个分布意味着两件事。第一,进度条不能只按 HTTP 的 progress 事件算,那个事件在压缩模型上会有明显的"卡在 90% 不动"的现象,因为文件下载完了但解码还没开始。第二,压缩不是无脑上,如果你的模型本身不大,Draco 解码的那几百毫秒可能比省下来的下载时间还长,尤其是在弱网以外的场景下,反而是负优化。
所以在动手接加载器之前,先给自己建立一个基线:拿一个未压缩的模型,记录从发起加载到模型出现在场景里的总耗时;再拿压缩后的模型,记录同样的指标。两个数字对比,你才知道压缩带来的收益到底落在哪一段。这个基线测量不需要什么工具,performance.now()打个点就够了。
2. 把 GLTFLoader 接进项目:目录、依赖与模块解析的第一次碰撞
2.1 加载器不在 three 的核心包里
这是新手第一个绊脚石。GLTFLoader、DRACOLoader、KTX2Loader这些都不在 three 的主包入口里,它们放在examples/jsm/loaders/目录下。你用 npm 装的 three,这些文件在node_modules/three/examples/jsm/里;你用 CDN 引的 three,路径也是类似的相对位置。它们没有被默认导出,必须单独 import。
在纯静态页面、用 import map 的场景下,最容易出的问题就是模块解析。three 的 examples 里那些文件之间会互相 import,而且 import 的时候写的是'three'这个裸模块名。浏览器不认识裸模块名,必须在 import map 里把"three"映射到实际的模块路径,否则你会看到类似 "Failed to resolve module specifier" 的报错。
一份能跑通的最小 import map 长这样:
<script type="importmap"> { "imports": { "three": "./vendor/three/build/three.module.js", "three/addons/": "./vendor/three/examples/jsm/" } } </script>注意第二个键末尾那个斜杠。它是路径前缀映射,你写import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'的时候,浏览器会拼成./vendor/three/examples/jsm/loaders/GLTFLoader.js。这个斜杠忘了写,报错信息会非常迷惑,它不会告诉你斜杠错了,只会说找不到模块。
我的建议是把 three 的 build 目录和 examples/jsm 目录整体拷到项目的 vendor 下,不依赖任何外部链接。这样做的好处是版本完全锁定,不会出现哪天外部源更新了版本、你的加载器行为和主包对不上导致的各种诡异问题。升级的时候整体替换,干净利落。
2.2 一个真正能跑起来的最小示例
不玩虚的,直接给一套完整的、包括场景和光照的代码,你贴进去改一下模型路径就能看到东西。这里我假设模型是一个普通的未压缩 GLB。
import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const container = document.getElementById('app'); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera( 45, container.clientWidth / container.clientHeight, 0.1, 1000 ); camera.position.set(3, 2, 5); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(container.clientWidth, container.clientHeight); renderer.shadowMap.enabled = true; container.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2)); const keyLight = new THREE.DirectionalLight(0xffffff, 3); keyLight.position.set(5, 10, 7); keyLight.castShadow = true; keyLight.shadow.mapSize.set(2048, 2048); scene.add(keyLight); const loader = new GLTFLoader(); loader.load( './models/demo.glb', (gltf) => { const model = gltf.scene; model.traverse((obj) => { if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; } }); const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center); const maxDim = Math.max(size.x, size.y, size.z); camera.position.set(maxDim * 1.6, maxDim * 1.2, maxDim * 2.0); camera.near = maxDim / 100; camera.far = maxDim * 100; camera.updateProjectionMatrix(); scene.add(model); }, (xhr) => { if (xhr.total) { console.log(`加载进度 ${((xhr.loaded / xhr.total) * 100).toFixed(1)}%`); } }, (err) => { console.error('模型加载失败', err); } ); const clock = new THREE.Clock(); function animate() { const dt = clock.getDelta(); controls.update(); renderer.render(scene, camera); requestAnimationFrame(animate); } animate();这段代码里有几处是我特意加的,不是凑数。自动居中和自动调整相机距离这一段,解决了"模型加载成功但屏幕全黑"这个最高频的困惑——因为你根本不知道模型在哪儿,可能是几万单位外,也可能是零点几个单位大,相机默认位置压根看不见它。用 Box3 算一下包围盒,把模型挪到原点、相机拉到合适距离,这一步是所有模型预览类功能的标准动作。
2.3 onLoad 回调里必须补的三件事
加载器回调给你的是gltf.scene、gltf.scenes、gltf.animations、gltf.cameras、gltf.asset这几样东西。很多人拿到 scene 直接scene.add(gltf.scene)就完事了,然后发现没有阴影、动画不动、模型比例奇怪。
第一件是阴影。glTF 规范里没有"投影"这个概念,美术导出时也不会带。所以你得自己 traverse 一遍,给每个 Mesh 手动开castShadow和receiveShadow。漏了这一步,模型在场景里就是飘飘忽忽一块死物,没有落地感。
第二件是动画。gltf.animations是一个 AnimationClip 数组,你要自己创建 AnimationMixer,然后用 mixer 驱动。mixer 必须在渲染循环里每帧update(delta),而且 delta 要用clock.getDelta()拿,不能用固定的 1/60,否则在 120Hz 屏幕或者掉帧的时候动画速度会不对。
第三件是坐标和比例。不同建模工具的导出设置不一样,有的模型 Y 轴向上,有的 Z 轴向上(glTF 规范要求 Y 轴向上,但导出时转换错了的情况不罕见);有的模型单位是米,有的是厘米,导进来可能只有 0.01 单位大。用包围盒判断尺寸并做一次归一化是最省事的处理,比去追美术改导出设置快得多。
3. 模型体积从 12MB 降到 1.6MB:Draco 与 Meshopt 的取舍
3.1 Draco 压缩之后,解码器文件必须跟着走
先说 Draco 的接入。压缩工具层面,常用的有 gltf-pipeline 和 gltf-transform 两个命令行工具,后者功能更全、维护更活跃。一条命令把未压缩的 glb 转成 Draco 压缩版:
gltf-transform draco input.glb output.glb --quantize-position 14--quantize-position 14是把顶点位置量化到 14 位,这个值是有讲究的。量化位深越低,文件越小,但模型表面越容易出现肉眼可见的阶梯状抖动。14 位对绝大多数场景够用,如果是需要精密配合的机械模型或者大尺度地形,建议提到 16 位;如果是小而远的道具,12 位也看不出问题。这个参数不是玄学,它决定的是位置精度的粒度,位深每加 1 位精度翻倍,体积也略涨。
压缩完之后,代码侧必须挂解码器:
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath('./draco/'); dracoLoader.setDecoderConfig({ type: 'wasm' }); dracoLoader.preload(); const gltfLoader = new GLTFLoader(); gltfLoader.setDRACOLoader(dracoLoader);setDecoderPath指向的目录里必须实际存在这几个文件:draco_decoder.js(或者draco_decoder.wasm)、draco_wasm_wrapper.js、draco_encoder.js用不上但要一起拷也行。这些文件在 three 的examples/jsm/libs/draco/目录里。网上的教程经常只写一行setDecoderPath,不提这些文件要手动拷贝,结果就是控制台报一堆 fetch 404,然后模型永远停在加载中。
preload()这个调用也别省。它会在空闲时提前把解码器拉下来,避免第一个模型加载时多等一个 RTT。项目里只有一个模型时差别不明显,有十几个模型顺序加载时,差别就是几百毫秒。
还有一个坑值得单独说:draco_decoder.wasm这个文件的 MIME 类型。有些静态服务器对.wasm返回的是application/octet-stream而不是application/wasm,浏览器在instantiateStreaming的时候会报警告并回退到用 ArrayBuffer 编译,功能上不致命但会慢一点。如果你看到控制台有相关警告,去服务器配置里把 wasm 的 MIME 加上即可。
3.2 Meshopt 的接入更轻,但坑在别处
Meshopt 的接入代码只有三行:
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; gltfLoader.setMeshoptDecoder(MeshoptDecoder);它不需要额外的目录,不需要额外加载 wasm 文件(解码器本身就是个模块化的 wasm,打包在里面了),这是它相比 Draco 最舒服的地方。
压缩命令:
gltf-transform meshopt input.glb output.glb --level medium--level有 low、medium、high 三档,影响的是压缩比和解码耗时的平衡。medium 是绝大多数项目的默认选择,high 会让解码时间明显上升。另外注意,Meshopt 压缩必须配合 vertex 量化一起用才有意义,gltf-transform 默认会带量化,如果你手动关掉了量化,压缩效果会非常有限。
Meshopt 最容易踩的坑是版本不匹配。模型文件是用某个版本的 meshopt 编码器压的,浏览器里跑的 decoder 是另一个版本,可能出现解码失败或者顶点错乱。我遇到过一次,模型表面看起来正常,但法线全乱了,着色一片诡异。解决方式就是保证编码器和解码器来自同一批 three 依赖,别一边用 A 版本的加载器一边用 B 版本的解码器。
还有一个细节:Meshopt 解码是同步的(在较新版本里支持 worker),如果模型很大,解码那一下会卡住主线程,表现为加载过程的最后一瞬间掉一帧甚至卡住半秒。做首屏体验敏感的项目时,这个卡顿要考虑进去,可以通过延长加载遮罩的淡出时间把视觉上的突兀感抹掉。
3.3 两个方案怎么选,我用一张表说清楚
| 维度 | Draco | Meshopt |
|---|---|---|
| 压缩比 | 高,通常 5-10 倍 | 中,通常 3-6 倍 |
| 解码速度 | 较慢,纯计算密集 | 快,约为 Draco 的 3-5 倍 |
| 额外文件依赖 | 需要 draco 解码器目录 + wasm | 无需额外目录,模块内置 |
| 移动端表现 | 解码耗时明显,低端机可能卡顿 | 相对友好 |
| 生态兼容 | 几乎所有查看器都支持 | 支持度略低,部分老工具不认 |
| 适合场景 | 静态展示、模型极大、网络条件差 | 交互频繁、需要快速切换模型 |
这张表不是让你无脑选一个,而是给你判断依据。我的实际做法是:先都压一遍,测出来两个文件大小,再测一遍两者从加载到可交互的端到端时间,取总耗时短的那个。有时候 Draco 省下来的 3MB 下载时间,完全被解码时间吃掉了,尤其是在内网或者本地开发环境,下载几乎瞬间完成,Draco 反而更慢。而面向公网、弱网用户的场景,Draco 的优势就会体现出来。
如果项目允许,两个都留着做成可切换的加载路径也是不错的选择:检测navigator.hardwareConcurrency或者设备类型,高端设备走 Draco 拿更小体积,低端设备走 Meshopt 避免解码卡顿。
4. 纹理才是体积大头:KTX2 与 Basis 压缩的落地细节
4.1 "黑模型"十有八九是转码器路径的问题
我是被这个问题按在地上摩擦过的。一个 40MB 的模型,压缩之后 6MB,加载完成,控制台没有红色报错,但模型就是一片纯黑,连高光都没有,像是被吞进了黑洞。检查了光照、检查了材质、检查了相机 near/far,全都没问题。
最后的答案是 KTX2Loader 的setTranscoderPath指错了目录,转码器加载失败,纹理全部为空,材质采样到的是黑色。而 Three.js 在这里的报错非常温和,它不会中断加载流程,只是默默给你一个空纹理。
正确的接入顺序是这样的:
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; const ktx2Loader = new KTX2Loader(); ktx2Loader.setTranscoderPath('./basis/'); ktx2Loader.detectSupport(renderer); gltfLoader.setKTX2Loader(ktx2Loader);三个点必须全对。第一,./basis/目录里要有basis_transcoder.js和basis_transcoder.wasm,这两个文件在 three 的examples/jsm/libs/basis/下,得手动拷。第二,detectSupport(renderer)必须在 renderer 创建之后调用,而且传进去的必须是真正用来渲染的那个 renderer 实例,因为它是通过试跑一次 WebGL 上下文来判断当前设备支持哪种压缩格式(ASTC、ETC2、BC7、BC1、PVRTC)。传 null 或者传一个没初始化的 renderer,检测结果就是错的,转码目标格式选错,纹理要么显示异常要么直接黑。第三,setKTX2Loader要在load()之前调用。
我后来把这三个点做成了一份接入检查清单,每次新项目接 KTX2 都过一遍,再没黑过。
压缩纹理的命令是另一套:
gltf-transform etc1s input.glb output.glb --quality 200etc1s是 ETC1S 编码,压缩比极高、体积小,但色彩精度有损,适合大量贴图、对细节不苛刻的场景。另一种是 UASTC,质量接近原始,但体积大得多,适合法线贴图、细节纹理。
gltf-transform uastc input.glb output.glb --level 2 --rdo-quality 4这里有个强经验:法线贴图千万别用 ETC1S。ETC1S 是给基础色用的,它的色彩压缩方式会破坏法线的方向信息,压缩后模型表面的光照会变成一块一块的色斑。法线和粗糙度、金属度这类数据贴图,老老实实用 UASTC。
4.2 压缩纹理和普通贴图混用的兼容处理
真实项目里很少能做到全量纹理都转 KTX2,尤其是那些从别处拿来复用的素材、或者序列帧、或者表情贴图。混合使用的时候,有几个兼容性细节要处理。
第一是色彩空间。KTX2Loader 转出来的纹理,Three.js 的 GLTFLoader 会根据 glTF 的材质语义自动给它设colorSpace。但如果你自己手动加载贴图往材质上贴,就必须自己设:
const tex = await new THREE.TextureLoader().loadAsync('./textures/albedo.png'); tex.colorSpace = THREE.SRGBColorSpace;基础色、自发光这类"给人看颜色"的贴图用 SRGBColorSpace,法线、粗糙度、金属度、AO 这类"给计算用数值"的贴图必须保持 NoColorSpace(线性)。这个搞反了,画面会明显偏灰或者过饱和。我见过一个项目因为一张 AO 贴图设成了 sRGB,整个模型的暗部全是脏的,找了两天才定位到。
第二是各向异性过滤。压缩纹理在倾斜角度下的模糊会比普通纹理更明显,尤其是地面、地板这类大平面。给纹理设anisotropy = renderer.capabilities.getMaxAnisotropy()能明显改善,代价是采样开销略微上升。这个设置在压缩纹理上收益比在普通纹理上更明显。
第三是纹理尺寸。KTX2 要求长宽最好是 4 的倍数,某些格式还要求是 2 的幂。如果原图是 1000×1000 这种非 2 幂尺寸,转码后可能出现边缘拉伸或者直接失败。压缩前统一把纹理尺寸规整到 2 的幂,虽然会损失一点原始精度,但能避免后续一堆麻烦。
5. 加载进度、并发控制和显存回收:一条完整的资源管线
5.1 进度条的百分比为什么会失真
先说一个几乎所有人都会遇到的现象:用 LoadingManager 或者 loader 的 onProgress 做进度条,压缩模型的进度会卡在 80% 到 95% 之间不动很久,然后突然跳到 100%。原因是xhr.total在有 gzip 压缩传输、或者分块传输的情况下可能拿不到准确值,而且即便拿到了,它反映的是字节下载进度,不包括下载完之后的 Draco 解码和纹理转码时间。
解决方式是拆成两段进度。下载段用 xhr 的 loaded/total 算,解码段用人工估算或者干脆用一个"处理中"的动画遮过去。更精细的做法是:如果你要加载 N 个模型,进度按"完成的模型数 / N"来算,每个模型的内部进度只在前 90% 区间映射,剩下的 10% 留给解码和构建。
const manager = new THREE.LoadingManager(); let done = 0; const total = 8; manager.onProgress = (url, loaded, totalLoaded) => { const perItem = loaded / totalLoaded; const overall = (done + perItem) / total; setProgress(Math.min(overall, 0.95)); }; manager.onLoad = () => { setProgress(1); };那个Math.min(overall, 0.95)不是偷懒,是刻意的。因为加载完成之后还有解压、编译着色器、构建场景这几步,如果进度条先到 100% 再卡住,用户体感反而更差。留一点余量,让进度条在遮罩淡出的同时走完,观感会顺很多。
5.2 切换场景时不清显存,内存会一路涨上去
这是做多场景切换时最容易被忽视的问题。scene.remove(model)只是把模型从场景树上摘下来,几何体和纹理还占着显存,renderer.info.memory里的数字一点没降。反复切换十几次,低端设备上的标签页就会崩。
正确的回收链路是逐层 dispose:
function disposeObject(root) { root.traverse((obj) => { if (!obj.isMesh) return; obj.geometry?.dispose(); const materials = Array.isArray(obj.material) ? obj.material : [obj.material]; materials.forEach((mat) => { if (!mat) return; for (const key of Object.keys(mat)) { const value = mat[key]; if (value && value.isTexture) { value.dispose(); mat[key] = null; } } mat.dispose(); }); }); }有几个细节值得说。第一,遍历的时候要跳过共享资源,如果两个模型共用同一张纹理,你 dispose 了之后另一个模型的贴图就没了,会变成黑色。这种情况需要在资源管理层面做引用计数,或者在 dispose 前判断texture.userData.shared。第二,加载器本身也有 dispose 方法,DRACOLoader 和 KTX2Loader 用完之后可以调dispose()释放 worker 池。第三,动画的 mixer 也要stopAllAction()并置空,否则它还会持有 clip 的引用,垃圾回收器收不走。
验证有没有漏掉,最直接的办法是打开renderer.info.memory看 geometries 和 textures 两个数字。切换场景前后数字应该回到初始值附近,如果只增不减,就说明有地方没 dispose。
5.3 并发数不是越多越好
批量加载模型时,很多人图省事直接一个 for 循环发起 N 个loader.load。浏览器对同域名并发连接数有限制,超出的请求会排队,看起来像是"卡住了"。更麻烦的是,多个模型同时进入解码阶段时,主线程会被占满,整个页面直接无响应。
我的做法是做一个简单的队列,并发数控制在 3 到 4 之间:
async function loadQueue(urls, concurrency = 3, onEach) { const results = []; let cursor = 0; async function worker() { while (cursor < urls.length) { const index = cursor++; const gltf = await gltfLoader.loadAsync(urls[index]); results[index] = gltf; onEach?.(index, gltf); } } await Promise.all( Array.from({ length: Math.min(concurrency, urls.length) }, worker) ); return results; }这个并发数不是拍脑袋定的。3 到 4 是在"浏览器连接复用效率"和"主线程解码压力"之间比较舒服的平衡点。如果你的模型都做过 Draco 压缩,解码很重,建议降到 2;如果都是 Meshopt 或者未压缩的小模型,可以提到 6。
6. 一套可复现的排查链路:模型不出来、发黑、动画不动
6.1 从网络面板到控制台的逐层验证
模型加载出问题的时候,最忌讳的就是漫无目的地改代码。我的排查顺序是固定的,从底层往上推,每一层确认过了再往下一层走,这样最多十分钟就能定位。
第一步,看网络面板。模型文件本身是 200 还是 404?如果是 404,说明路径错了,先解决路径。有些服务器对路径大小写敏感,Model.glb和model.glb是两个不同的文件。另外注意相对路径的基准是当前 HTML 文档的 URL,不是你想象的项目根目录。
第二步,看解码器文件是不是也都 200。Draco 的 wasm、KTX2 的 transcoder,这些请求失败不会中断整个加载,但会让模型异常。在 Network 面板过滤一下 wasm 关键字,确认它们的状态码。
第三步,看控制台。区分警告和错误。THREE.GLTFLoader: Unknown extension这类警告说明有扩展没被识别,通常不影响主体加载但会丢一些特性。Uncaught TypeError就会中断整个流程。
第四步,在 onLoad 回调里打印gltf.scene.children.length和gltf.animations.length。如果 children 是 0,说明模型加载到了但里面是空的,可能是压缩解码出了问题。如果 animations 是 0 但你确定模型有动画,说明导出时没勾选动画或者动画被压缩工具丢掉了。
第五步,在场景里临时加一个 AxesHelper 和一个固定尺寸的立方体作为参照。如果立方体看得见、模型看不见,那是模型位置或尺寸的问题;两个都看不见,那问题在相机或渲染循环,跟模型无关。这一步能快速把问题域切一半。
6.2 发黑、发灰、发白:色彩空间的三个典型症状
材质颜色不对,几乎百分之百是色彩空间的问题。三种症状对应三种不同的原因,理解了这个映射关系,看到画面就知道该改哪儿。
模型整体发黑、暗部糊成一团,通常是光照不足或者环境光缺失。Three.js 在较新版本里改了光照单位,同样的强度数值比老版本暗不少。如果你是从老教程里抄的new THREE.DirectionalLight(0xffffff, 1),在新版本里会明显偏暗,提到 3 左右比较接近。另外如果没有环境贴图,金属材质的反射无处可依,也会呈现黑色,这不是 bug,是物理正确的表现——金属没有环境光照就没有颜色。
模型整体发灰、颜色发闷、饱和度低,通常是纹理的 colorSpace 没设成 sRGB。GLTFLoader 加载的标准 glTF 会自动处理,但如果你手动替换过贴图、或者用 CanvasTexture 动态生成过贴图,就得自己设colorSpace = THREE.SRGBColorSpace。
模型发白、过曝、颜色发飘,方向反过来,是把本该线性的数据贴图设成了 sRGB,或者是renderer.toneMapping的曝光参数太高。前者常见于 AO、粗糙度贴图,后者去调toneMappingExposure,从 1.0 往下试。
| 症状 | 最可能原因 | 处理动作 |
|---|---|---|
| 整体接近纯黑 | 无环境光照 / 灯光强度过低 | 加 HemisphereLight 或环境贴图,灯光强度提到 2-3 |
| 颜色发灰发闷 | 基础色贴图未设 sRGB | texture.colorSpace = SRGBColorSpace |
| 明暗块状色斑 | 法线/AO 贴图被 sRGB 处理 | 设回NoColorSpace |
| 局部过曝发白 | 曝光参数或 toneMapping | 降toneMappingExposure至 0.8-1.0 |
| 表面阶梯状抖动 | 顶点量化位深太低 | 重压时把 quantize 提到 16 |
6.3 动画不播放的三种典型情况
模型进来了,动画不动,这是另一个高频问题。
第一种,忘了 mixer.update。AnimationMixer 不会自己跑,必须在渲染循环里每帧调mixer.update(delta)。而且这个 delta 要是真实的时间增量,不能是写死的。我见过有人写了mixer.update(0.016)图省事,结果在 60Hz 屏幕上勉强对,换到 144Hz 屏幕动画快了 2.4 倍。
第二种,clipAction 没 play。animations.forEach(clip => mixer.clipAction(clip).play())这行不能少。还要注意clipAction返回的 action 默认是 LoopRepeat,如果想要一次性播放然后停在最后一帧,要设setLoop(THREE.LoopOnce, 1)和clampWhenFinished = true。
第三种,骨骼名称对不上。模型换了导出批次,或者压缩工具重命名了节点,mixer 找不到对应的骨骼就静默不播。这种情况下控制台不会有任何报错,只能靠对比clip.tracks里的名字和模型节点的名字来确认。我遇到过一次,两个看起来完全一样的模型,一个动画正常一个不动,最后发现是其中一个的骨骼名多了个后缀。
还有个小坑:mixer.update()的调用位置要在renderer.render()之前,否则这一帧的动画不会体现在画面上,表现为延迟一帧,在慢速动画里不明显,在快速切换的动画里会造成首帧跳变。
最后分享一个我自己踩过、后来固化成习惯的做法:所有加载进来的模型,我都先在一个独立的调试页里跑一遍,页面上固定放三样东西——一个 AxesHelper、一个已知尺寸的参照立方体、一个显示renderer.info的角标。任何模型进来先过这个页面,能不能显示、比例对不对、几何体和纹理数量是不是合理,一眼就能看出来。等模型确认没问题了,再往正式场景里接。这个调试页我用了三年,省下来的排查时间可能是按天算的。