新增 MeshFactory(Cube/Plane/Sphere/Cylinder/Cone/Torus + 切线计算), Model 程序化几何改走 MeshFactory(收敛为单一几何源,消除重复的顶点 生成),并新增 CreatePrimitive;打通 AssetManager/IAssetUploader/ VulkanAssetUploader 的 CreatePrimitive 链路;编辑器 Procedural 弹窗 下拉 + ResolveProcedural 伪路径反解 + 场景序列化(程序化图元可存取)。
1858 lines
87 KiB
C++
1858 lines
87 KiB
C++
//
|
||
// Created by atdunbg on 2026/9/21.
|
||
//
|
||
|
||
#include <Engine/Renderer/Model.h>
|
||
#include <Engine/Renderer/Material.h>
|
||
#include <Engine/Renderer/MaterialAsset.h> // CreateDefaultMaterialTexture
|
||
#include <Engine/Vulkan/Device.h>
|
||
#include <Engine/Vulkan/MemoryAllocator.h>
|
||
#include <Engine/Vulkan/DescriptorManager.h>
|
||
#include <Engine/Vulkan/Texture.h>
|
||
#include <Engine/Core/Log.h>
|
||
|
||
#include <assimp/Importer.hpp>
|
||
#include <assimp/scene.h>
|
||
#include <assimp/postprocess.h>
|
||
#include <assimp/GltfMaterial.h> // AI_MATKEY_GLTF_ALPHAMODE
|
||
|
||
#include <ufbx.h> // FBX 全版本导入(6.1 兼容,见 ParseUfbxFromDisk)
|
||
|
||
#include <meshoptimizer.h>
|
||
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <algorithm>
|
||
#include <cctype>
|
||
#include <cmath>
|
||
#include <functional>
|
||
|
||
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<f32>(m.cols[c].x);
|
||
out[c][1] = static_cast<f32>(m.cols[c].y);
|
||
out[c][2] = static_cast<f32>(m.cols[c].z);
|
||
}
|
||
out[3][0] = static_cast<f32>(m.cols[3].x);
|
||
out[3][1] = static_cast<f32>(m.cols[3].y);
|
||
out[3][2] = static_cast<f32>(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<u32>&& 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<u32> 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<std::string, u32> 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<glm::mat4> 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<glm::mat4> 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<std::string> 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<void(ufbx_node*, i32)> flatten =
|
||
[&](ufbx_node* n, i32 parent) {
|
||
const u32 idx = static_cast<u32>(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<i32>(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<const ufbx_mesh*, const ufbx_node*> meshMount;
|
||
std::function<void(ufbx_node*)> 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<f32>(base.value_vec4.x),
|
||
static_cast<f32>(base.value_vec4.y),
|
||
static_cast<f32>(base.value_vec4.z),
|
||
static_cast<f32>(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()
|
||
? "<none>" : dst.texPaths[Material::Albedo],
|
||
dst.texPaths[Material::MetallicRoughness].empty()
|
||
? "<none>" : dst.texPaths[Material::MetallicRoughness],
|
||
dst.texPaths[Material::Normal].empty()
|
||
? "<none>" : dst.texPaths[Material::Normal],
|
||
dst.texPaths[Material::Ao].empty()
|
||
? "<none>" : dst.texPaths[Material::Ao],
|
||
dst.texPaths[Material::Emissive].empty()
|
||
? "<none>" : dst.texPaths[Material::Emissive]);
|
||
};
|
||
|
||
std::unordered_map<std::string, u32> 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<Vertex> 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<f32>(p.x),
|
||
static_cast<f32>(p.y),
|
||
static_cast<f32>(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<f32>(nrm.x),
|
||
static_cast<f32>(nrm.y),
|
||
static_cast<f32>(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<f32>(uv.x),
|
||
1.0f - static_cast<f32>(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<f32>(t.x),
|
||
static_cast<f32>(t.y),
|
||
static_cast<f32>(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<u32>(
|
||
std::min<size_t>(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<u32>(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<f32>(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<Tri> 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<u32>(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<u32> 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<u32> 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<u32, u32> 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<u32>(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<f32>(stack->time_end
|
||
- stack->time_begin);
|
||
|
||
std::unordered_map<std::string, u32> 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<double>& 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<f32>(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<double> ts;
|
||
unionTimes(cs, ts);
|
||
if (!ts.empty()) {
|
||
for (const double t : ts) {
|
||
ch.positions.push_back({static_cast<f32>(
|
||
t - stack->time_begin),
|
||
{evalAxis(cs[0], t, static_cast<f32>(n->local_transform.translation.x)),
|
||
evalAxis(cs[1], t, static_cast<f32>(n->local_transform.translation.y)),
|
||
evalAxis(cs[2], t, static_cast<f32>(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<double> ts;
|
||
unionTimes(cs, ts);
|
||
if (!ts.empty()) {
|
||
for (const double t : ts) {
|
||
const ufbx_vec3 euler = {
|
||
evalAxis(cs[0], t, static_cast<f32>(n->euler_rotation.x)),
|
||
evalAxis(cs[1], t, static_cast<f32>(n->euler_rotation.y)),
|
||
evalAxis(cs[2], t, static_cast<f32>(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<f32>(t - stack->time_begin),
|
||
glm::quat(static_cast<f32>(q.w),
|
||
static_cast<f32>(q.x),
|
||
static_cast<f32>(q.y),
|
||
static_cast<f32>(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<double> ts;
|
||
unionTimes(cs, ts);
|
||
if (!ts.empty()) {
|
||
for (const double t : ts) {
|
||
ch.scales.push_back({static_cast<f32>(
|
||
t - stack->time_begin),
|
||
{evalAxis(cs[0], t, static_cast<f32>(n->local_transform.scale.x)),
|
||
evalAxis(cs[1], t, static_cast<f32>(n->local_transform.scale.y)),
|
||
evalAxis(cs[2], t, static_cast<f32>(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<Material::Slot>(slot));
|
||
}
|
||
}
|
||
|
||
Model::Model(Device& device, MemoryAllocator& allocator,
|
||
DescriptorManager& descriptorManager,
|
||
vk::DescriptorSetLayout textureLayout,
|
||
ProceduralTag, MeshData&& geo, const std::array<u8, 4>& 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<Texture> 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<std::vector<u32>> lodIndices = {std::move(geo.indices)};
|
||
|
||
// 材质/纹理生命周期随模型(缓存持有);颜色纹理必须显式覆盖 albedo
|
||
// 槽——缺了会折到默认白纹理(白板叠白地 → 平面“消失”的教训)
|
||
std::string paths[Material::kSlotCount] = {"", "", "", ""};
|
||
std::array<Texture*, Material::kSlotCount> overrides{};
|
||
overrides[Material::Albedo] = albedoOverride;
|
||
Material* mat = GetOrCreateMaterial(paths, MaterialParams{}, transparent,
|
||
overrides);
|
||
|
||
m_Meshes.push_back(CreateScope<Mesh>(
|
||
m_Device, m_Allocator, geo.vertices, lodIndices, mat));
|
||
// 内建材质快照(与 m_Meshes 下标对齐,编辑器材质槽 UI 不越界)
|
||
m_MeshMaterialInfos.push_back({name, {}, MaterialParams{}, transparent});
|
||
RebuildUnionBounds();
|
||
}
|
||
|
||
Scope<Model> Model::CreatePlane(Device& device, MemoryAllocator& allocator,
|
||
DescriptorManager& descriptorManager,
|
||
vk::DescriptorSetLayout textureLayout,
|
||
f32 halfSize, u8 r, u8 g, u8 b)
|
||
{
|
||
return Scope<Model>(new Model(device, allocator, descriptorManager,
|
||
textureLayout, ProceduralTag{},
|
||
MeshFactory::Plane(halfSize),
|
||
std::array<u8, 4>{r, g, b, 255}, false,
|
||
"Plane"));
|
||
}
|
||
|
||
Scope<Model> Model::CreateTransparentPlane(
|
||
Device& device, MemoryAllocator& allocator,
|
||
DescriptorManager& descriptorManager,
|
||
vk::DescriptorSetLayout textureLayout,
|
||
f32 halfSize, u8 r, u8 g, u8 b, u8 a)
|
||
{
|
||
return Scope<Model>(new Model(device, allocator, descriptorManager,
|
||
textureLayout, ProceduralTag{},
|
||
MeshFactory::Plane(halfSize),
|
||
std::array<u8, 4>{r, g, b, a}, true,
|
||
"Plane"));
|
||
}
|
||
|
||
Scope<Model> Model::CreateCube(Device& device, MemoryAllocator& allocator,
|
||
DescriptorManager& descriptorManager,
|
||
vk::DescriptorSetLayout textureLayout,
|
||
f32 halfSize, u8 r, u8 g, u8 b)
|
||
{
|
||
return Scope<Model>(new Model(device, allocator, descriptorManager,
|
||
textureLayout, ProceduralTag{},
|
||
MeshFactory::Cube(halfSize),
|
||
std::array<u8, 4>{r, g, b, 255}, false,
|
||
"Cube"));
|
||
}
|
||
|
||
// 通用程序化图元:形状名 → MeshFactory 几何。非法形状名/空几何返回
|
||
// nullptr(调用方回退,绝不把空 mesh 塞进 Mesh)
|
||
Scope<Model> 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<char>(std::toupper(
|
||
static_cast<unsigned char>(name[0])));
|
||
}
|
||
return Scope<Model>(new Model(device, allocator, descriptorManager,
|
||
textureLayout, ProceduralTag{},
|
||
std::move(geo),
|
||
std::array<u8, 4>{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<unsigned char>(c))) {
|
||
s.push_back(static_cast<char>(
|
||
std::tolower(static_cast<unsigned char>(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<std::string> 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];
|
||
}
|
||
}
|
||
|
||
// 内嵌贴图落盘:"*<index>"(assimp 的 aiTexture 表下标)不是磁盘路径——
|
||
// 内建材质有解析期字节池(ParsedModel::embeddedImages)可直接解码,但
|
||
// 覆盖材质(.material)与编辑器 UI 只认磁盘路径:交到
|
||
// VulkanAssetUploader 会被 exists() 判否,整槽退化默认纹理(塔菲 5 张
|
||
// 内嵌贴图 → 任意 Create Override 全白)。抽到 <模型目录>/textures/ 下
|
||
// 得到稳定真实路径,内建/覆盖两条消费链共用。返回空 = 抽取失败,调用
|
||
// 方保留 "*N" 走内嵌字节兜底(只读目录/磁盘满时功能不劣化)
|
||
static std::string WriteEmbeddedTexture(
|
||
const std::unordered_map<std::string, std::vector<u8>>& 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<int>(scene->mNumTextures)) return {};
|
||
const aiTexture* tex = scene->mTextures[idx];
|
||
|
||
// 文件名:aiTexture 自带名(取 basename,glTF image.name)优先,
|
||
// 空或以 '*' 开头则回退 embedded_<idx>;非法字符折叠 '_'
|
||
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<unsigned char>(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<u8>& 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<const char*>(bit->second.data()),
|
||
static_cast<std::streamsize>(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<std::string, std::string> 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<std::string, u32> boneSlots;
|
||
// GLB 内嵌贴图字节池("*N" → PNG/JPEG 原始数据)
|
||
std::unordered_map<std::string, std::vector<u8>> 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<ModelNode>& out)
|
||
{
|
||
const u32 idx = static_cast<u32>(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<i32>(idx), out);
|
||
}
|
||
return idx;
|
||
}
|
||
|
||
void Model::ParseAnimations(const aiScene* scene,
|
||
const std::vector<ModelNode>& nodes,
|
||
std::vector<AnimationClip>& out)
|
||
{
|
||
if (scene->mNumAnimations == 0) return;
|
||
|
||
// nodeName → node 表下标(channel 的目标必须能在层级里累乘)
|
||
std::unordered_map<std::string, u32> 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<f32>(anim->mTicksPerSecond) : 1.0f;
|
||
clip.duration = static_cast<f32>(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<f32>(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<f32>(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<f32>(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<Vertex>& vertices,
|
||
std::vector<ModelBone>& bones,
|
||
std::unordered_map<std::string, u32>& 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<u32>(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> 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<Model>(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<Mesh>(
|
||
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<Mesh>(
|
||
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<ParsedMesh>& out,
|
||
const std::filesystem::path& modelDir, bool isGltf,
|
||
std::vector<ModelBone>& bones,
|
||
std::unordered_map<std::string, u32>& boneSlots,
|
||
std::unordered_map<std::string, std::vector<u8>>&
|
||
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<u32>(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<Texture>(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<Texture*, Material::kSlotCount>&
|
||
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<uintptr_t>(t))
|
||
: ":0";
|
||
}
|
||
}
|
||
|
||
if (auto it = m_MaterialCache.find(key); it != m_MaterialCache.end()) {
|
||
return it->second.get();
|
||
}
|
||
|
||
// 每槽位解析纹理:程序化覆盖优先,路径次之,空/缺失折到默认纹理
|
||
std::array<Texture*, Material::kSlotCount> 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<Material>(
|
||
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<ModelBone>& bones,
|
||
std::unordered_map<std::string, u32>& boneSlots,
|
||
std::unordered_map<std::string, std::vector<u8>>&
|
||
embeddedImages,
|
||
const aiMatrix4x4& globalTransform)
|
||
{
|
||
ParsedMesh out;
|
||
std::vector<Vertex>& vertices = out.vertices;
|
||
std::vector<u32> 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<aiTextureType, u32> 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 记作 "*<index>"(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<int>(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<const u8*>(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() ? "<none>"
|
||
: texPaths[Material::Albedo],
|
||
texPaths[Material::Normal].empty() ? "<none>"
|
||
: texPaths[Material::Normal],
|
||
texPaths[Material::MetallicRoughness].empty() ? "<none>"
|
||
: texPaths[Material::MetallicRoughness],
|
||
texPaths[Material::Roughness].empty() ? "<none>"
|
||
: texPaths[Material::Roughness],
|
||
texPaths[Material::Emissive].empty() ? "<none>"
|
||
: texPaths[Material::Emissive],
|
||
texPaths[Material::Ao].empty() ? "<none>"
|
||
: 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<f32>(metallic),
|
||
static_cast<f32>(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<f32>(ior),
|
||
static_cast<f32>(transmission),
|
||
static_cast<f32>(emissiveStrength),
|
||
static_cast<f32>(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<f32>(emissiveColor.r),
|
||
static_cast<f32>(emissiveColor.g),
|
||
static_cast<f32>(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<f32>(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<f32>(at.mScaling.x);
|
||
const f32 sy = static_cast<f32>(at.mScaling.y);
|
||
const f32 rot = -static_cast<f32>(at.mRotation);
|
||
const f32 c = std::cos(rot);
|
||
const f32 s = std::sin(rot);
|
||
const f32 offx = static_cast<f32>(at.mTranslation.x)
|
||
- 0.5f * sx * (-c + s + 1.0f);
|
||
const f32 offy = 1.0f - sy
|
||
- static_cast<f32>(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<f32>(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<u32> 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
|