// // Created by atdunbg on 2026/9/21. // #include #include #include // CreateDefaultMaterialTexture #include #include #include #include #include #include #include #include #include // AI_MATKEY_GLTF_ALPHAMODE #include // FBX 全版本导入(6.1 兼容,见 ParseUfbxFromDisk) #include #include #include #include #include #include #include namespace Engine { static void NormalizeClipNames(ParsedModel& pm, const std::string& path); namespace { // assimp 行主矩阵 → glm 列主(转置搬运;骨骼 node TRS / invBind 共用) glm::mat4 ToGlm(const aiMatrix4x4& m) { glm::mat4 out; out[0][0] = m.a1; out[1][0] = m.a2; out[2][0] = m.a3; out[3][0] = m.a4; out[0][1] = m.b1; out[1][1] = m.b2; out[2][1] = m.b3; out[3][1] = m.b4; out[0][2] = m.c1; out[1][2] = m.c2; out[2][2] = m.c3; out[3][2] = m.c4; out[0][3] = m.d1; out[1][3] = m.d2; out[2][3] = m.d3; out[3][3] = m.d4; return out; } // ufbx_matrix → glm(ufbx cols[] 列主语义与 glm 一致,col3 = 平移) glm::mat4 ToGlm(const ufbx_matrix& m) { glm::mat4 out{1.0f}; for (int c = 0; c < 3; ++c) { out[c][0] = static_cast(m.cols[c].x); out[c][1] = static_cast(m.cols[c].y); out[c][2] = static_cast(m.cols[c].z); } out[3][0] = static_cast(m.cols[3].x); out[3][1] = static_cast(m.cols[3].y); out[3][2] = static_cast(m.cols[3].z); return out; } std::string U8Str(const ufbx_string& s) { return s.length ? std::string(s.data, s.length) : std::string(); } // LOD 链生成(assimp ProcessMesh 与 ufbx 路径共用): // LOD0 = 原始;每级目标索引数减半,相对误差 1e-2。顶点共享, // 简化只重排索引(meshopt_simplify 从 LOD0 单次起点逐级向下) void BuildLodChain(ParsedMesh& out, std::vector&& indices) { out.lods.push_back(std::move(indices)); for (u32 lod = 1; lod < kMeshLodCount; ++lod) { const size_t target = out.lods[0].size() >> lod; // 太小的网格不简化:简化收益低于误差风险,直接复用上一级 if (target < 96 || out.lods[0].size() < 192) { out.lods.push_back(out.lods.back()); continue; } std::vector dest(out.lods[0].size()); float error = 0.0f; const size_t count = meshopt_simplify( dest.data(), out.lods[0].data(), out.lods[0].size(), &out.vertices[0].position.x, out.vertices.size(), sizeof(Vertex), target, 1e-2f, 0, &error); if (count == 0) { out.lods.push_back(out.lods.back()); // 简化失败兜底 continue; } dest.resize(count); out.lods.push_back(std::move(dest)); } } // 解析收尾(assimp / ufbx 两路径共用):骨骼 nodeIdx 回填 + 蒙皮 // CPU 位置换算到渲染空间 + 解析日志 void FinalizeParsed(ParsedModel& out) { // 骨骼 nodeIdx 回填(注册发生在各 mesh 处理内,这里按 node 表 // 查 name 定位累乘路径) if (!out.bones.empty()) { std::unordered_map nodeByName; nodeByName.reserve(out.nodes.size()); for (u32 i = 0; i < out.nodes.size(); ++i) { nodeByName.emplace(out.nodes[i].name, i); } for (auto& bone : out.bones) { if (auto it = nodeByName.find(bone.name); it != nodeByName.end()) { bone.nodeIdx = it->second; } else { ENGINE_WARN("Bone '{}' has no matching node, palette entry static", bone.name); } } // 蒙皮 mesh 的 CPU 位置副本换算到渲染空间:渲染 = palette(t)×raw //(palette = global(bone)×invBind,与 shader 同式),物理/包围球取 // 绑定姿态。FBX 的单位换算折在骨骼链上(bind palette ≠ I), // 直接用 raw 会让物理凸包大一个数量级(M1911 实测 10×) std::vector globals(out.nodes.size()); for (size_t i = 0; i < out.nodes.size(); ++i) { globals[i] = out.nodes[i].parent < 0 ? out.nodes[i].localTransform : globals[out.nodes[i].parent] * out.nodes[i].localTransform; } std::vector bindPalette(out.bones.size()); for (size_t b = 0; b < out.bones.size(); ++b) { bindPalette[b] = globals[out.bones[b].nodeIdx] * out.bones[b].invBind; } for (auto& pm : out.meshes) { if (!pm.skinned) continue; pm.cpuPositions.resize(pm.vertices.size()); for (size_t v = 0; v < pm.vertices.size(); ++v) { const Vertex& vert = pm.vertices[v]; glm::vec3 p(0.0f); f32 wsum = 0.0f; for (int k = 0; k < 4; ++k) { const f32 w = vert.weights[k]; if (w <= 0.0f) continue; p += w * glm::vec3(bindPalette[vert.boneIds[k]] * glm::vec4(vert.position, 1.0f)); wsum += w; } // 零权重顶点(shader 侧塌到原点)物理侧保留原始位置兜底 pm.cpuPositions[v] = wsum > 1e-6f ? p : vert.position; } } } ENGINE_INFO("Model parsed: {} ({} meshes, {} bones, {} anims)", out.path, out.meshes.size(), out.bones.size(), out.clips.size()); } // ufbx 贴图引用 → 磁盘路径。FBX 里的引用常见三类:相对路径(可能 // 带 ".fbm" 媒体目录前缀,Blender 导出常态)、Windows 绝对路径 //(反斜杠分隔)。实际文件多与 FBX 同目录或其 textures/ 子目录—— // 逐候选探测落盘,全落空返回空串(上层退默认纹理 + 日志锚点) std::string ResolveUfbxTexture(const std::filesystem::path& modelDir, const ufbx_texture& tex) { namespace fs = std::filesystem; auto clean = [](std::string s) { for (char& c : s) { if (c == '\\') c = '/'; } return s; }; std::vector cands; auto push = [&](const std::string& rel) { if (rel.empty()) return; const fs::path p(rel); if (p.is_absolute()) { cands.push_back(rel); } cands.push_back((modelDir / p).string()); cands.push_back((modelDir / p.filename()).string()); cands.push_back( (modelDir / "textures" / p.filename()).string()); }; push(clean(U8Str(tex.relative_filename))); push(clean(U8Str(tex.absolute_filename))); for (const auto& c : cands) { std::error_code ec; if (fs::exists(c, ec) && fs::is_regular_file(c, ec)) { return c; } } return {}; } // ---- ufbx 解析路径(.fbx 专用)---- // assimp 的 FBX importer 只支持 FBX 2011+(7100+):Mixamo 及大量 // 网上资产是 FBX 6.1 binary,对象 ID 层与 7.x 布局不兼容,assimp // DOM 直接拒读(放宽版本检查也只走到 "expected L(ong)")。ufbx // 官方支持 FBX 5.x/6.x/7.x 全版本,故 .fbx 统一走本路径; // glTF/OBJ 语义不同仍走 assimp(isGltf 的 UV/MR 合图语义) ParsedModel ParseUfbxFromDisk(const std::string& path) { ParsedModel out; out.path = path; out.isGltf = false; // FBX 惯例同 OBJ:UV v 翻转 + 贴图分槽 const std::filesystem::path modelDir = std::filesystem::path(path).parent_path(); ufbx_load_opts opts = {0}; // 引擎世界单位 = m(物理/脚本全 m/s),FBX 文件单位多为 cm // (original_unit_meters=0.01)→ 折算到 1 世界单位 = 1 m: // ufbx 把 ÷100 折进根节点变换,蒙皮 palette(boneG×inv(bind)× // mountRef)与静态烘焙(node_to_world)全链一致输出米制; // InheritType 各节点实测全为 1(NORMAL),无需 COMPENSATE opts.target_unit_meters = 1.0; ufbx_error err; ufbx_scene* scene = ufbx_load_file(path.c_str(), &opts, &err); if (scene == nullptr) { ENGINE_ERROR("ufbx parse failed: '{}' ({})", path, U8Str(err.description)); return out; // meshes/clips 空 = 无效 } struct SceneGuard { ufbx_scene* s; ~SceneGuard() { ufbx_free_scene(s); } } guard{scene}; // ---- 节点表:DFS 拍平(对齐 assimp FlattenNodes 语义)---- // localTransform 用 node_to_parent(含 adjust 的最终 local) std::function flatten = [&](ufbx_node* n, i32 parent) { const u32 idx = static_cast(out.nodes.size()); ModelNode& mn = out.nodes.emplace_back(); mn.name = U8Str(n->name); if (mn.name.empty()) mn.name = "node_" + std::to_string(idx); mn.parent = parent; mn.localTransform = ToGlm(n->node_to_parent); for (size_t i = 0; i < n->children.count; ++i) { flatten(n->children.data[i], static_cast(idx)); } }; flatten(scene->root_node, -1); // ---- mesh:per-index 顶点展开 + cluster 权重 + fan 三角化 ---- // 场景式组装模型(Blender 全场景导出等):各 mesh 挂在带独立 // 变换的节点下(部件摆放/武器悬挂/单位抵消缩放),而引擎 mesh // 渲染 = "mesh 列表 + 实体 transform",无 per-mesh 节点变换。 // 非蒙皮 mesh 的顶点必须烘挂载节点 global,否则部件乱堆原点、 // 尺度错乱;蒙皮 mesh 不烘(palette = global(bone)×geometry_to_bone // 语义已含 mesh 节点变换,M1911 bind palette ≠ I 同源) std::unordered_map meshMount; std::function mountWalk = [&](ufbx_node* n) { if (n->mesh != nullptr) { meshMount.emplace(n->mesh, n); // 多实例取首个 } for (size_t i = 0; i < n->children.count; ++i) { mountWalk(n->children.data[i]); } }; mountWalk(scene->root_node); // 蒙皮参考挂载空间:首个带权重的蒙皮 mesh 的挂载节点世界。 // Blender 组装导出里多个蒙皮 mesh 挂在不同变换的节点下却共享 // 同一套骨骼,全局单份 invBind 无法同时满足各挂载点 → 全部 // 统一到该空间:invBind = inv(bind_to_world) × mountRef,各真 // 蒙皮 mesh 顶点预烘 skinFix = inv(mountRef) × 挂载世界,渲染 // 式仍为 bone(t) × inv(bind) × mount_M × v(Blender 蒙皮语义); // 单挂载资产 skinFix = I,零回归 glm::mat4 mountRef(1.0f); bool mountRefSet = false; // 材质应用:把 ufbx 材质表项写入目标 ParsedMesh 槽位(Albedo 贴图 + // diffuse 纯色兜底 + alpha 透明判定)。tint:有贴图时不写 baseColor // (贴图即颜色,Blender 缺省 0.8 灰会整体压暗);无贴图时 diffuse // 色作纯色 albedo auto applyMaterial = [&](ParsedMesh& dst, const ufbx_material* mat) { const ufbx_material_map& base = mat->pbr.base_color.texture != nullptr ? mat->pbr.base_color : mat->fbx.diffuse_color; if (base.texture == nullptr && base.has_value) { dst.params.baseColor = glm::vec4( static_cast(base.value_vec4.x), static_cast(base.value_vec4.y), static_cast(base.value_vec4.z), static_cast(base.value_vec4.w)); } dst.transparent = base.has_value && base.value_vec4.w < 0.999f; if (base.texture != nullptr) { dst.texPaths[Material::Albedo] = ResolveUfbxTexture(modelDir, *base.texture); } // UE/ORCA 打包 Specular 工作流:R=AO, G=Roughness, B=Metalness。 // 通道与 glTF MR 合图(G=rough, B=metal)恰好同序 → 同一张贴图 // 直供 MetallicRoughness 槽 + Ao 槽,纹理缓存按路径去重共享同一 // Texture*。factors=(1,1,0,0):金属/粗糙因子直用贴图(z=0 → shader // 读 MR.g 当粗糙度,同 glTF 语义)。UE 打包 Specular ⇔ DirectX // 法线(同源约定)→ 默认置翻绿位 misc.w if (const ufbx_material_map& spec = mat->pbr.specular_color; spec.texture != nullptr) { const std::string p = ResolveUfbxTexture(modelDir, *spec.texture); dst.texPaths[Material::MetallicRoughness] = p; dst.texPaths[Material::Ao] = p; dst.params.factors = glm::vec4(1.0f, 1.0f, 0.0f, 0.0f); dst.params.misc.w = 1.0f; } if (const ufbx_material_map& nrm = mat->pbr.normal_map; nrm.texture != nullptr) { dst.texPaths[Material::Normal] = ResolveUfbxTexture(modelDir, *nrm.texture); } if (const ufbx_material_map& emi = mat->pbr.emission_color; emi.texture != nullptr) { dst.texPaths[Material::Emissive] = ResolveUfbxTexture(modelDir, *emi.texture); } ENGINE_INFO( "ufbx Mesh '{}': Material '{}': albedo={} mr={} normal={} ao={} emissive={}", dst.name, mat->name.length ? U8Str(mat->name) : "?", dst.texPaths[Material::Albedo].empty() ? "" : dst.texPaths[Material::Albedo], dst.texPaths[Material::MetallicRoughness].empty() ? "" : dst.texPaths[Material::MetallicRoughness], dst.texPaths[Material::Normal].empty() ? "" : dst.texPaths[Material::Normal], dst.texPaths[Material::Ao].empty() ? "" : dst.texPaths[Material::Ao], dst.texPaths[Material::Emissive].empty() ? "" : dst.texPaths[Material::Emissive]); }; std::unordered_map boneSlots; for (size_t mi = 0; mi < scene->meshes.count; ++mi) { const ufbx_mesh* m = scene->meshes.data[mi]; const std::string meshName = U8Str(m->name); const ufbx_skin_deformer* skin = m->skin_deformers.count > 0 ? m->skin_deformers.data[0] : nullptr; const bool meshSkinned = skin != nullptr; // static 烘焙变换:挂载节点 global + 法线矩阵(含非均匀 // 缩放的正确逆转置;无挂载节点 = 恒等) glm::mat4 bakeG(1.0f); if (const auto mit = meshMount.find(m); mit != meshMount.end()) { bakeG = ToGlm(mit->second->node_to_world); } const glm::mat3 bakeN = glm::transpose(glm::inverse(glm::mat3(bakeG))); bool bakeStatic = skin == nullptr; // 本 mesh 是否真带权重(Blender 导出把全套 vertex group 写 // 成 cluster,挂件顶点权重全零 = 伪蒙皮,走 static 烘焙) bool meshHasWeights = false; if (skin != nullptr) { for (size_t v = 0; v < skin->vertices.count; ++v) { if (skin->vertices.data[v].num_weights > 0) { meshHasWeights = true; break; } } } if (meshHasWeights && !mountRefSet) { mountRef = bakeG; mountRefSet = true; } // 真蒙皮 mesh 的挂载偏差修正(参考挂载 mesh 自身 = I) glm::mat4 skinFix(1.0f); if (meshHasWeights) { skinFix = glm::inverse(mountRef) * bakeG; } const glm::mat3 skinFixN = glm::transpose(glm::inverse(glm::mat3(skinFix))); std::vector verts; verts.reserve(m->num_indices); for (size_t ii = 0; ii < m->num_indices; ++ii) { const u32 v = m->vertex_indices.data[ii]; // 逻辑顶点 const ufbx_vec3 p = m->vertices.data[v]; Vertex vert{}; vert.position = {static_cast(p.x), static_cast(p.y), static_cast(p.z)}; // 法线/UV 为 per-index 属性(经 indices[] 取值); // FBX 的 UV v 原点在底部 → 翻到左上惯例(同 assimp // FlipUVs 对 FBX 的处理) if (m->vertex_normal.exists) { const ufbx_vec3 nrm = m->vertex_normal.values.data [m->vertex_normal.indices.data[ii]]; vert.normal = {static_cast(nrm.x), static_cast(nrm.y), static_cast(nrm.z)}; } if (m->vertex_uv.exists) { const ufbx_vec2 uv = m->vertex_uv.values.data [m->vertex_uv.indices.data[ii]]; vert.uv = {static_cast(uv.x), 1.0f - static_cast(uv.y)}; } if (m->vertex_tangent.exists) { const ufbx_vec3 t = m->vertex_tangent.values.data [m->vertex_tangent.indices.data[ii]]; vert.tangent = {static_cast(t.x), static_cast(t.y), static_cast(t.z)}; } // 蒙皮权重:skin->vertices[v](按逻辑顶点)→ 前 4 权重 //(已按权重降序)。cluster 注册即骨骼表去重(同 assimp // boneSlots 语义),invBind 统一参考空间重推(见注册处) bool vertWeighted = false; if (skin != nullptr && v < skin->vertices.count) { const ufbx_skin_vertex sv = skin->vertices.data[v]; const u32 nW = static_cast( std::min(4, sv.num_weights)); glm::uvec4 ids(0); glm::vec4 w(0.0f); f32 sum = 0.0f; for (u32 k = 0; k < nW; ++k) { const ufbx_skin_weight sw = skin->weights.data[sv.weight_begin + k]; const ufbx_skin_cluster* cl = skin->clusters.data[sw.cluster_index]; // 骨骼名优先 bone_node(FinalizeParsed 按 node 表 // 查下标),断链 cluster 回退自身名 std::string bname = cl->bone_node != nullptr && cl->bone_node->name.length ? U8Str(cl->bone_node->name) : U8Str(cl->name); if (bname.empty()) continue; auto it = boneSlots.find(bname); if (it == boneSlots.end()) { const u32 slot = static_cast(out.bones.size()); ModelBone& b = out.bones.emplace_back(); b.name = bname; b.nodeIdx = 0; // FinalizeParsed 回填 // invBind 统一到 mountRef:bind_to_world //(TransformLink = 骨骼 bind 世界)的逆 × // mountRef。与 ufbx 缺省分支的单挂载公式 // inv(bind)×mountW 等价;Blender 导出的显式 // Transform cluster 被 ufbx 0.0.7 直接当 // mesh_node_to_bone 用(缺 inv(TransformLink) // 补偿),原始 geometry_to_bone 语义错位不可 // 用。bind_to_world 零矩阵(奇异文件)退回原值 const glm::mat4 bindW = ToGlm(cl->bind_to_world); const f32 bindWNorm = glm::length(bindW[0]) + glm::length(bindW[1]) + glm::length(bindW[2]) + glm::length(bindW[3]); b.invBind = bindWNorm > 1e-8f ? glm::inverse(bindW) * mountRef : ToGlm(cl->geometry_to_bone); it = boneSlots.emplace(bname, slot).first; } ids[k] = it->second; w[k] = static_cast(sw.weight); sum += w[k]; } // 权重归一化(ufbx 不保证归一;与 assimp // LimitBoneWeights 后的引擎约定一致) if (sum > 1e-6f) { w /= sum; vertWeighted = true; } vert.boneIds = ids; vert.weights = w; } // 烘焙统一放权重之后,以权重块写入的确定性标志分路 //(static mesh 的 vert.weights 是未初始化内存,不可读): // 真蒙皮顶点预烘 skinFix 把挂载偏差折进顶点,使共享骨骼 // 的单一 invBind 在本 mesh 上仍满足蒙皮语义;无权重顶点 //(含伪蒙皮 mesh)按 static 烘挂载节点世界,GPU 上不塌原点 if (vertWeighted) { vert.position = glm::vec3( skinFix * glm::vec4(vert.position, 1.0f)); if (glm::length(vert.normal) > 1e-8f) { vert.normal = glm::normalize(skinFixN * vert.normal); } if (glm::length(vert.tangent) > 1e-8f) { vert.tangent = glm::normalize(skinFixN * vert.tangent); } } else { vert.position = glm::vec3( bakeG * glm::vec4(vert.position, 1.0f)); if (glm::length(vert.normal) > 1e-8f) { vert.normal = glm::normalize(bakeN * vert.normal); } if (glm::length(vert.tangent) > 1e-8f) { vert.tangent = glm::normalize(bakeN * vert.tangent); } } verts.push_back(vert); } // 三角化:faces 多边形 fan 展开,逐三角形记录材质槽(ufbx // 已把 AllSame 展开为逐面 face_material;缺失/越界归 0) struct Tri { u32 i0, i1, i2, mat; }; std::vector tris; tris.reserve(m->num_triangles); for (size_t f = 0; f < m->num_faces; ++f) { const ufbx_face face = m->faces.data[f]; if (face.num_indices < 3) continue; const u32 base = static_cast(face.index_begin); u32 fmat = 0; if (m->face_material.count > 0) { fmat = m->face_material.data[ f < m->face_material.count ? f : 0]; } if (fmat >= m->materials.count) fmat = 0; for (u32 k = 1; k + 1 < face.num_indices; ++k) { tris.push_back({base, base + k, base + k + 1, fmat}); } } // 按材质把面分组(首现顺序稳定);无面兜底保留一个空子 mesh // 以维持 mesh 数与场景引用的稳定性 std::vector matOrder; for (const Tri& t : tris) { if (std::find(matOrder.begin(), matOrder.end(), t.mat) == matOrder.end()) { matOrder.push_back(t.mat); } } if (matOrder.empty()) matOrder.push_back(0); // 逐材质发射子 mesh:单材质原样搬运(逐位零回归),多材质按 // 材质分组紧凑重排顶点(引擎 mesh 单材质,同 Unity submesh / // UE section 语义),每个子 mesh 直接套用其真实材质 for (size_t gi = 0; gi < matOrder.size(); ++gi) { const u32 groupMat = matOrder[gi]; ParsedMesh& pm = out.meshes.emplace_back(); pm.name = matOrder.size() == 1 ? meshName : meshName + "#" + std::to_string(gi + 1); pm.skinned = meshSkinned; std::vector indices; if (matOrder.size() == 1) { indices.reserve(tris.size() * 3); pm.vertices = std::move(verts); for (const Tri& t : tris) { indices.push_back(t.i0); indices.push_back(t.i1); indices.push_back(t.i2); } } else { // 多材质:该组三角形共享逻辑顶点紧凑重排 std::unordered_map remap; indices.reserve(tris.size() * 3); for (const Tri& t : tris) { if (t.mat != groupMat) continue; const u32 src[3] = {t.i0, t.i1, t.i2}; for (u32 k = 0; k < 3; ++k) { auto it = remap.find(src[k]); if (it == remap.end()) { const u32 ni = static_cast(pm.vertices.size()); pm.vertices.push_back(verts[src[k]]); it = remap.emplace(src[k], ni).first; } indices.push_back(it->second); } } } if (!indices.empty()) { BuildLodChain(pm, std::move(indices)); } // 零权重"伪蒙皮"mesh(Blender 导出把全套 vertex group 写成 // cluster 但顶点无权重):顶点已按 static 烘挂载节点世界, // 这里仅需摘掉蒙皮标记——GPU 上 Σ0×palette = 原点塌成一坨 if (!bakeStatic) { bool allZero = true; for (const auto& v : pm.vertices) { if (v.weights.x + v.weights.y + v.weights.z + v.weights.w > 1e-6f) { allZero = false; break; } } if (allZero && !pm.vertices.empty()) { pm.skinned = false; } } if (m->materials.count > 0) { applyMaterial(pm, m->materials.data[groupMat]); } } } // ---- 动画:stack → layer → 每 node 三轨道(T/R/S)---- // ufbx 时间已是秒(tps=1)。rotation 原始曲线是欧拉角(度) // 且三轴 keys 可能不对齐 → 按三轴 key 时刻并集逐时刻求值后 // 组 quat(node 的 rotation_order 语义由 ufbx_euler_to_quat // 处理)。无曲线的轴回退节点静止姿态 for (size_t si = 0; si < scene->anim_stacks.count; ++si) { const ufbx_anim_stack* stack = scene->anim_stacks.data[si]; if (stack->layers.count == 0) continue; const ufbx_anim_layer* layer = stack->layers.data[0]; AnimationClip clip; clip.name = U8Str(stack->name); clip.tps = 1.0f; clip.duration = static_cast(stack->time_end - stack->time_begin); std::unordered_map nodeByName; nodeByName.reserve(out.nodes.size()); for (u32 i = 0; i < out.nodes.size(); ++i) { nodeByName.emplace(out.nodes[i].name, i); } // 三轴曲线 key 时刻并集(升序) auto unionTimes = [](const ufbx_anim_curve* const cs[3], std::vector& ts) { for (int a = 0; a < 3; ++a) { if (cs[a] == nullptr) continue; for (size_t k = 0; k < cs[a]->keyframes.count; ++k) { ts.push_back(cs[a]->keyframes.data[k].time); } } std::sort(ts.begin(), ts.end()); ts.erase(std::unique(ts.begin(), ts.end()), ts.end()); }; // 三轴求值:有曲线 evaluate,无曲线回退静止值(度/单位) auto evalAxis = [](const ufbx_anim_curve* c, double t, f32 rest) { return c != nullptr ? static_cast(ufbx_evaluate_curve(c, t, rest)) : rest; }; for (size_t ni = 0; ni < scene->nodes.count; ++ni) { const ufbx_node* n = scene->nodes.data[ni]; auto nit = nodeByName.find(U8Str(n->name)); if (nit == nodeByName.end()) continue; AnimChannel ch; ch.nodeIdx = nit->second; const ufbx_anim_prop* tp = ufbx_find_anim_prop( layer, &n->element, UFBX_Lcl_Translation); const ufbx_anim_prop* rp = ufbx_find_anim_prop( layer, &n->element, UFBX_Lcl_Rotation); const ufbx_anim_prop* sp = ufbx_find_anim_prop( layer, &n->element, UFBX_Lcl_Scaling); // T:逐并集时刻求值(缺省 = 静止平移) if (tp != nullptr && tp->anim_value != nullptr) { const ufbx_anim_curve* cs[3] = { tp->anim_value->curves[0], tp->anim_value->curves[1], tp->anim_value->curves[2]}; std::vector ts; unionTimes(cs, ts); if (!ts.empty()) { for (const double t : ts) { ch.positions.push_back({static_cast( t - stack->time_begin), {evalAxis(cs[0], t, static_cast(n->local_transform.translation.x)), evalAxis(cs[1], t, static_cast(n->local_transform.translation.y)), evalAxis(cs[2], t, static_cast(n->local_transform.translation.z))}}); } } } // R:三轴欧拉(度)→ quat(节点 rotation_order 语义)。 // FBX 的 PreRotation/轴心补偿折在 local_transform.rotation //(Mixamo 6.1/7.x 常态:静态 Lcl Rotation 全 0,绑定位姿 // 全在补偿里),动画曲线只是裸欧拉——直接当局部旋转会丢 // 补偿,人形撕裂。逐节点烘焙补偿进键值: // qPre = q_local_rest × inv(q_euler_rest) // 键 = qPre × euler_to_quat(anim euler) // 语义 = 完整局部旋转(与 localTransform 同构),求值器 // T*R*S 重建零改动 if (rp != nullptr && rp->anim_value != nullptr) { const ufbx_quat qEulerRest = ufbx_euler_to_quat( n->euler_rotation, n->rotation_order); ufbx_quat qRestInv = qEulerRest; // 共轭 = 逆(单位四元数) qRestInv.x = -qRestInv.x; qRestInv.y = -qRestInv.y; qRestInv.z = -qRestInv.z; const ufbx_quat qPre = ufbx_quat_mul(n->local_transform.rotation, qRestInv); const ufbx_anim_curve* cs[3] = { rp->anim_value->curves[0], rp->anim_value->curves[1], rp->anim_value->curves[2]}; std::vector ts; unionTimes(cs, ts); if (!ts.empty()) { for (const double t : ts) { const ufbx_vec3 euler = { evalAxis(cs[0], t, static_cast(n->euler_rotation.x)), evalAxis(cs[1], t, static_cast(n->euler_rotation.y)), evalAxis(cs[2], t, static_cast(n->euler_rotation.z))}; ufbx_quat q = ufbx_euler_to_quat( euler, n->rotation_order); q = ufbx_quat_mul(qPre, q); ch.rotations.push_back( {static_cast(t - stack->time_begin), glm::quat(static_cast(q.w), static_cast(q.x), static_cast(q.y), static_cast(q.z))}); } } } // S:逐并集时刻求值(缺省 = 静止缩放) if (sp != nullptr && sp->anim_value != nullptr) { const ufbx_anim_curve* cs[3] = { sp->anim_value->curves[0], sp->anim_value->curves[1], sp->anim_value->curves[2]}; std::vector ts; unionTimes(cs, ts); if (!ts.empty()) { for (const double t : ts) { ch.scales.push_back({static_cast( t - stack->time_begin), {evalAxis(cs[0], t, static_cast(n->local_transform.scale.x)), evalAxis(cs[1], t, static_cast(n->local_transform.scale.y)), evalAxis(cs[2], t, static_cast(n->local_transform.scale.z))}}); } } } if (!ch.positions.empty() || !ch.rotations.empty() || !ch.scales.empty()) { clip.channels.push_back(std::move(ch)); } } if (!clip.channels.empty()) { out.clips.push_back(std::move(clip)); } } // 返回前改名(勿在调用方对返回值二次包装——ParsedModel 携带 // 浅拷贝资源,RVO 断裂的强制拷贝会悬空崩溃) NormalizeClipNames(out, path); FinalizeParsed(out); return out; } } Model::Model(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, const std::string& path) : Model(device, allocator, descriptorManager, textureLayout) { // 同步便捷路径 = 两段式串行(等价异步收割后的主线程消费) LoadModel(path); } // 空模型骨架:只建默认纹理;mesh/材质由 FromParsed 填充 Model::Model(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout) : m_Device(device) , m_Allocator(allocator) , m_DescriptorManager(descriptorManager) , m_TextureLayout(textureLayout) { InitDefaultTextures(); } Model::~Model() = default; void Model::RebuildUnionBounds() { if (m_Meshes.empty()) { m_Bounds = {}; return; } // 各 mesh 包围球 → AABB 并集 → 外接球(保守,宁多画不漏画) glm::vec3 mn(1e9f), mx(-1e9f); for (const auto& mesh : m_Meshes) { const auto& b = mesh->GetBounds(); mn = glm::min(mn, b.center - glm::vec3(b.radius)); mx = glm::max(mx, b.center + glm::vec3(b.radius)); } m_Bounds.center = (mn + mx) * 0.5f; m_Bounds.radius = 0.5f * glm::length(mx - mn); } void Model::InitDefaultTextures() { // 各槽位兜底纹理(1x1,开销可忽略):语义单点维护在 // CreateDefaultMaterialTexture(MaterialAsset 构建端共用同一组) for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { m_DefaultTextures[slot] = CreateDefaultMaterialTexture( m_Device, m_Allocator, static_cast(slot)); } } Model::Model(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, ProceduralTag, MeshData&& geo, const std::array& rgba, bool transparent, const std::string& name) : m_Device(device) , m_Allocator(allocator) , m_DescriptorManager(descriptorManager) , m_TextureLayout(textureLayout) { // 纯色贴图直供 albedo 槽(alpha 非全 1 时为半透明材质); // 生命周期挂进纹理缓存(键含 rgba 值,同色共享、异色互不冲突) InitDefaultTextures(); Scope tex = (rgba[3] == 255) ? Texture::CreateSolidColor(device, allocator, rgba[0], rgba[1], rgba[2]) : Texture::CreateSolidColorRGBA(device, allocator, rgba[0], rgba[1], rgba[2], rgba[3]); Texture* albedoOverride = tex.get(); const std::string texKey = "proc:" + std::to_string(rgba[0]) + ',' + std::to_string(rgba[1]) + ',' + std::to_string(rgba[2]) + ',' + std::to_string(rgba[3]); m_TextureCache.emplace(texKey, std::move(tex)); // 几何(顶点/索引 + 逐顶点切线)由 MeshFactory 产出:单一几何源, // 各具名工厂不再各自内联一套立方体/平面 const std::vector> lodIndices = {std::move(geo.indices)}; // 材质/纹理生命周期随模型(缓存持有);颜色纹理必须显式覆盖 albedo // 槽——缺了会折到默认白纹理(白板叠白地 → 平面“消失”的教训) std::string paths[Material::kSlotCount] = {"", "", "", ""}; std::array overrides{}; overrides[Material::Albedo] = albedoOverride; Material* mat = GetOrCreateMaterial(paths, MaterialParams{}, transparent, overrides); m_Meshes.push_back(CreateScope( m_Device, m_Allocator, geo.vertices, lodIndices, mat)); // 内建材质快照(与 m_Meshes 下标对齐,编辑器材质槽 UI 不越界) m_MeshMaterialInfos.push_back({name, {}, MaterialParams{}, transparent}); RebuildUnionBounds(); } Scope Model::CreatePlane(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, f32 halfSize, u8 r, u8 g, u8 b) { return Scope(new Model(device, allocator, descriptorManager, textureLayout, ProceduralTag{}, MeshFactory::Plane(halfSize), std::array{r, g, b, 255}, false, "Plane")); } Scope Model::CreateTransparentPlane( Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, f32 halfSize, u8 r, u8 g, u8 b, u8 a) { return Scope(new Model(device, allocator, descriptorManager, textureLayout, ProceduralTag{}, MeshFactory::Plane(halfSize), std::array{r, g, b, a}, true, "Plane")); } Scope Model::CreateCube(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, f32 halfSize, u8 r, u8 g, u8 b) { return Scope(new Model(device, allocator, descriptorManager, textureLayout, ProceduralTag{}, MeshFactory::Cube(halfSize), std::array{r, g, b, 255}, false, "Cube")); } // 通用程序化图元:形状名 → MeshFactory 几何。非法形状名/空几何返回 // nullptr(调用方回退,绝不把空 mesh 塞进 Mesh) Scope Model::CreatePrimitive(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, const std::string& shape, const PrimitiveDesc& desc, u8 r, u8 g, u8 b) { if (!MeshFactory::IsKnownShape(shape)) return nullptr; MeshData geo = MeshFactory::Make(shape, desc); if (geo.vertices.empty() || geo.indices.empty()) return nullptr; // 显示名 = 形状名首字母大写(编辑器材质槽显示用) std::string name = shape; if (!name.empty()) { name[0] = static_cast(std::toupper( static_cast(name[0]))); } return Scope(new Model(device, allocator, descriptorManager, textureLayout, ProceduralTag{}, std::move(geo), std::array{r, g, b, 255}, false, name)); } // 资产显示名:文件 stem;无信息量(空 / 导入资产库的 "asset")时读 // 同目录 pathname(导入器记录的原始资产路径)取 basename;再回退父 // 目录名。编辑器 UI 与 clip 命名共用此约定(避免显示哈希 id) std::string Model::AssetDisplayName(const std::string& path) { const std::filesystem::path p(path); std::string stem = p.stem().string(); if (stem.empty() || stem == "asset") { std::ifstream pn(p.parent_path() / "pathname"); std::string line; if (std::getline(pn, line) && !line.empty()) { const auto base = std::filesystem::path(line).stem().string(); if (!base.empty()) stem = base; } if (stem.empty() || stem == "asset") { stem = p.parent_path().filename().string(); } } return stem; } // 无信息量的 take 名(导出器固定栈名 / 占位名):不得作为 clip 名展示 static bool IsPlaceholderClipName(const std::string& raw) { std::string s; s.reserve(raw.size()); for (char c : raw) { if (std::isalnum(static_cast(c))) { s.push_back(static_cast( std::tolower(static_cast(c)))); } } if (s.empty()) return true; static const char* const kPlaceholders[] = { "mixamocom", "mixamo", "take001", "take1", "unrealtake", "scene", "animation", "anim", "default", "unnamed", "asset", "clip", "motion", "sequence"}; for (const char* p : kPlaceholders) { if (s == p) return true; } return false; } // clip 命名统一化(Unity 语义):单 take → 文件名(AssetDisplayName); // 多 take → 保留文件内原始 take 名(如 glTF 的 walk/run/idle),仅当 // 名为导出器占位符(Mixamo 固定 "mixamo.com")或重名时才回退"名#序号"。 // 旧实现无条件用文件名覆盖,把 Xbot.glb 的 agree/idle/walk 全改成 // "Xbot#1..#7"——用户反馈"选项全是 xbot#1"。场景引用用 ClipIndex 下标, // 改名不破坏引用 static void NormalizeClipNames(ParsedModel& pm, const std::string& path) { if (pm.clips.empty()) return; const std::string stem = Model::AssetDisplayName(path); if (pm.clips.size() == 1) { pm.clips[0].name = stem; return; } std::vector names(pm.clips.size()); for (size_t i = 0; i < pm.clips.size(); ++i) { names[i] = IsPlaceholderClipName(pm.clips[i].name) ? std::string() : pm.clips[i].name; } for (size_t i = 0; i < pm.clips.size(); ++i) { bool fallback = names[i].empty(); for (size_t j = 0; j < pm.clips.size() && !fallback; ++j) { if (i != j && names[j] == names[i]) fallback = true; } if (fallback) { names[i] = stem + "#" + std::to_string(i + 1); } } for (size_t i = 0; i < pm.clips.size(); ++i) { pm.clips[i].name = names[i]; } } // 内嵌贴图落盘:"*"(assimp 的 aiTexture 表下标)不是磁盘路径—— // 内建材质有解析期字节池(ParsedModel::embeddedImages)可直接解码,但 // 覆盖材质(.material)与编辑器 UI 只认磁盘路径:交到 // VulkanAssetUploader 会被 exists() 判否,整槽退化默认纹理(塔菲 5 张 // 内嵌贴图 → 任意 Create Override 全白)。抽到 <模型目录>/textures/ 下 // 得到稳定真实路径,内建/覆盖两条消费链共用。返回空 = 抽取失败,调用 // 方保留 "*N" 走内嵌字节兜底(只读目录/磁盘满时功能不劣化) static std::string WriteEmbeddedTexture( const std::unordered_map>& images, const aiScene* scene, const std::filesystem::path& modelDir, const std::string& key) { const auto bit = images.find(key); if (bit == images.end() || bit->second.empty()) return {}; const int idx = atoi(key.c_str() + 1); if (idx < 0 || idx >= static_cast(scene->mNumTextures)) return {}; const aiTexture* tex = scene->mTextures[idx]; // 文件名:aiTexture 自带名(取 basename,glTF image.name)优先, // 空或以 '*' 开头则回退 embedded_;非法字符折叠 '_' std::string name = tex->mFilename.length ? tex->mFilename.C_Str() : ""; name = std::filesystem::path(name).filename().string(); if (name.empty() || name[0] == '*') { name = "embedded_" + std::to_string(idx); } for (char& c : name) { if (!std::isalnum(static_cast(c)) && c != '.' && c != '_' && c != '-') { c = '_'; } } // 扩展名:声明的格式提示优先,缺失/非法则按魔数判定(解码只看内容, // 扩展名仅为人可读,写错不影响 stbi) std::string ext(tex->achFormatHint); if (ext != "png" && ext != "jpg" && ext != "jpeg" && ext != "tga" && ext != "bmp") { const std::vector& bytes = bit->second; if (bytes.size() >= 4 && bytes[0] == 0x89 && bytes[1] == 'P' && bytes[2] == 'N' && bytes[3] == 'G') { ext = "png"; } else if (bytes.size() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8) { ext = "jpg"; } else { ext = "png"; } } std::filesystem::path file(name); if (file.extension() != ("." + ext)) { file.replace_extension("." + ext); } const std::filesystem::path dir = modelDir / "textures"; std::error_code ec; std::filesystem::create_directories(dir, ec); if (ec) { ENGINE_WARN("Embedded texture: cannot create '{}': {}", dir.string(), ec.message()); return {}; } std::filesystem::path target = dir / file; if (std::filesystem::exists(target)) { // 已落盘:同尺寸视为本次结果;尺寸不同(不同 image 重名)追加 // 下标区分,避免覆盖 std::error_code se; const auto size = std::filesystem::file_size(target, se); if (se || size != bit->second.size()) { target = dir / (file.stem().string() + "_" + std::to_string(idx) + file.extension().string()); } else { return target.string(); } } std::ofstream out(target, std::ios::binary | std::ios::trunc); if (!out) { ENGINE_WARN("Embedded texture: cannot write '{}'", target.string()); return {}; } out.write(reinterpret_cast(bit->second.data()), static_cast(bit->second.size())); if (!out) { ENGINE_WARN("Embedded texture: write failed '{}'", target.string()); return {}; } ENGINE_INFO("Embedded texture extracted: {} -> {}", key, target.string()); return target.string(); } // 遍历全部 mesh 的贴图槽,把 "*N" 就地重写成落盘后的真实路径;抽取失败 // 的槽保留 "*N"(内建材质经字节池仍可正常渲染) static void MaterializeEmbeddedTextures(ParsedModel& out, const aiScene* scene, const std::filesystem::path& modelDir) { if (out.embeddedImages.empty()) return; std::unordered_map resolved; // "*N" → 磁盘路径 for (ParsedMesh& mesh : out.meshes) { for (std::string& p : mesh.texPaths) { if (p.empty() || p[0] != '*') continue; auto it = resolved.find(p); if (it == resolved.end()) { it = resolved.emplace( p, WriteEmbeddedTexture(out.embeddedImages, scene, modelDir, p)).first; } if (!it->second.empty()) { p = it->second; } } } } ParsedModel Model::ParseFromDisk(const std::string& path) { ParsedModel out; out.path = path; // UV 翻转:所有格式统一走 aiProcess_FlipUVs,翻到引擎采样惯例 // (v=0 = 图像顶:stbi 行 0 即图顶、Vulkan texel(0,0) 即首行, // memcpy 直传不倒置)。各格式进入 assimp 前的 V 原点不同: // OBJ/FBX(assimp) —— 源 V 原点在底部 // glTF/GLB —— assimp glTF2 导入器无条件翻 V(glTF2Importer.cpp // "values[i].y = 1 - values[i].y"),把 glTF 规范 // 的 V-down(左上原点)转成 assimp 内部左下原点。 // → 到引擎手里已是左下原点,须与其它格式一样翻回 // 曾误以为"glTF UV 本就是左上原点、不用翻"——那忽略了 assimp 自己 // 已翻过一次:不翻 = 所有 glTF 贴图上下颠倒(法线/MR 全反 = 塑料感、 // 塔菲贴图错位元凶)。FBX 走 ufbx 分支在别处显式翻,与此处无关。 const std::string lower = [&] { std::string s = path; std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); return s; }(); out.isGltf = lower.size() >= 5 && (lower.rfind(".gltf") == lower.size() - 5 || lower.rfind(".glb") == lower.size() - 4); // FBX 统一走 ufbx(全版本含 6.1;assimp FBX importer 拒 Mixamo // 主流格式,见 ParseUfbxFromDisk 注释)。glTF/OBJ 仍走 assimp if (lower.size() >= 4 && lower.rfind(".fbx") == lower.size() - 4) { return ParseUfbxFromDisk(path); } Assimp::Importer importer; const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs | // 注意:不用 aiProcess_TransformUVCoords 烘焙 KHR_texture_transform // ——该步骤的复合顺序是"先平移后缩放"(TextureTransform.cpp // matrix = m2·m4·R·m3·m5,m5 最右先作用),与 glTF2Importer 写出 // AI_MATKEY_UVTRANSFORM 时假定的"先缩放旋转后平移"不一致, // 顺序差在 scale≠1 时结果偏出整数倍 tile(ChronographWatch // 背板实测)。改由 ProcessMesh 按正确约定自烘焙 aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | // 法线贴图 TBN aiProcess_JoinIdenticalVertices | aiProcess_LimitBoneWeights); // 每顶点限 4 权重(= 顶点格式 boneIds 容量) // 硬失败只有两种:scene 为 null(文件损坏/格式不支持)、无根节点。 // 注意 INCOMPLETE 不能当失败判据——assimp 对"无网格场景"统一打此 // 标记(FBXConverter:没读到 mesh 就置位),纯动画 FBX 必中,数据 // 本身完好。继续解析,最终有效性由 Valid()(mesh 或 clip 至少 // 其一非空)统一裁决 if (!scene || !scene->mRootNode) { ENGINE_ERROR("Model parse failed: '{}' ({})", path, importer.GetErrorString()); return out; // meshes/clips 空 = 无效 } if (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) { ENGINE_WARN("Assimp: no meshes in '{}' (anims: {}, materials: {}) " "- continuing for animation data", path, scene->mNumAnimations, scene->mNumMaterials); } // 骨骼动画三件套:node 表(global 累乘骨架)→ 动画轨道 → 骨骼注册 //(DFS 序;ProcessNode 的 mesh 遍历序与其独立,nodeIdx 靠 name 查表) FlattenNodes(scene->mRootNode, -1, out.nodes); ParseAnimations(scene, out.nodes, out.clips); NormalizeClipNames(out, path); const std::filesystem::path modelDir = std::filesystem::path(path).parent_path(); // 骨骼注册表(boneName → palette slot):多 mesh 共享骨架去重 std::unordered_map boneSlots; // GLB 内嵌贴图字节池("*N" → PNG/JPEG 原始数据) std::unordered_map> embeddedImages; ProcessNode(scene->mRootNode, scene, out.meshes, modelDir, out.isGltf, out.bones, boneSlots, embeddedImages); out.embeddedImages = std::move(embeddedImages); // 内嵌贴图落盘 + "*N" 重写为真实路径(覆盖材质/UI 的前置条件,见 // WriteEmbeddedTexture)。必须在 FinalizeParsed 前完成,texPaths 已定型 MaterializeEmbeddedTextures(out, scene, modelDir); // 收尾共用(骨骼 nodeIdx 回填 + 蒙皮 CPU 位置 + 日志):assimp 与 // ufbx 两路径语义一致 FinalizeParsed(out); return out; } u32 Model::FlattenNodes(const aiNode* node, i32 parent, std::vector& out) { const u32 idx = static_cast(out.size()); ModelNode n; n.name = node->mName.C_Str(); n.parent = parent; // 完整 mTransformation:FBX 的 PreRotation/单位换算都折在这里, // 骨骼 global 累乘必须用全量矩阵,不能拆 TRS 重建 n.localTransform = ToGlm(node->mTransformation); out.push_back(std::move(n)); for (u32 i = 0; i < node->mNumChildren; ++i) { FlattenNodes(node->mChildren[i], static_cast(idx), out); } return idx; } void Model::ParseAnimations(const aiScene* scene, const std::vector& nodes, std::vector& out) { if (scene->mNumAnimations == 0) return; // nodeName → node 表下标(channel 的目标必须能在层级里累乘) std::unordered_map nodeByName; nodeByName.reserve(nodes.size()); for (u32 i = 0; i < nodes.size(); ++i) { nodeByName.emplace(nodes[i].name, i); } for (u32 a = 0; a < scene->mNumAnimations; ++a) { const aiAnimation* anim = scene->mAnimations[a]; AnimationClip clip; clip.name = anim->mName.length > 0 ? anim->mName.C_Str() : ("clip_" + std::to_string(a)); // tps=0 按 1(assimp 对部分导出器的惯例缺省) clip.tps = anim->mTicksPerSecond > 0.0 ? static_cast(anim->mTicksPerSecond) : 1.0f; clip.duration = static_cast(anim->mDuration) / clip.tps; for (u32 c = 0; c < anim->mNumChannels; ++c) { const aiNodeAnim* ch = anim->mChannels[c]; auto it = nodeByName.find(ch->mNodeName.C_Str()); if (it == nodeByName.end()) { ENGINE_WARN("Anim '{}': channel node '{}' not in hierarchy, skipped", clip.name, ch->mNodeName.C_Str()); continue; } AnimChannel chan; chan.nodeIdx = it->second; // 关键帧 time 统一换算成秒(运行时采样免除法) chan.positions.reserve(ch->mNumPositionKeys); for (u32 k = 0; k < ch->mNumPositionKeys; ++k) { const auto& key = ch->mPositionKeys[k]; chan.positions.push_back({ static_cast(key.mTime) / clip.tps, {key.mValue.x, key.mValue.y, key.mValue.z}}); } chan.rotations.reserve(ch->mNumRotationKeys); for (u32 k = 0; k < ch->mNumRotationKeys; ++k) { const auto& key = ch->mRotationKeys[k]; // aiQuaternion 存储序 (w,x,y,z) → glm 构造序一致 chan.rotations.push_back({ static_cast(key.mTime) / clip.tps, glm::quat(key.mValue.w, key.mValue.x, key.mValue.y, key.mValue.z)}); } chan.scales.reserve(ch->mNumScalingKeys); for (u32 k = 0; k < ch->mNumScalingKeys; ++k) { const auto& key = ch->mScalingKeys[k]; chan.scales.push_back({ static_cast(key.mTime) / clip.tps, {key.mValue.x, key.mValue.y, key.mValue.z}}); } clip.channels.push_back(std::move(chan)); } out.push_back(std::move(clip)); } } void Model::CollectMeshBones(const aiMesh* mesh, std::vector& vertices, std::vector& bones, std::unordered_map& boneSlots) { for (u32 b = 0; b < mesh->mNumBones; ++b) { const aiBone* bone = mesh->mBones[b]; const char* name = bone->mName.C_Str(); u32 slot; if (auto it = boneSlots.find(name); it != boneSlots.end()) { slot = it->second; // 多 mesh 共享同一骨架(身体/附件分区) } else { slot = static_cast(bones.size()); boneSlots.emplace(name, slot); // nodeIdx 由 ParseFromDisk 统一回填(node 表此时已就绪) bones.push_back({name, 0, ToGlm(bone->mOffsetMatrix)}); } // 顶点权重累加:LimitBoneWeights 保证每顶点 ≤4 根骨,逐槽找空位; // 4 槽满的理论残留权重直接忽略(量化误差级) for (u32 w = 0; w < bone->mNumWeights; ++w) { const aiVertexWeight& vw = bone->mWeights[w]; if (vw.mVertexId >= vertices.size() || vw.mWeight <= 0.0f) continue; Vertex& v = vertices[vw.mVertexId]; for (u32 k = 0; k < 4; ++k) { if (v.weights[k] == 0.0f) { v.boneIds[k] = slot; v.weights[k] = vw.mWeight; break; } } } } } Scope Model::FromParsed(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, ParsedModel&& parsed) { if (!parsed.Valid()) { ENGINE_ERROR("Model::FromParsed: invalid parsed model ({})", parsed.path); return nullptr; } auto model = Scope(new Model(device, allocator, descriptorManager, textureLayout)); model->m_ModelPath = parsed.path; model->m_IsGltf = parsed.isGltf; model->m_Nodes = std::move(parsed.nodes); model->m_Bones = std::move(parsed.bones); model->m_Clips = std::move(parsed.clips); model->m_EmbeddedImages = std::move(parsed.embeddedImages); // 材质缓存去重 + 纹理上传 + mesh 缓冲(全部 GPU,主线程) for (auto& pm : parsed.meshes) { // 导入资产一律按不透明建材质:DCC/MMD 常把整只模型的材质都标 // alphaMode=BLEND(塔菲.glb 36 个材质槽全是 BLEND),若照搬进 // 透明队列 → 整只角色走混合 + 关深度写 → 身体部件互相穿透/消失。 // pm.transparent 只作元数据登记(编辑器/覆盖快照读),不驱动队列 Material* mat = model->GetOrCreateMaterial(pm.texPaths.data(), pm.params); model->m_MeshMaterialInfos.push_back( {pm.name, pm.texPaths, pm.params, pm.transparent}); model->m_Meshes.push_back(CreateScope( device, allocator, pm.vertices, pm.lods, mat, pm.cpuPositions.empty() ? nullptr : &pm.cpuPositions)); } ENGINE_INFO("Model loaded: {} ({} meshes, {} textures)", parsed.path, model->m_Meshes.size(), model->m_TextureCache.size()); model->RebuildUnionBounds(); return model; } void Model::LoadModel(const std::string& path) { // 同步路径:解析 + GPU 构建串行(与 FromParsed 相同的消费逻辑) ParsedModel parsed = ParseFromDisk(path); if (!parsed.Valid()) { return; } m_ModelPath = parsed.path; m_IsGltf = parsed.isGltf; m_Nodes = std::move(parsed.nodes); m_Bones = std::move(parsed.bones); m_Clips = std::move(parsed.clips); m_EmbeddedImages = std::move(parsed.embeddedImages); for (auto& pm : parsed.meshes) { // 同 FromParsed:导入资产一律不透明建材质(理由见彼处注释) Material* mat = GetOrCreateMaterial(pm.texPaths.data(), pm.params); m_MeshMaterialInfos.push_back( {pm.name, pm.texPaths, pm.params, pm.transparent}); m_Meshes.push_back(CreateScope( m_Device, m_Allocator, pm.vertices, pm.lods, mat, pm.cpuPositions.empty() ? nullptr : &pm.cpuPositions)); } ENGINE_INFO("Model loaded: {} ({} meshes, {} textures)", path, m_Meshes.size(), m_TextureCache.size()); RebuildUnionBounds(); } void Model::ProcessNode(const aiNode* node, const aiScene* scene, std::vector& out, const std::filesystem::path& modelDir, bool isGltf, std::vector& bones, std::unordered_map& boneSlots, std::unordered_map>& embeddedImages, const aiMatrix4x4& parentGlobal) { // 累乘节点链全局变换:Blender/Sketchfab 导出的 FBX(未勾 Apply // Transform)把物体旋转(含 Z-up→Y-up)与 cm→m 单位换算全折在 // 节点变换上,忽略会让每个零件按原始局部空间乱画(朝向丢失、 // 尺度差百倍,车都不成车) const aiMatrix4x4 global = parentGlobal * node->mTransformation; for (u32 i = 0; i < node->mNumMeshes; ++i) { aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; out.push_back(ProcessMesh(mesh, scene, modelDir, isGltf, bones, boneSlots, embeddedImages, global)); } for (u32 i = 0; i < node->mNumChildren; ++i) { ProcessNode(node->mChildren[i], scene, out, modelDir, isGltf, bones, boneSlots, embeddedImages, global); } } Texture* Model::GetOrLoadTexture(const std::string& rawPath, bool srgb) { // FBX/DCC 导出的路径常带反斜杠(Windows 语义),统一成正斜杠 std::string path = rawPath; std::replace(path.begin(), path.end(), '\\', '/'); // 缓存键带 sRGB 语义:同一路径可能同时被颜色槽与数据槽引用 std::string key = (srgb ? "s:" : "l:") + path; auto it = m_TextureCache.find(key); if (it != m_TextureCache.end()) { return it->second.get(); } // GLB 内嵌贴图("*N"):字节在解析期随 ParsedModel 带过来, // 此处(主线程、GPU 上下文)解码上传 if (!path.empty() && path[0] == '*') { const auto emb = m_EmbeddedImages.find(path); if (emb == m_EmbeddedImages.end()) { ENGINE_WARN("Embedded texture bytes not carried: {}", path); return nullptr; } auto tex = Texture::CreateFromEncodedMemory( m_Device, m_Allocator, emb->second.data(), static_cast(emb->second.size()), srgb); if (!tex) { ENGINE_WARN("Embedded texture decode failed: {}", path); return nullptr; } Texture* ptr = tex.get(); m_TextureCache.emplace(key, std::move(tex)); return ptr; } // 文件不存在则用兜底纹理 if (!std::filesystem::exists(path)) { ENGINE_WARN("Texture not found, using default: {}", path); return nullptr; } auto tex = CreateScope(m_Device, m_Allocator, path, srgb); Texture* ptr = tex.get(); m_TextureCache.emplace(key, std::move(tex)); return ptr; } Material* Model::GetOrCreateMaterial(const std::string paths[Material::kSlotCount], const MaterialParams& params, bool transparent, const std::array& slotOverrides) { // 程序化覆盖纹理以指针地址入键:同模型内不同颜色的覆盖组合 //(如两块玻璃板)各得一份材质,不与纯路径键的材质串缓存 std::string key = Material::MakeCacheKey(paths, params, transparent); bool hasOverride = false; for (const Texture* t : slotOverrides) { if (t) { hasOverride = true; break; } } if (hasOverride) { key += "@ovr"; for (const Texture* t : slotOverrides) { key += t ? ":" + std::to_string(reinterpret_cast(t)) : ":0"; } } if (auto it = m_MaterialCache.find(key); it != m_MaterialCache.end()) { return it->second.get(); } // 每槽位解析纹理:程序化覆盖优先,路径次之,空/缺失折到默认纹理 std::array textures{}; // 槽位 sRGB 语义:颜色图 true,数据图 false const bool slotSrgb[Material::kSlotCount] = {true, false, false, true}; for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { Texture* tex = slotOverrides[slot]; if (!tex && !paths[slot].empty()) { tex = GetOrLoadTexture(paths[slot], slotSrgb[slot]); } textures[slot] = tex ? tex : m_DefaultTextures[slot].get(); } auto mat = CreateScope( m_Device, m_Allocator, m_DescriptorManager, m_TextureLayout, textures, params, transparent); Material* ptr = mat.get(); m_MaterialCache.emplace(key, std::move(mat)); return ptr; } ParsedMesh Model::ProcessMesh(const aiMesh* mesh, const aiScene* scene, const std::filesystem::path& modelDir, bool isGltf, std::vector& bones, std::unordered_map& boneSlots, std::unordered_map>& embeddedImages, const aiMatrix4x4& globalTransform) { ParsedMesh out; std::vector& vertices = out.vertices; std::vector indices; vertices.reserve(mesh->mNumVertices); // 有骨骼的 mesh:顶点权重先清零再由 CollectMeshBones 灌入—— // 默认 w=(1,0,0,0) 的槽 0 若不清,palette[0] 的 identity 会以 // 权重 1 混进蒙皮矩阵(顶点永远钉在原点) const bool hasBones = mesh->mNumBones > 0; // 非蒙皮 mesh:烘焙节点链全局变换(旋转/单位换算折进顶点); // 蒙皮 mesh 保持 mesh 空间——palette × offsetMatrix 的蒙皮 // 约定依赖原空间,烘焙会与动画路径打架 const bool bakeTransform = !hasBones; const aiMatrix3x3 normalMat(globalTransform); for (u32 i = 0; i < mesh->mNumVertices; ++i) { Vertex vertex{}; if (bakeTransform) { const aiVector3D p = globalTransform * mesh->mVertices[i]; vertex.position = {p.x, p.y, p.z}; } else { vertex.position = { mesh->mVertices[i].x, mesh->mVertices[i].y, mesh->mVertices[i].z }; } if (mesh->HasNormals()) { const aiVector3D n = bakeTransform ? normalMat * mesh->mNormals[i] : mesh->mNormals[i]; const glm::vec3 nn{n.x, n.y, n.z}; vertex.normal = bakeTransform ? glm::normalize(nn) : nn; } if (mesh->mTextureCoords[0]) { vertex.uv = { mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y }; } // 切线(法线贴图 TBN;无切线数据置零,shader 侧退回几何法线) if (mesh->mTangents) { const aiVector3D t = bakeTransform ? normalMat * mesh->mTangents[i] : mesh->mTangents[i]; const glm::vec3 tt{t.x, t.y, t.z}; vertex.tangent = bakeTransform ? glm::normalize(tt) : tt; } if (hasBones) { vertex.weights = glm::vec4(0.0f); } vertices.push_back(vertex); } // 骨骼权重灌顶点(无骨骼 mesh 顶点保持默认 idx0/w1 → identity 直通) CollectMeshBones(mesh, vertices, bones, boneSlots); out.skinned = hasBones; for (u32 i = 0; i < mesh->mNumFaces; ++i) { aiFace face = mesh->mFaces[i]; for (u32 j = 0; j < face.mNumIndices; ++j) { indices.push_back(face.mIndices[j]); } } // 读取材质纹理路径(PBR metallic-roughness 槽位)+ 金属度/粗糙度因子 std::string texPaths[Material::kSlotCount]; MaterialParams params; std::string materialName; // 材质资产名(UI 材质槽显示/覆盖文件命名优先用它) if (mesh->mMaterialIndex >= 0) { const aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; // 材质名(glTF material name / FBX 材质名):Blender 导出的 mesh // 名是 "Plane.023" 这类几何拓扑名,对"这是哪个材质"毫无信息量; // 材质名才是 UE Material Slots 列表的语义 aiString matName; if (material && material->Get(AI_MATKEY_NAME, matName) == aiReturn_SUCCESS && matName.length > 0) { materialName = matName.C_Str(); } // glTF2 贴图在 assimp 5.x 的落点:MR 合图 → METALNESS(15)/ // DIFFUSE_ROUGHNESS(16)(同图双槽);baseColor → BASE_COLOR(12)。 // OBJ:map_Ns → SHININESS(独立 roughness 图)、map_Ka 常绑 AO。 // 先到先得,数组顺序即优先级。 // 注意:FBX 独立 metalness/roughness 是两张图——metal 落 MR 槽 //(灰度 R=G=B,shader 读 .b 当金属度恰好正确),roughness 落 // 独立 Roughness 槽(factors.z=1 启用),不再挤占 MR 槽的 .g const std::pair slots[] = { {aiTextureType_DIFFUSE, Material::Albedo}, {aiTextureType_BASE_COLOR, Material::Albedo}, {aiTextureType_NORMALS, Material::Normal}, {aiTextureType_HEIGHT, Material::Normal}, // OBJ map_Bump 落点 {aiTextureType_METALNESS, Material::MetallicRoughness}, {aiTextureType_DIFFUSE_ROUGHNESS, Material::Roughness}, {aiTextureType_SHININESS, Material::Roughness}, // OBJ map_Ns {aiTextureType_UNKNOWN, Material::MetallicRoughness}, {aiTextureType_AMBIENT_OCCLUSION, Material::Ao}, {aiTextureType_LIGHTMAP, Material::Ao}, {aiTextureType_AMBIENT, Material::Ao}, {aiTextureType_EMISSIVE, Material::Emissive}, {aiTextureType_EMISSION_COLOR, Material::Emissive}, }; // 每槽命中源类型:KHR_texture_transform 在 assimp 侧按 // aiTextureType 键控(AI_MATKEY_UVTRANSFORM(type, 0)),必须按 // 命中时的同款类型查询才能取到对应贴图的变换 aiTextureType slotSrcType[Material::kSlotCount] = {}; for (const auto& [type, slot] : slots) { if (!texPaths[slot].empty()) continue; // 高优先级已命中 aiString path; if (material->GetTexture(type, 0, &path) == AI_SUCCESS && path.length > 0) { slotSrcType[slot] = type; if (path.C_Str()[0] == '*') { // GLB/gltf 内嵌贴图:assimp 记作 "*"(aiTexture // 表下标),不是磁盘路径——解析期拷出原始编码字节, // 实例期 GetOrLoadTexture 解码上传(解析线程无 GPU) texPaths[slot] = path.C_Str(); if (!embeddedImages.count(path.C_Str())) { const int texIdx = atoi(path.C_Str() + 1); if (texIdx < 0 || texIdx >= static_cast(scene->mNumTextures)) { ENGINE_WARN("Embedded texture index out of range: " "{}", path.C_Str()); continue; } const aiTexture* t = scene->mTextures[texIdx]; if (t->mHeight != 0) { // 未压缩 ARGB8888 内嵌数据(罕见):折默认纹理 ENGINE_WARN("Uncompressed embedded texture " "unsupported: {}", path.C_Str()); continue; } const u8* src = reinterpret_cast(t->pcData); embeddedImages[path.C_Str()].assign( src, src + t->mWidth); } } else { texPaths[slot] = (modelDir / path.C_Str()).string(); } } } // 注:不做文件名探测/自动补槽——资产缺什么就是什么; // 补槽是将来手动材质编辑的职责,加载期保持确定性 // 材质加载可见性:贴图槽缺失/路径解析错误会静默退化成默认 // 纹理(金属全没 → 塑料感),这里留下诊断锚点 ENGINE_INFO("Material '{}': albedo={} normal={} MR={} roughness={} " "emissive={} ao={}", material->GetName().C_Str(), texPaths[Material::Albedo].empty() ? "" : texPaths[Material::Albedo], texPaths[Material::Normal].empty() ? "" : texPaths[Material::Normal], texPaths[Material::MetallicRoughness].empty() ? "" : texPaths[Material::MetallicRoughness], texPaths[Material::Roughness].empty() ? "" : texPaths[Material::Roughness], texPaths[Material::Emissive].empty() ? "" : texPaths[Material::Emissive], texPaths[Material::Ao].empty() ? "" : texPaths[Material::Ao]); // 因子按工作流分支: // glTF2:MR 合图(B=金属度语义),因子按声明(缺省 1/1) // OBJ:specular 工作流无金属概念 → metallic=0; // roughness 有独立贴图 → 因子 1(G 通道直达); // 无贴图 → Blinn-Phong Ns 换算 R=sqrt(2/(Ns+2)), // 补偿默认 MR 贴图 G=0.5 → 因子 = 2R if (isGltf) { ai_real metallic = 1.0f, roughness = 1.0f; material->Get(AI_MATKEY_METALLIC_FACTOR, metallic); material->Get(AI_MATKEY_ROUGHNESS_FACTOR, roughness); params.factors = glm::vec4(static_cast(metallic), static_cast(roughness), 0.0f, 0.0f); // baseColorFactor:rgb 调色进 albedo;a 是 BLEND 材质的透明度 //(玻璃等无 baseColor 贴图、纯靠 factor alpha 的材质全靠它, // 缺了会退化成兜底白纹理的 a=1 = 完全不透) aiColor4D baseColor(1.0f, 1.0f, 1.0f, 1.0f); if (material->Get(AI_MATKEY_BASE_COLOR, baseColor) == AI_SUCCESS) { params.baseColor = glm::vec4(baseColor.r, baseColor.g, baseColor.b, baseColor.a); } // glTF alphaMode: // BLEND = 半透明(透明绘制队列 + 深度只测不写) // MASK = 镂空(factors.w=1 → shader 侧 alpha test 丢弃, // 表针/栅栏等硬边贴图常见)仍走不透明队列 // OBJ/FBX 无此键 → 恒不透明(资产格式定义写死) aiString alphaMode; if (material->Get(AI_MATKEY_GLTF_ALPHAMODE, alphaMode) == AI_SUCCESS) { if (alphaMode == aiString("BLEND")) { out.transparent = true; } else if (alphaMode == aiString("MASK")) { params.factors.w = 1.0f; } } // 表面扩展四键(assimp 仅在扩展存在时写出,缺省 = 物理默认): // KHR_materials_ior → REFRACTI(缺省 1.5) // KHR_materials_transmission → TRANSMISSION_FACTOR(缺省 0) // KHR_materials_emissive_strength → EMISSIVE_INTENSITY(缺省 1) // KHR_materials_volume → VOLUME_THICKNESS_FACTOR(缺省 0) ai_real ior = 1.5f; material->Get(AI_MATKEY_REFRACTI, ior); ai_real transmission = 0.0f; material->Get(AI_MATKEY_TRANSMISSION_FACTOR, transmission); ai_real emissiveStrength = 1.0f; material->Get(AI_MATKEY_EMISSIVE_INTENSITY, emissiveStrength); ai_real thickness = 0.0f; material->Get(AI_MATKEY_VOLUME_THICKNESS_FACTOR, thickness); params.surface = glm::vec4(static_cast(ior), static_cast(transmission), static_cast(emissiveStrength), static_cast(thickness)); // glTF emissiveFactor → assimp EMISSION_COLOR。非黑直接写; // 有 emissive 贴图但 factor 黑的导出器(factor 缺省不写)按 // glTF 惯例设白——贴图语义就是"贴图即发光强度分布",乘 0 // 会把本来正常的自发光整个吃掉 aiColor3D emissiveColor(0.0f, 0.0f, 0.0f); material->Get(AI_MATKEY_COLOR_EMISSIVE, emissiveColor); if (emissiveColor.r > 0.0f || emissiveColor.g > 0.0f || emissiveColor.b > 0.0f) { params.emissiveFactor = glm::vec4( static_cast(emissiveColor.r), static_cast(emissiveColor.g), static_cast(emissiveColor.b), 0.0f); } else if (!texPaths[Material::Emissive].empty()) { params.emissiveFactor = glm::vec4(1.0f, 1.0f, 1.0f, 0.0f); } // transmission > 0 的材质必须走透明队列:物理透射表面要采样 // 不透明场景副本做折射。Khronos 新式玻璃写法是 alphaMode=OPAQUE // + transmission=1(透明度语义全在 transmission,alpha 恒 1)—— // 只认 alphaMode=BLEND 会把玻璃送进不透明队列,画成白 albedo // 镜面实心盖板(表盘/指针全被挡住,正面"糊成一坨") if (static_cast(transmission) > 0.0f) { out.transparent = true; } // KHR_texture_transform:每张贴图各带独立 offset/scale/rotation //(AnimationPointerUVs 一致性模型:82 材质共享一张棋盘格、 // 各槽变换各异),烘焙进顶点 UV 不可行(顶点 UV 只有一份), // 改写入材质参数、shader 采样前逐槽应用。 // // importer 把 glTF 参数换算进 assimp 约定(缩放 → 绕 (0.5,0.5) // 旋转 -rot → 平移)写入 UVTRANSFORM;该换算在 θ≠0 时与 glTF // 规范语义不符(数值实测误差 0.44+),θ=0 才精确。这里先从 // assimp 值精确反演回原始 glTF 参数(off = 规范 offset): // rot = -mRotation // off.x = mTrl.x - 0.5·sx·(-c + s + 1) // off.y = 1 - sy - mTrl.y + 0.5·sy·(s + c - 1) // 顶点 UV 现已归一到 glTF 规范空间(v=0 = 图像顶,见上文 // aiProcess_FlipUVs),直接套用规范式: // uv' = off + R(rot)·S(scale)·uv // A = [[c·sx, -s·sy], [s·sx, c·sy]](行主序摊平 vec4) // t = (off.x, off.y) for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { if (texPaths[slot].empty()) continue; aiUVTransform at; if (material->Get(AI_MATKEY_UVTRANSFORM(slotSrcType[slot], 0), at) != AI_SUCCESS) { continue; } const f32 sx = static_cast(at.mScaling.x); const f32 sy = static_cast(at.mScaling.y); const f32 rot = -static_cast(at.mRotation); const f32 c = std::cos(rot); const f32 s = std::sin(rot); const f32 offx = static_cast(at.mTranslation.x) - 0.5f * sx * (-c + s + 1.0f); const f32 offy = 1.0f - sy - static_cast(at.mTranslation.y) + 0.5f * sy * (s + c - 1.0f); params.uvA[slot] = glm::vec4(c * sx, -s * sy, s * sx, c * sy); params.uvT[slot] = glm::vec4(offx, offy, 0.0f, 0.0f); } // KHR_materials_unlit:misc.x=1 → shader 早退直出 albedo int shadingModel = 0; if (material->Get(AI_MATKEY_SHADING_MODEL, shadingModel) == AI_SUCCESS && shadingModel == aiShadingMode_Unlit) { params.misc.x = 1.0f; } } else { // FBX/OBJ 独立贴图工作流 const bool hasSepRough = !texPaths[Material::Roughness].empty(); f32 roughnessFactor = 1.0f; if (!hasSepRough && texPaths[Material::MetallicRoughness].empty()) { // 无粗糙度贴图 → Blinn-Phong Ns 换算 R=sqrt(2/(Ns+2)), // 补偿默认 MR 贴图 G=0.5 → 因子 = 2R ai_real shininess = 10.0f; material->Get(AI_MATKEY_SHININESS, shininess); const f32 rough = std::sqrt(2.0f / (static_cast(shininess) + 2.0f)); roughnessFactor = 2.0f * glm::clamp(rough, 0.05f, 1.0f); } // 金属度图落 MR 槽(灰度 .b 即金属度语义): // 有图 metallic 因子 = 1,无图 = 0(OBJ specular 工作流无金属概念) const f32 metallicFactor = texPaths[Material::MetallicRoughness].empty() ? 0.0f : 1.0f; params.factors = glm::vec4(metallicFactor, roughnessFactor, hasSepRough ? 1.0f : 0.0f, 0.0f); } } // 材质创建延迟到 GPU 构建段(FromParsed):解析期只留引用 for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { out.texPaths[slot] = std::move(texPaths[slot]); } out.params = params; // out.transparent 已在 glTF 分支读取 // UI 名字:材质名优先(语义对齐 UE Material Slots),无材质名 //(罕见)回退 mesh 名 out.name = !materialName.empty() ? materialName : mesh->mName.C_Str(); // ---- LOD 链(解析期生成,worker 线程免费时间):---- // LOD0 = 原始;每级目标索引数减半,相对误差 1e-2。顶点共享, // 简化只重排索引(meshopt_simplify 从 LOD0 单次起点逐级向下) out.lods.push_back(std::move(indices)); for (u32 lod = 1; lod < kMeshLodCount; ++lod) { const size_t target = out.lods[0].size() >> lod; // 太小的网格不简化:简化收益低于误差风险,直接复用上一级 if (target < 96 || out.lods[0].size() < 192) { out.lods.push_back(out.lods.back()); continue; } std::vector dest(out.lods[0].size()); float error = 0.0f; const size_t count = meshopt_simplify( dest.data(), out.lods[0].data(), out.lods[0].size(), &out.vertices[0].position.x, out.vertices.size(), sizeof(Vertex), target, 1e-2f, 0, &error); if (count == 0) { out.lods.push_back(out.lods.back()); // 简化失败兜底 continue; } dest.resize(count); out.lods.push_back(std::move(dest)); } return out; } } // namespace Engine