
如果你是一个 Web 3D 项目的负责人应该对这套流程一点都不陌生美术在 Blender 里调了一下午导出几百 MB 的 glTF 模型你也认真写了模型加载代码结果用户打开页面先盯着加载条看了三秒然后说“这页面怎么这么卡”。更麻烦的是纹理贴图在不同设备上色差明显明明同一张图手机上饱和度偏灰桌面上又鲜亮过头。这些问题的根源不是显卡也不全是网络带宽而是 3D 资产的编码环节太原始。t3code 这个名字我拆开理解是 Three.js 场景下 3D 资产的编码与压缩管线核心解决三件事让模型文件体积更小、让加载速度更快、让显示颜色在不同设备上保持一致。它不是某个官方插件而是一套可落地的工程方法覆盖纹理压缩、几何压缩、色彩空间管理和运行时加载。我会把它拆成四块讲清楚最后附上实测数据和踩坑记录。适合正在做 Web 3D 展示、电商预览、数字孪生项目的前端同学也适合刚接触 Three.js 的开发者当作资产优化的入门路线图。1. t3code 是什么3D 资产编码痛点与方案起因1.1 原始资产为什么不适合直接上线先别急着写代码我们先看问题出在哪。美术同事从 Substance Painter 里导出的贴图通常是 4K 分辨率的 PNG 或 TGA单张 PBR 材质包含 BaseColor、Normal、Roughness、Metallic、AO 五张图按平均每张 20MB 算一个角色光贴图就近 100MB。模型本身如果是 ZBrush 雕刻精度三角面数动辄几百万。这些东西放在本机渲染没问题但一旦通过浏览器传输就变成一场灾难。我见过一个很典型的项目产品展示页有 12 个机械零件模型原始 glTF 总大小 380MB首屏加载需要等待资源全部拉完才渲染用户平均停留时长只有 40 秒却要等 15 秒的加载进度条。最后把纹理降级到 2K、用 Draco 压缩几何整体体积降到 45MB加载时间压到 3 秒以内。t3code 要解决的就是这个从 380MB 到 45MB 的工程过程。Web 3D 资源瓶颈有几个维度不能笼统说“压缩一下就行”第一是 GPU 需要纹理的格式和文件格式不同运行时必须有转码或解码环节第二是几何数据本质是顶点坐标流需要高效的熵编码第三是颜色数据到底以哪个色彩空间为基准DirectX 和 OpenGL 生态、sRGB 和 linear 之间的换算直接决定最终像素值。这三个维度如果只靠压缩工具一把梭结果往往是你觉得文件小了但渲染时反而多了 CPU 解压时间或颜色偏得离谱。1.2 t3code 管线的全貌我给 t3code 定的处理顺序是源资产 - 离线预处理 - 编码压缩 - 运行时解码 - GPU 渲染。离线预处理阶段把模型减面、清理多余 UV、重拓扑这类脏活干掉编码压缩阶段针对纹理和几何分别走 KTX2 Basis Universal 和 Draco运行时解压则由 three.js 的加载器集成完成。这条管线有一个原则值得记住能离线算的绝不放到运行时算。纹理转码是最典型的例子KTX2 内部存储的是超压缩数据GPU 直接消费需要硬件特定的格式但你可以在离线阶段就把目标平台需要的解码结果打包好或选用 runtime 端以极低成本解压的方案。这个取舍在后面会反复用到。2. 纹理压缩与色彩编码从 PNG 到 KTX2 的迁移2.1 PNG 作为 GPU 纹理为什么低效PNG 的优势是无损压缩适合 UI、图标和照片级的网络展示但它在 GPU 管线里有个致命问题GPU 根本不认识 PNG。GPU 纹理单元需要的是块压缩格式比如 BC7、ASTC、ETC2这些格式允许随机访问且按固定块大小解压而 PNG 是整张图片级别的压缩必须有一个 CPU 解码成 RGBA 位图再上传显存的环节。这个环节需要占用内存带宽且会拖慢加载速度。打个比方PNG 像一本整卷压缩的报纸你要看中间某一页得先整卷展开GPU 压缩纹理像索引卡片盒抽哪张都瞬间可用。浏览器同时加载几十个 PNG 纹理时CPU 解码时间会非常明显地飙升尤其对手机中端芯片是巨大负担。这也是为什么同样一个 3D 展示桌面端流畅、移动端却掉帧的一个隐形原因。2.2 KTX2 容器与 Basis Universal 压缩KTX2 是 Khronos 组织推的纹理容器格式它允许纹理数据以 GPU 原生可消费的方式存储配合 basis universal 编码器可以在离线阶段把普通图片转成两种超压缩模式ETC1S 和 UASTC。ETC1S基于 ETC1 的全局调色板结构目标极高压缩率通常体积是 PNG 的十分之一甚至更低但画质上限有限适合 Diffuse、Roughness 这类低频变化贴图。UASTC追求高质量属于有损但接近视觉无损目标画质优先压缩率不如 ETC1S适合 Normal Map 这类高频法线细节。在 t3code 管线里我对贴图类型做区分BaseColor 和 Roughness 用 ETC1S quality 255 档Normal 和 AO 细节要求高的用 UASTC 高质量档。如果一张 BaseColor 和 Roughness 都是用 4K PNG因为 R 通道和 G 通道分别用到不同纹理槽位它们其实是两张独立文件压缩完分别约 2-4MB整体节省量非常可观。2.3 色彩空间sRGB 和 linear以及为什么你调的颜色会偏这是 t3code 最容易被忽略、却在真实项目里坑人最多的一环。3D 渲染管线内部默认使用线性空间做光照计算但美术画的贴图是在 sRGB 空间下看到的颜色。如果你的纹理加载时没有标注 SRGBColorSpaceGPU 会把它当线性数据直接参与光照结果就是漫反射变暗、对比度奇怪。反过来Normal Map 必须保持线性采样把它错误标成 sRGB高光点会彻底乱掉。three.js 从 r152 开始Texture 的 encoding 属性被 colorSpace 取代具体的设置方法是// 漫反射等颜色贴图声明为 sRGB texture.colorSpace THREE.SRGBColorSpace; // 法线、粗糙度、金属度这类数据贴图保持线性 normalTexture.colorSpace THREE.NoColorSpace;这个设置如果不是在写加载逻辑前就固定好而是项目做到中后期补排查成本非常高。因为人眼对这个偏差的感知很微妙不是那种“全错”而是“好像哪里脏脏的饱和度不对”美术会反复调图前端会反复核对色值最后才发现是色彩空间标记漏了。我在 t3code 里直接把这一条写进加载器默认配置禁止例外从源头隔离这个坑。2.4 动态范围HDR 贴图与曝光控制工业产品展示经常用到环境贴图.hdr 格式如果直接转成 PNG 类 LDR 纹理高光层次会丢失。正确做法是保留 HDR 数据在运行时通过 PMREMGenerator 处理成环境辐射贴图。这块和 t3code 纹理管线的相关性在于如果场景里存在金属材质环境贴图质量直接决定反射的高光是否奶油般顺滑而这部分体积不大但极易被误压缩。如果环境贴图是 .hdr 文件我不建议转 KTX2 压成低动态范围因为 KTX2 对 HDR 支持需要 RADL 或 Half-Float 模式很多离线工具链还不完善。更稳妥的做法是保留 EXR 或 HDR 原文件使用 RGBELoader 加载把它放进 t3code 的“不压缩清单”里。管线的意义就是知道什么东西需要压、什么东西不能压而不是把所有变量一刀切这是我在多个项目里反复验证过的经验。3. 几何压缩Draco 对模型流动性的提升3.1 原始网格数据为什么偏大一个三角网格的数据结构里包含位置坐标3 个 float、法线3 个 float、UV 坐标2 个 float部分情况下还有 tangent、color 和 bone weights。一个点有几十字节100 万顶点就是几十上百 MB 二进制数据。更关键的是glTF 文件里这些数据默认是二进制数组基本没有经过任何压缩即便你用 GZip 对 glTF 压一遍也远达不到数据级编码器的效果。Draco 的思路不是简单 zip 一下而是针对网格特有结构做重新排列、预测编码和熵编码。比如相邻顶点的坐标差值很小用上一个顶点预测下一个差值经过量化后交给熵编码体积大幅下降。穿过屏幕的顶点数量从 300 万降到 30 万压缩率通常能达到 10:1 到 15:1 输出 GLB。3.2 压缩参数怎么调才能兼顾体积和质量我最初用 Draco 时习惯把所有精度拉满quantizationPosition 设成 14texture 设成 12。结果压缩率不算理想。后来明白量化位数直接决定顶点坐标的离散精度对大部分 Web 展示场景位置用 11-12 bit 足够法线和 UV 用 8-10 bit。一个简单的判断标准是模型在屏幕上最大占画幅比例如果你做的是汽车展示用户会拉近看轮毂那位置量化需要 12 以上如果模型只占画面四分之一量化 10 也不会差到哪里去。用 gltf-transform 的 draco 命令举例gltf-transform draco input.glb output.glb --method edgebreaker --quantize-position 11 --quantize-normal 8 --quantize-texcoord 9三个 quantization 参数的效果是阶梯式变化的9-10 会有轻微可见棱边11 在多数镜头下感知不到12 明显有体积回涨。所以我在 t3code 里默认值是 position 11、normal 8、texcoord 10特殊镜头特写的模型单独调到 12。这套参数在多台设备上做过对比盲测人眼几乎无法区分与原始模型的差异。3.3 关于“Draco 解压耗时”的误判很多人担心 Draco 解压消耗 CPU 性能我的实测结论是对于单帧加载的场景多花 20-50ms 解压换取 80% 体积下降完全值得。但如果场景需要大量动态加载模型解压时间是叠加的这时候要考虑异步分包配合优先级队列避免同时解压多个大模型导致主线程卡顿。另外Draco 不是唯一选择。如果模型是程序化生成的低面数几何原始数据本身不大直接用原始模式可能更合理只有三角面数超过一定规模、且文件体感大了Draco 的价值才凸显。t3code 在接入时加了一个阈值判断原始模型体积小于 5MB 且三角面低于 5 万跳过 Draco不折腾超过阈值才进入编码流程。这个判断写成一个脚本标记直接记在资产表里后续发布读取即可。4. 在项目里落地 t3code构建步骤与运行时接入4.1 从动手到跑通工具链编排先明确整体依赖Node.js 环境、gltf-transform CLI、basis CLIBasis Universal 命令行工具、three.js r160。t3code 的构建流程分三阶段预处理清理资产由 Blender 端处理或 Python 脚本完成编码压缩用 gltf-transform 和 basis CLI发布材质色彩空间标记在运行时加载器里维护。我建议把压缩过程封装成 CI 脚本不要手动敲命令。一个简单的情况一张 roughness 贴图要转成 KTX2你可以这么处理basisu -file roughness.png -output_format ktx2 -ktx2_uastc -uastc_quality 3 -mipmap屏幕快照工具输出格式是 KTX2uastc_quality 范围 0-43 是一个画质和体积比较平衡的位置。加上 -mipmap为后续 GPU 采样生成 mipmap 链。4.2 运行时加载Three.js 配置顺序很重要运行时如果你只引入了 GLTFLoader且模型内嵌了 KTX2 纹理必须在初始化时注册 KTX2Loader 并把解压器路径指对。import { KTX2Loader } from three/addons/loaders/KTX2Loader.js; const ktx2Loader new KTX2Loader() .setDetectSupport(renderer) .setTranscoderPath(https://example.com/wasm/); const loader new GLTFLoader() .setKTX2Loader(ktx2Loader); loader.load(model.glb, (gltf) scene.add(gltf.scene));这里的 setTranscoderPath 指向包含 basis_transcoder.js 和 wasm 文件的目录。如果路径写错或者忘记设置纹理加载会直接失败控制台报 KTX2 unsupported 或无法解码。这是新手最容易踩的第一道坎。另一个实战细节不要在 loader.load 回调之后再对材质美颜加载回调里第一时间处理紧凑材质边缘。因为 KTX2 纹理的 colorSpace 属性的默认继承有时会沿用 MeshStandardMaterial 的 roughness 属性你不显式设置默认值可能不是你要的效果。4.3 集成后的数据对比我不谈理论分析直接放实测我用一个测试模型包含 2 张 2K BaseColor、2 张 2K Roughness、1 张 2K Normal原始 glTF 约 76MB。经过 t3code 管线处理纹理全部 ETC1S 与 UASTC 分类转 KTX2几何 Draco 量化压缩最终 GLB 4.8MB。体积下降约 94%加载耗时从慢速网络的约 9 秒降至 1.5 秒渲染帧率因 CPU 解码减少反而有所提升。我用一张表对比三种方案在同样网络环境下 LCP最大内容绘制时间的变化方案总大小资源加载时间CPU 解码时间/帧视觉质量原始 glTF PNG76MB8.8s约 12ms基准glTF Draco 几何压缩42MB4.2s约 6ms接近基准t3codeKTX2Draco4.8MB1.4s约 1ms可接受视觉质量不是全无损失ETC1S 下的 BaseColor 贴图在渐变暗部带会有一点点色阶UASTC 模式下近乎无损。你需要在项目里根据客户视角做权衡非核心展示资产全走 ETC1S核心视觉位置重点展示的模型使用 UASTC 或双份纹理策略。4.4 如果不想部署 wasm 解码器怎么办有人会问KTX2 必然要 wasm 解压其实还有一个更省事的思路在离线阶段直接生成目标平台 context 下需要的 GPU 纹理比如桌面的 BC7 或移动端的 ASTC但这样必须推多平台产物。NDN 加载器都判断设备能力这个策略会增大工程复杂度且用户设备环境变化会有缓存失效风险。我的观点是不要绕开 wasm。basis transcoder 的 wasm 非常小约 100-300KB一次性缓存后运行时不占据首屏关键资源。而且 three.js 的 KTX2Loader 自动管理 decoder 实例进一步降低接入成本。5. 避坑清单与实测经验5.1 KTX2 压缩后反而更大的怪现象我遇到过一张大片纯色背景的 UI 图标纹理转 ETC1S 后体积比原始 PNG 还大因为 ETC1S 的全局调色板对高频硬边文字其实不太友好。遇到这种情况不要盲目调高压缩质量而是把这类纹理排除出 KTX2 管线直接用 PNG 走 traditional 流程。这也是 t3code 里预设的一条“智能资产分类规则”含有大面积锐利 UI 元素的贴图跳过。5.2 法线贴图压缩后的光照崩坏法线贴图被压缩成 ETC1S 后如果质量不够表面高光会出现星星点点闪烁。我在一个汽车车漆模型上踩过这个坑法线贴图精度不够导致轮毂反射跳动排查了两天最后把法线纹理改成 UASTC mipmap 才解决。经验是法线贴图永远不要用 ETC1S这是 t3code 的硬编码规则。另一个细节是 KTX2 压缩后的法线贴图在采样时可能直接用硬件线性过滤配合切线空间的法线数据要注意 UV 边界是否平滑不能有大的接缝。5.3 Draco 压缩模型的运行时崩溃如果你的模型附带了 morph targets 或骨骼动画数据Draco 默认的配置可能与某些目标设备的实现存在兼容性问题。我的建议是动画密集的资产在压缩后导出前执行一次 gltf-validator 排查且让压缩后的资产使用和原始版本在纹理数量、骨骼数量上保持一致防止加载期报 unknown extension。这个坑不是必然出现但遇到就是硬骨头可能崩溃在某个安卓机型上你却没有实机来测。5.4 版本对齐比什么都重要three.js、KTX2Loader、basis_transcoder.wasm 三者的版本必须严格匹配。我在项目里曾因为单独升级 three.js 没同步更新 KTX2Loader导致 KTX2 纹理加载后花屏。t3code 要求每次升级把 three.js 和 wasm 依赖锁定到同一发布版本测试全量回归后再更新线上。这个看起来很简单也是实际项目中最容易忽略的供应链问题。5.5 关于压缩完成后的播放端还有一个容易被忽略的点t3code 并不是做完压缩就结束了你依然要在最终页面里做资源优雅降级检测到弱设备时可进一步加载低分辨率纹理版本检测到内存极限时释放场景里当前不可见模型的几何体。这些逻辑我省略了很多细节但这里的核心是t3code 给你搭建好了一个可持续的管线不至于每次资源更新都从头开始。最后再分享一个小技巧t3code 用于生产之前建议准备一个包含 30 个以上不同模型的测试包覆盖金属、布料、玻璃、皮肤四类材质每个模型至少经过一次低端安卓机实机浏览。3D 资产优化这件事光看 Chrome DevTools 的 Network 面板远远不够因为 GPU 纹理的解码和上传耗时在真机差异很大。你把这个测试包跑一遍就基本能预判所有用户端的体验上限在哪。我的经验是这套流程做完项目从“能用”到“稳”质的飞跃通常就在这一步。