Files
HelloVulkan/Engine/src/Renderer/Model.cpp
T
atdunbg f107854236 MeshFactory:程序化几何单一源 + CreatePrimitive 链路
新增 MeshFactory(Cube/Plane/Sphere/Cylinder/Cone/Torus + 切线计算),
Model 程序化几何改走 MeshFactory(收敛为单一几何源,消除重复的顶点
生成),并新增 CreatePrimitive;打通 AssetManager/IAssetUploader/
VulkanAssetUploader 的 CreatePrimitive 链路;编辑器 Procedural 弹窗
下拉 + ResolveProcedural 伪路径反解 + 场景序列化(程序化图元可存取)。
2026-10-18 17:09:25 +08:00

1858 lines
87 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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