如果你是一个 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 空间下看到的颜色。如果你的纹理加载时没有标注 SRGBColorSpace,GPU 会把它当线性数据直接参与光照,结果就是漫反射变暗、对比度奇怪。反过来,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 设成 14,texture 设成 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 CLI(Basis 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屏幕快照工具输出格式是 KTX2,uastc_quality 范围 0-4,3 是一个画质和体积比较平衡的位置。加上 -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 + PNG | 76MB | 8.8s | 约 12ms | 基准 |
| glTF + Draco 几何压缩 | 42MB | 4.2s | 约 6ms | 接近基准 |
| t3code(KTX2+Draco) | 4.8MB | 1.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 纹理的解码和上传耗时在真机差异很大。你把这个测试包跑一遍,就基本能预判所有用户端的体验上限在哪。我的经验是这套流程做完,项目从“能用”到“稳”,质的飞跃通常就在这一步。