/** * glTF 模型重新优化脚本 * * 修复问题: * - 网格简化 (simplify) 导致纹理拉伸 * - 网格简化导致墙体孔洞,室内漫游碰撞失效 * * 策略: * 只做无损/几何保真压缩,不做网格简化: * 1. dedupe - 去重访问器/纹理(不合并材质) * 2. prune - 清理未引用资源 * 3. weld - 仅合并同位置同 UV 顶点(保守容差,保证不破坏 UV) * 4. draco - 几何压缩(保留全部顶点和 UV) * 5. textureCompress - WebP 纹理压缩(保留 UV 映射) * * 用法: * node scripts/optimize-glb.mjs */ import { NodeIO } from '@gltf-transform/core'; import { dedup, prune, weld, draco, textureCompress } from '@gltf-transform/functions'; import { EXTTextureWebP, KHRDracoMeshCompression, KHRMaterialsSpecular } from '@gltf-transform/extensions'; import draco3d from 'draco3d'; import fs from 'node:fs'; const INPUT = 'd:/Web/PlatformModel/RuoYi-Vue3/public/models/莒口水闸3_original.glb'; const OUTPUT = 'd:/Web/PlatformModel/RuoYi-Vue3/public/models/莒口水闸3_reoptimized.glb'; async function main() { console.log('读取原始模型:', INPUT); // 注册扩展和 Draco 编解码器依赖 const [encoder, decoder] = await Promise.all([ draco3d.createEncoderModule(), draco3d.createDecoderModule(), ]); const io = new NodeIO() .registerExtensions([KHRDracoMeshCompression, EXTTextureWebP, KHRMaterialsSpecular]) .registerDependencies({ 'draco3d.encoder': encoder, 'draco3d.decoder': decoder, }); const doc = await io.read(INPUT); const root = doc.getRoot(); console.log('原始模型资源统计:'); console.log(' 网格数:', root.listMeshes().length); console.log(' 节点数:', root.listNodes().length); console.log(' 材质数:', root.listMaterials().length); console.log(' 纹理数:', root.listTextures().length); let totalVerts = 0, totalIdx = 0; for (const mesh of root.listMeshes()) { for (const prim of mesh.listPrimitives()) { const pos = prim.getAttribute('POSITION'); if (pos) totalVerts += pos.getCount(); const idx = prim.getIndices(); if (idx) totalIdx += idx.getCount(); } } console.log(' 原始顶点总数:', totalVerts); console.log(' 原始三角形索引总数:', totalIdx); // 创建 WebP 扩展(textureCompress 会自动注册并使用) doc.createExtension(EXTTextureWebP); console.log('\n应用优化变换...'); // 1. 去重访问器/纹理(不会合并不同材质) console.log(' [1/5] dedup - 去重访问器和纹理...'); await doc.transform(dedup()); // 2. 清理未引用资源 console.log(' [2/5] prune - 清理未引用资源...'); await doc.transform(prune()); // 3. 保守的 weld:仅合并位置完全相同且 UV 相同的顶点 // 容差设为极小值,避免合并不同 UV 的顶点导致纹理拉伸 console.log(' [3/5] weld - 保守顶点合并(容差 1e-4)...'); await doc.transform(weld({ tolerance: 1e-4 })); // 4. Draco 几何压缩(保留全部顶点和 UV,仅压缩存储) console.log(' [4/5] draco - 几何压缩...'); await doc.transform( draco({ encodeSpeed: 5, decodeSpeed: 5, quantizePosition: 14, quantizeNormal: 10, quantizeColor: 8, quantizeTexcoord: 12, quantizeGeneric: 12, }) ); // 5. 纹理压缩为 WebP(保留 UV 映射,仅压缩图像数据) console.log(' [5/5] textureCompress - WebP 纹理压缩...'); await doc.transform( textureCompress({ targetFormat: 'webp', encoderOptions: { quality: 80 }, resizeFilter: 'lanczos4', }) ); console.log('\n输出优化模型:', OUTPUT); await io.write(OUTPUT, doc); const outStat = fs.statSync(OUTPUT); console.log('输出文件大小:', (outStat.size / 1024 / 1024).toFixed(2), 'MB'); // 验证输出 console.log('\n=== 验证输出模型 ==='); const outDoc = await io.read(OUTPUT); const outRoot = outDoc.getRoot(); console.log('输出模型资源统计:'); console.log(' 网格数:', outRoot.listMeshes().length); console.log(' 节点数:', outRoot.listNodes().length); console.log(' 材质数:', outRoot.listMaterials().length); console.log(' 纹理数:', outRoot.listTextures().length); let outVerts = 0, outIdx = 0; for (const mesh of outRoot.listMeshes()) { for (const prim of mesh.listPrimitives()) { const pos = prim.getAttribute('POSITION'); if (pos) outVerts += pos.getCount(); const idx = prim.getIndices(); if (idx) outIdx += idx.getCount(); } } console.log(' 输出顶点总数:', outVerts, '(原始:', totalVerts, ')'); console.log(' 输出三角形索引总数:', outIdx, '(原始:', totalIdx, ')'); const vertLoss = ((1 - outVerts / totalVerts) * 100).toFixed(2); const idxLoss = ((1 - outIdx / totalIdx ) * 100).toFixed(2); console.log(' 顶点变化:', vertLoss + '% (应接近 0%,仅 weld 合并的重复顶点)'); console.log(' 索引变化:', idxLoss + '% (应接近 0%)'); console.log('\n✅ 优化完成'); } main().catch(err => { console.error('❌ 优化失败:', err); process.exit(1); });