做 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 typeimportmap { 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.6MBDraco 与 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 两个方案怎么选我用一张表说清楚维度DracoMeshopt压缩比高通常 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里的数字一点没降。反复切换十几次低端设备上的标签页就会崩。正确的回收链路是逐层 disposefunction 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颜色发灰发闷基础色贴图未设 sRGBtexture.colorSpace SRGBColorSpace明暗块状色斑法线/AO 贴图被 sRGB 处理设回NoColorSpace局部过曝发白曝光参数或 toneMapping降toneMappingExposure至 0.8-1.0表面阶梯状抖动顶点量化位深太低重压时把 quantize 提到 166.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的角标。任何模型进来先过这个页面能不能显示、比例对不对、几何体和纹理数量是不是合理一眼就能看出来。等模型确认没问题了再往正式场景里接。这个调试页我用了三年省下来的排查时间可能是按天算的。
阅读完成 · 觉得有帮助?