From 503e6408edb0afc5e1e1e30f31bd8c5a28f56e58 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Fri, 10 Apr 2026 20:55:48 +0800 Subject: [PATCH] Add model and GaussianSplat asset pipelines --- ...与GaussianSplat资源缓存正式化计划_2026-04-10.md | 693 +++++++++++ ...型导入与Model资产架构重构计划_2026-04-10.md | 779 ++++++++++++ engine/CMakeLists.txt | 15 + .../XCEngine/Core/Asset/ArtifactFormats.h | 68 ++ .../XCEngine/Core/Asset/AssetDatabase.h | 5 +- .../XCEngine/Core/Asset/AssetImportService.h | 2 + .../XCEngine/Core/Asset/ProjectAssetIndex.h | 2 +- .../XCEngine/Core/Asset/ResourceTypes.h | 8 +- .../Resources/GaussianSplat/GaussianSplat.h | 138 +++ .../GaussianSplat/GaussianSplatArtifactIO.h | 18 + .../GaussianSplat/GaussianSplatLoader.h | 21 + .../Resources/Model/AssimpModelImporter.h | 43 + .../include/XCEngine/Resources/Model/Model.h | 72 ++ .../Resources/Model/ModelArtifactIO.h | 18 + .../XCEngine/Resources/Model/ModelLoader.h | 21 + engine/include/XCEngine/Resources/Resources.h | 6 + engine/src/Core/Asset/AssetDatabase.cpp | 348 +++++- engine/src/Core/Asset/AssetImportService.cpp | 10 + engine/src/Core/Asset/ProjectAssetIndex.cpp | 8 +- engine/src/Core/Asset/ResourceManager.cpp | 91 +- .../Resources/GaussianSplat/GaussianSplat.cpp | 109 ++ .../GaussianSplat/GaussianSplatArtifactIO.cpp | 202 ++++ .../GaussianSplat/GaussianSplatLoader.cpp | 40 + .../Internal/GaussianSplatPlyImporter.cpp | 634 ++++++++++ .../Internal/GaussianSplatPlyImporter.h | 14 + .../Resources/Model/AssimpModelImporter.cpp | 1054 +++++++++++++++++ engine/src/Resources/Model/Model.cpp | 57 + .../src/Resources/Model/ModelArtifactIO.cpp | 261 ++++ engine/src/Resources/Model/ModelLoader.cpp | 40 + tests/Core/Asset/test_resource_types.cpp | 8 + tests/Resources/CMakeLists.txt | 2 + tests/Resources/GaussianSplat/CMakeLists.txt | 34 + .../GaussianSplat/test_gaussian_splat.cpp | 73 ++ .../test_gaussian_splat_loader.cpp | 526 ++++++++ tests/Resources/Mesh/test_mesh_loader.cpp | 175 ++- tests/Resources/Model/CMakeLists.txt | 42 + tests/Resources/Model/test_model.cpp | 97 ++ .../Model/test_model_import_pipeline.cpp | 185 +++ tests/Resources/Model/test_model_loader.cpp | 122 ++ 39 files changed, 5900 insertions(+), 141 deletions(-) create mode 100644 docs/plan/3DGS专用PLY导入器与GaussianSplat资源缓存正式化计划_2026-04-10.md create mode 100644 docs/plan/Unity风格模型导入与Model资产架构重构计划_2026-04-10.md create mode 100644 engine/include/XCEngine/Resources/GaussianSplat/GaussianSplat.h create mode 100644 engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatArtifactIO.h create mode 100644 engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatLoader.h create mode 100644 engine/include/XCEngine/Resources/Model/AssimpModelImporter.h create mode 100644 engine/include/XCEngine/Resources/Model/Model.h create mode 100644 engine/include/XCEngine/Resources/Model/ModelArtifactIO.h create mode 100644 engine/include/XCEngine/Resources/Model/ModelLoader.h create mode 100644 engine/src/Resources/GaussianSplat/GaussianSplat.cpp create mode 100644 engine/src/Resources/GaussianSplat/GaussianSplatArtifactIO.cpp create mode 100644 engine/src/Resources/GaussianSplat/GaussianSplatLoader.cpp create mode 100644 engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.cpp create mode 100644 engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.h create mode 100644 engine/src/Resources/Model/AssimpModelImporter.cpp create mode 100644 engine/src/Resources/Model/Model.cpp create mode 100644 engine/src/Resources/Model/ModelArtifactIO.cpp create mode 100644 engine/src/Resources/Model/ModelLoader.cpp create mode 100644 tests/Resources/GaussianSplat/CMakeLists.txt create mode 100644 tests/Resources/GaussianSplat/test_gaussian_splat.cpp create mode 100644 tests/Resources/GaussianSplat/test_gaussian_splat_loader.cpp create mode 100644 tests/Resources/Model/CMakeLists.txt create mode 100644 tests/Resources/Model/test_model.cpp create mode 100644 tests/Resources/Model/test_model_import_pipeline.cpp create mode 100644 tests/Resources/Model/test_model_loader.cpp diff --git a/docs/plan/3DGS专用PLY导入器与GaussianSplat资源缓存正式化计划_2026-04-10.md b/docs/plan/3DGS专用PLY导入器与GaussianSplat资源缓存正式化计划_2026-04-10.md new file mode 100644 index 00000000..48047b4c --- /dev/null +++ b/docs/plan/3DGS专用PLY导入器与GaussianSplat资源缓存正式化计划_2026-04-10.md @@ -0,0 +1,693 @@ +# 3DGS 专用 PLY 导入器与 GaussianSplat 资源缓存正式化计划 + +日期:2026-04-10 + +## 1. 文档定位 + +这份计划只覆盖 3DGS 落地中的前两层基础设施: + +1. `3DGS 专用 PLY importer` +2. `GaussianSplat 资源 / Artifact / ResourceManager / 缓存层` + +这份计划明确不讨论以下内容: + +1. 不实现最终的 3DGS 渲染 pass +2. 不实现 editor 里的 3DGS 编辑工具 +3. 不实现 cutout、selection、导出、相机激活等 Unity 参考项目中的高级功能 +4. 不提前把 3DGS 强行塞进现有 mesh / volume 路径 + +这份计划的目标不是“先把 `.ply` 读出来”,而是把 3DGS 资产链正式纳入引擎现有的资源系统,使它从一开始就是一条可缓存、可复用、可异步、可测试、可长期维护的正式路径。 + +--- + +## 2. 当前参考与现状 + +当前参考工程是: + +1. [mvs/3DGS-Unity](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity) + +当前测试样本是: + +1. [room.ply](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity/room.ply) + +当前已确认的事实: + +1. `3DGS-Unity` 并不是运行时直接渲染 `.ply`,而是先把 `.ply` 转成更接近 GPU 消费形态的运行时资产。 +2. 它的导入工作流核心在 [GaussianSplatAssetCreator.cs](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity/Editor/GaussianSplatAssetCreator.cs)。 +3. 它的 `.ply` 读取器 [PLYFileReader.cs](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity/Editor/Utils/PLYFileReader.cs) 本质上是一个偏工程化的快速路径,不是健壮的通用 PLY 解析器。 +4. 它的运行时资产 [GaussianSplatAsset.cs](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity/Runtime/GaussianSplatAsset.cs) 已经把数据拆成了 `pos / other / sh / color / chunk` 几类 GPU 资源。 +5. 它的运行时渲染 [GaussianSplatRenderer.cs](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity/Runtime/GaussianSplatRenderer.cs) 依赖 compute、StructuredBuffer、RawBuffer、procedural draw、fullscreen composite。 + +对当前引擎的现状判断: + +1. 引擎已经具备 `StructuredBuffer / RawBuffer / RWStructuredBuffer / RWRawBuffer` 级别的 shader 资源识别能力。 +2. 引擎已经具备 compute shader 与 `Dispatch` 的 RHI 基础能力。 +3. 引擎已经具备 runtime material buffer binding 能力。 +4. 引擎已经具备 `AssetDatabase -> Library/Artifacts -> ResourceManager` 的正式资源链。 +5. 引擎当前还没有 `GaussianSplat` 这种正式资源类型。 +6. 引擎当前还没有 3DGS 专用 importer、artifact schema、loader、GPU residency cache。 + +--- + +## 3. 本轮最核心的架构决策 + +### 3.1 不允许运行时直接消费 `.ply` + +正式方案必须是: + +1. `Assets/*.ply` +2. 经过 `GaussianSplatImporter` +3. 生成 `Library/Artifacts/.../main.xcgsplat` +4. 运行时只加载 `xcgsplat` + +不允许的错误方案: + +1. renderer 首次遇到 `.ply` 时再现场解析 +2. component 里直接持有 `.ply` 文件句柄 +3. 把 `.ply` 读取逻辑塞进 render pass +4. 为了尽快出图先做“临时直接加载 `.ply`”然后以后再回收 + +原因很明确: + +1. `.ply` 是 source asset,不是 runtime-ready asset +2. 直接 runtime 解析会破坏 `AssetDatabase / ResourceManager / Library` 体系 +3. 3DGS 数据量远大于普通 mesh,更不能把 source 解析和 GPU 上传压到 draw path 上 + +### 3.2 不做通用 PLY 导入器,先做 3DGS 专用 PLY 导入器 + +本轮 importer 的职责不是支持一切 PLY 变体,而是支持当前 3DGS 工作流所需的那类 PLY。 + +正式边界是: + +1. 支持 `binary_little_endian` +2. 只关心 `element vertex` +3. 通过属性名映射识别 3DGS 语义字段 +4. 对不支持的属性布局给出明确错误 + +不做的事: + +1. 不支持带面片索引的通用模型 PLY +2. 不支持 ASCII PLY +3. 不支持任意 list property +4. 不支持“能读但语义不清晰”的模糊推断 + +这不是退让,而是边界明确。当前目标是 3DGS 正式化,不是通用点云 SDK。 + +### 3.3 运行时正式资源类型命名为 `GaussianSplat` + +建议引入: + +1. `ResourceType::GaussianSplat` +2. `Resources::GaussianSplat` +3. `GaussianSplatLoader` +4. `GaussianSplatImporter` + +不建议把运行时资源叫: + +1. `GaussianSplatAsset` +2. `GaussianAsset` +3. `PLYAsset` + +原因: + +1. 引擎当前 `ResourceType` 命名体系都是运行时资源名,不是 editor 资产名 +2. `Asset` 更适合出现在导入流程和文档语义中,不适合塞进正式 runtime `ResourceType` + +### 3.4 Artifact 采用单文件主 artifact,而不是 Unity 式多 TextAsset 拼装 + +建议正式主 artifact 为: + +1. `main.xcgsplat` + +不建议照抄 Unity MVS 的多文件拆分为: + +1. `*_pos.bytes` +2. `*_other.bytes` +3. `*_sh.bytes` +4. `*_col.bytes` +5. `*_chk.bytes` + +Unity 那样做是受 Unity 资产模型约束。我们自己的引擎不需要跟着它的工程妥协走。 + +本轮更合理的正式方案是: + +1. 单个 `xcgsplat` 文件包含 header、section table、payload +2. loader 一次读入 metadata,按 section 定位各 payload +3. 后续如果要做分段流式或 memory mapping,再在 schema 上扩展,不先把文件形态做碎 + +这样做的好处: + +1. artifact 边界清晰 +2. `ArtifactDB` 记录简单 +3. 依赖跟踪简单 +4. reimport 稳定 +5. 不会出现多 sidecar 丢失或部分过期的问题 + +--- + +## 4. 对参考 MVS 的正式吸收方式 + +`mvs/3DGS-Unity` 里真正值得吸收的是流程,不是实现细节原样照搬。 + +本轮吸收的内容: + +1. 输入语义 +2. 数据重排思路 +3. 运行时数据拆分维度 +4. `chunk` 概念 +5. 颜色纹理化而不是纯 buffer 化 +6. 未来 compute 排序与 view-data 预处理对资源格式的需求 + +本轮不直接照搬的内容: + +1. Unity 的 `TextAsset` 资产组织方式 +2. 依赖 `UnsafeUtility.SizeOf() == vertexStride` 的固定内存布局导入 +3. editor 窗口与工具链 +4. HDRP/URP feature 接入方式 +5. 所有编辑态 GPU buffer + +简单说: + +1. 流程借鉴 +2. 数据语义借鉴 +3. 工程架构不照抄 Unity 的壳 + +--- + +## 5. `room.ply` 的正式支持目标 + +当前基线样本 [room.ply](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity/room.ply) 已确认包含如下字段: + +1. `x y z` +2. `nx ny nz` +3. `f_dc_0..2` +4. `f_rest_0..44` +5. `opacity` +6. `scale_0..2` +7. `rot_0..3` + +本轮 importer 至少必须把这类文件稳定导入。 + +本轮导入结果必须包含: + +1. splat 数量 +2. bounds +3. position +4. rotation +5. scale +6. opacity +7. color / dc0 +8. SH 数据 +9. 供后续 chunk 化、排序、view-data 预计算所需的稳定 layout + +本轮不要求: + +1. 用 room.ply 直接出图 +2. 完成 chunk 压缩优化后的最终视觉验证 + +但必须做到: + +1. room.ply 可以稳定导入成正式 artifact +2. runtime 可以正式加载该 artifact +3. 资源缓存与异步链路已经为后续渲染阶段准备好 + +--- + +## 6. 目标资源模型设计 + +### 6.1 `Resources::GaussianSplat` + +建议 `GaussianSplat` 运行时资源至少包含: + +1. `splatCount` +2. `boundsMin / boundsMax` +3. `dataFormatVersion` +4. `positionFormat` +5. `otherFormat` +6. `colorFormat` +7. `shFormat` +8. `chunkCount` +9. `cameraInfoCount` +10. 各 section 的只读数据视图 + +这里的 section 建议为: + +1. `positions` +2. `other` +3. `color` +4. `sh` +5. `chunks` +6. `cameras` + +### 6.2 `other` 的语义边界 + +建议 `other` 区域承担: + +1. rotation +2. scale +3. opacity +4. 可选 SH 索引或 chunk 相关辅助字段 + +原因: + +1. 这与参考 MVS 的消费模型更接近 +2. compute 阶段天然会把位置和其它数据拆开消费 +3. 将来做压缩时,位置和其它数据的量化策略也不同 + +### 6.3 `color` 保持纹理友好布局 + +建议 `color` section 不是简单“每 splat 一行 float4”,而是直接按运行时纹理消费友好的布局存储。 + +原因: + +1. 参考 MVS 最终就是把颜色上传成纹理 +2. 对 3DGS 而言,颜色作为纹理读取是合理路径 +3. 如果导入期就固化好 texel 布局,运行时不必再做一次昂贵重排 + +### 6.4 `chunks` 作为正式字段预留 + +即使第一阶段先允许 `chunkCount == 0`,artifact schema 也要正式留出 chunk 区域。 + +因为: + +1. chunk 数据不是可有可无的小优化,它影响后续压缩、解码和排序输入 +2. 后面一旦渲染 pass 接上,就会很自然依赖 chunk +3. 现在先把 schema 打对,比后面再迁移 artifact 版本更划算 + +--- + +## 7. Importer 设计 + +### 7.1 引入 `GaussianSplatImporter` + +`AssetDatabase` 对 `.ply` 的识别不应直接复用 `ModelImporter`。 + +建议规则: + +1. `.ply` 不作为通用模型格式挂进 `ModelImporter` +2. 本轮把 `.ply` 明确识别为 `GaussianSplatImporter` +3. 后续如果将来要支持“通用点云 PLY”,再单独扩展,不污染当前 3DGS 主线 + +### 7.2 Header 解析不能依赖固定顺序 + +正式解析流程必须是: + +1. 读取 header +2. 收集 `element vertex` +3. 收集每个 `property` 的名字、类型、偏移 +4. 建立 3DGS 语义字段到 property 的映射 +5. 校验必需字段是否完整 + +不允许的方案: + +1. 直接假定 `InputSplatData` 与文件二进制布局完全一致 +2. 直接假定 `f_rest_*` 顺序永远固定且不校验 +3. 因为 room.ply 能过就默认所有训练器导出的 PLY 都一样 + +### 7.3 importer 输出的是 cooked runtime layout,不是 source mirror + +导入器的正式职责不是把 `.ply` 原样搬进 artifact,而是做以下转换: + +1. 按语义解包 source data +2. 生成规范化内部 splat 记录 +3. 计算 bounds +4. 可选做 Morton reorder +5. 可选做 chunk 构建 +6. 输出运行时友好的 section layout + +这一步就是 source -> cooked artifact,而不是 source -> source copy。 + +### 7.4 关于压缩策略 + +本轮计划分两步: + +1. 第一阶段先实现无损或近无损基础 cooked 布局 +2. 第二阶段再把参考 MVS 的压缩格式体系正式移植进来 + +原因: + +1. 先把 artifact、loader、cache 链路跑通 +2. 再叠加压缩和 chunking +3. 避免 importer、artifact schema、runtime loader、未来 renderer 四件事同时出错 + +第一阶段允许: + +1. `position = float32` +2. `other = float32 / uint32 packed` +3. `color = RGBA32F` 或 `RGBA16F` +4. `sh = float32` +5. `chunk = 0` + +第二阶段再引入: + +1. `Norm16 / Norm11 / Norm6` +2. `BC7 / Norm8x4` +3. `SH clustering` +4. `chunk` 正式压缩路径 + +--- + +## 8. Artifact 设计 + +### 8.1 主文件 + +主文件建议: + +1. `main.xcgsplat` + +### 8.2 文件内容建议 + +建议 `xcgsplat` 文件包含: + +1. 文件头 +2. schema version +3. source metadata snapshot +4. splat metadata +5. section table +6. payload blob + +section table 至少描述: + +1. section type +2. byte offset +3. byte size +4. element count +5. element stride +6. format enum + +### 8.3 version 策略 + +建议单独引入: + +1. `kGaussianSplatArtifactSchemaVersion` + +不要复用其它 importer 的 schema version。 + +版本提升触发条件: + +1. section 布局改变 +2. chunk 编码改变 +3. 颜色纹理布局改变 +4. SH 格式或 camera 区块布局改变 + +### 8.4 `.meta` 设计 + +即使本轮先不做完整 Inspector,也应该为后续 importer settings 预留正式字段。 + +建议至少预留: + +1. `reorderMorton` +2. `buildChunks` +3. `positionFormat` +4. `otherFormat` +5. `colorFormat` +6. `shFormat` +7. `importCameras` + +第一阶段如果先不开放 UI,也要把默认设置结构体和 hash 纳入 artifact key 计算。 + +--- + +## 9. Loader 与 ResourceManager 接入 + +### 9.1 `GaussianSplatLoader` + +需要新增: + +1. `GaussianSplatLoader` + +职责: + +1. 读取 `xcgsplat` +2. 构建 `Resources::GaussianSplat` +3. 提供各 section 的稳定只读视图 + +### 9.2 `ResourceManager` 正式接入 + +正式链路应支持: + +1. `Load("Assets/.../room.ply")` +2. `AssetDatabase::EnsureArtifact(...)` +3. `ResourceManager` 实际加载 `main.xcgsplat` + +也必须支持: + +1. `Load("Library/Artifacts/.../main.xcgsplat")` + +### 9.3 不能把 GPU 上传塞进 loader + +`GaussianSplatLoader` 只负责 CPU 运行时资源,不负责 GPU residency。 + +原因: + +1. loader 属于资源层 +2. GPU residency 属于渲染缓存层 +3. 如果在 loader 里直接创 GPU 资源,会重复 volume 这条链已经暴露过的架构问题 + +--- + +## 10. 缓存与预热设计 + +### 10.1 资源缓存层必须提前设计 GPU residency 状态机 + +即使本轮还不接最终 render pass,也必须把状态机设计写进正式方案: + +1. `Uninitialized` +2. `CpuReady` +3. `GpuUploading` +4. `GpuReady` +5. `Failed` + +后续 `BuiltinGaussianSplatPass` 只能消费: + +1. `GpuReady` + +不允许 draw path 现场把 `CpuReady -> GpuReady` 做完。 + +### 10.2 建议新增 `CachedGaussianSplat` + +建议未来挂在 `RenderResourceCache` 或其正式拆分后的 GPU 资源缓存模块中。 + +它至少应持有: + +1. `posBuffer` +2. `otherBuffer` +3. `shBuffer` +4. `colorTexture` +5. `chunkBuffer` +6. `sortKeyBuffer` +7. `sortDistanceBuffer` +8. `viewDataBuffer` +9. runtime-ready flag / state + +本轮即使还不把所有 GPU 辅助 buffer 全建出来,也要把正式边界写清楚: + +1. asset static payload buffer/texture +2. per-frame transient / reusable working buffer + +### 10.3 首次可见前预热,而不是首次 draw 同步补做 + +这点必须作为硬约束写死: + +1. `GaussianSplat` 被场景反序列化后,CPU artifact 加载完成就进入 GPU 预热队列 +2. GPU 上传在后台或明确的准备阶段完成 +3. 首次 draw 只允许跳过未 ready 对象,不允许同步创建大资源 + +原因: + +1. 3DGS 资产通常很大 +2. room.ply 这种样本数据量已经足够把 draw path 压垮 +3. 当前 volume 这条链已经证明“首次绘制再上传”不是可接受正式方案 + +### 10.4 warm cache 验收标准 + +本轮资源 / 缓存层至少要达到: + +1. 第二次加载 room.ply 时不重新解析 source `.ply` +2. 直接命中 `Library/Artifacts` +3. `ResourceManager` 不会因为 cache hit 又走 source importer +4. 后续 GPU 预热可以稳定复用 artifact 输出 + +--- + +## 11. 测试计划 + +### 11.1 基线样本 + +统一使用: + +1. [room.ply](D:/Xuanchi/Main/XCEngine/mvs/3DGS-Unity/room.ply) + +它将承担: + +1. importer 基线 +2. artifact 基线 +3. cache hit 基线 +4. future renderer 接入基线 + +### 11.2 Unit Tests + +本轮至少要补齐以下单测: + +1. `PLY header parser` 正确识别 `vertexCount / properties / offsets` +2. `GaussianSplatImporter` 能正确识别 room.ply 的必需字段 +3. 缺字段时给出明确错误 +4. 非法格式时给出明确错误 +5. `xcgsplat` 写入 / 读取 roundtrip 正确 +6. `GaussianSplatLoader` 能读取 artifact 并恢复 metadata 与 section view +7. `ResourceManager` 能从 `Assets/.../room.ply` 正式加载 `GaussianSplat` +8. `AssetDatabase` 对 `.ply` 的 `EnsureArtifact` 能稳定复用 + +### 11.3 Integration Tests + +本轮先做资源链集成测试,不做最终出图测试。 + +至少要有: + +1. `room.ply -> EnsureArtifact -> Load` 全链通过 +2. 二次加载命中 artifact,不触发 reimport +3. 修改 source writeTime 后能触发 reimport +4. 清库后能重建 artifact + +### 11.4 为后续渲染阶段准备的 smoke test + +虽然本轮不做 3DGS pass,但建议提前补一个 GPU 资源 smoke test: + +1. 读取 `GaussianSplat` +2. 构建最小 GPU cache entry +3. 创建 `pos/other/sh/chunk` buffer 与 `color` texture +4. 验证状态进入 `GpuReady` + +这样后续 renderer 接入时,不会把“资源问题”和“渲染问题”混成一团。 + +--- + +## 12. 分阶段执行计划 + +### Phase 1:资源类型与 artifact schema 落地 + +目标: + +1. 正式引入 `ResourceType::GaussianSplat` +2. 正式引入 `Resources::GaussianSplat` +3. 正式定义 `xcgsplat` artifact schema + +任务: + +1. 扩展 `ResourceType` +2. 新增 `GaussianSplat` 运行时资源类 +3. 设计 artifact header 与 section table +4. 新增 `WriteGaussianSplatArtifactFile / LoadGaussianSplatArtifact` + +验收标准: + +1. `xcgsplat` 可写可读 +2. 资源元数据可稳定 roundtrip + +### Phase 2:3DGS 专用 PLY importer 正式化 + +目标: + +1. 把 `.ply` 纳入 `GaussianSplatImporter` + +任务: + +1. 新增 header parser +2. 新增 3DGS property mapping +3. 读取 room.ply 并转换成规范化内部 splat 数据 +4. 输出基础 cooked artifact + +验收标准: + +1. room.ply 可稳定导入 +2. 不依赖固定 struct stride == 文件 stride +3. 错误路径有清晰日志 + +### Phase 3:AssetDatabase / Library / ResourceManager 接入 + +目标: + +1. 把 `GaussianSplat` 完整接进项目资源工作流 + +任务: + +1. `.ply -> GaussianSplatImporter` +2. `EnsureArtifact(..., ResourceType::GaussianSplat)` +3. `GaussianSplatLoader` +4. `Load()` + +验收标准: + +1. 可以通过 `Assets/.../room.ply` 正式加载 +2. cache hit 时不重走 source parse + +### Phase 4:资源缓存与 GPU residency 预热骨架 + +目标: + +1. 正式建立 3DGS GPU 资源缓存的边界 + +任务: + +1. 设计 `CachedGaussianSplat` +2. 建立 GPU residency 状态机 +3. 实现最小 GPU 资源构建 smoke path +4. 明确禁止 draw path 首次同步上传 + +验收标准: + +1. room.ply 对应的 `GaussianSplat` 可以被 GPU cache 预热成 ready 状态 +2. 资源层与渲染层边界清晰 + +### Phase 5:测试补齐与收口 + +目标: + +1. 让这条链路可回归、可持续演进 + +任务: + +1. 补全 importer / loader / cache hit / reimport 单测与集成测试 +2. 输出阶段性说明 +3. 为后续 renderer 接入保留唯一正式资源路径 + +验收标准: + +1. room.ply 全链路测试稳定 +2. 不存在“临时直接读 ply”的旁路 + +--- + +## 13. 明确不允许出现的临时方案 + +以下方案本轮禁止出现: + +1. 为了尽快出图,先在 render pass 里直接解析 `.ply` +2. 先做一个 `BinaryResource` 包 `.ply` 内容,后面再说 +3. 先把 `.ply` 当 `Mesh` 导入 +4. 把 3DGS 的 GPU buffer 直接挂在 `Material` 资源本体上作为持久化资产 +5. 首次 draw 时同步创建 `pos / other / sh / color` GPU 资源 +6. 把 `room.ply` 单独写死成特判 + +这些做法都会把本该正式化的主线重新拉回临时方案。 + +--- + +## 14. 本轮完成标志 + +当以下条件同时成立时,这份计划才算完成: + +1. `.ply` 已正式被 `GaussianSplatImporter` 接管 +2. `GaussianSplat` 已成为正式 `ResourceType` +3. `room.ply` 能稳定导入成 `xcgsplat` +4. `ResourceManager` 能正式加载 `GaussianSplat` +5. 二次加载能稳定命中 artifact +6. GPU residency cache 骨架已经建立,不允许首次 draw 同步补做 +7. 资源层与缓存层测试已经覆盖 room.ply 主路径 + +--- + +## 15. 一句话结论 + +这条主线的第一步不是“做一个 ply 读取器”,而是把 3DGS 正式升级为引擎里的 `GaussianSplat` 资源体系: +由 `GaussianSplatImporter` 把 `.ply` 转成 `xcgsplat` cooked artifact,由 `GaussianSplatLoader` 与 `ResourceManager` 正式接管加载,再由独立的 GPU residency cache 提前完成资源预热,为后续 3DGS 渲染 pass 提供唯一、稳定、无旁路的正式输入。 diff --git a/docs/plan/Unity风格模型导入与Model资产架构重构计划_2026-04-10.md b/docs/plan/Unity风格模型导入与Model资产架构重构计划_2026-04-10.md new file mode 100644 index 00000000..8d59635f --- /dev/null +++ b/docs/plan/Unity风格模型导入与Model资产架构重构计划_2026-04-10.md @@ -0,0 +1,779 @@ +# Unity 风格模型导入与 Model 资产架构重构计划 + +日期:2026-04-10 + +## 1. 文档定位 + +这份计划覆盖 XCEngine 现阶段外部模型资源导入链路的结构性重构,目标不是“再给 `MeshLoader` 多加几个扩展名”,而是把模型资源正式提升为接近 Unity 的 `ModelImporter + ModelAsset` 工作流。 + +本计划默认遵循以下战略判断: + +1. 当前工程已经具备基于 `Assimp` 的基础静态模型读取能力,`.obj/.fbx/.gltf/.glb/.dae/.stl` 都能进入导入链。 +2. 当前主资源仍然是 `Mesh`,这导致导入时会把大量上层语义压扁到静态网格层。 +3. 如果要走 Unity 风格,后续继续在 `Mesh` 主资产上堆功能会越来越别扭,必须先补一层真正的 `Model` 资产。 +4. 骨骼动画不是本轮主目标,但本轮的数据结构和 artifact 设计必须为它预留正式扩展位。 + +本轮计划的本质是: + +1. 把“source model import”和“runtime mesh load”拆开。 +2. 把“外部模型文件的主资产类型”从 `Mesh` 提升到 `Model`。 +3. 把 `.obj/.fbx/...` 的导入体验统一到一条 `ModelImporter` 主链上。 + +--- + +## 2. 当前状态判断 + +基于当前代码结构,可以确认已有能力与缺口如下。 + +### 2.1 已有能力 + +1. `MeshLoader` 已通过 `Assimp` 支持 `.fbx/.obj/.gltf/.glb/.dae/.stl/.xcmesh`。 +2. `AssetDatabase` 已将 `.obj/.fbx/.gltf/.glb/.dae/.stl` 识别为 `ModelImporter`。 +3. 编辑器 `MeshFilter` 的资源选择 UI 已允许 `.fbx/.obj/.gltf/.glb`。 +4. 工程已接入 `assimp-vc143-mt.dll`,说明构建链和基础运行依赖已经建立。 +5. 当前 artifact 管线已经具备 `xcmesh/xcmat/xctex` 的写入与回读能力。 + +### 2.2 当前核心问题 + +1. `ModelImporter` 的主资源类型实际上仍然是 `Mesh`,并不是真正的 `Model`。 +2. 导入时递归遍历 Assimp node,但会把 node transform 烘进顶点,原始层级、局部变换、pivot 语义丢失。 +3. `Mesh` 顶点结构当前仍是静态网格视角,只覆盖 `position/normal/tangent/bitangent/uv0`。 +4. `MeshImportSettings` 中虽然已经出现 `ImportSkinning / ImportAnimations / ImportCameras / ImportLights` 等标志,但没有形成对应的正式数据通路。 +5. `.meta` 当前只写 importer 名称和版本,没有形成 Unity 风格的模型导入设置持久化。 +6. 测试主要覆盖 `obj` 路径,针对真实 `fbx` fixture 的链路验证明显不足。 +7. 拖模型进场景时,现有工作流更接近“给一个 `GameObject` 绑定单个 `Mesh`”,而不是实例化一棵模型层级。 + +### 2.3 当前阶段最重要的判断 + +当前问题不在于“FBX 能不能读进来”,而在于“读进来以后是被当成 `Mesh` 还是被当成 `Model`”。 + +如果继续让外部模型文件直接产出主 `Mesh`: + +1. `OBJ` 还能勉强成立。 +2. `FBX` 会不断丢失层级、pivot、子节点、多部件组织等上层语义。 +3. 后续无论做子资产展开、模型 prefab 化、骨骼动画还是稳定 reimport,都会越来越难。 + +--- + +## 3. 目标架构决策 + +### 3.1 总体决策 + +长期目标不是: + +1. `OBJ -> Mesh` +2. `FBX -> Model` + +而是统一到: + +1. 外部模型格式统一进入 `ModelImporter` +2. `ModelImporter` 的主产物统一是 `Model` +3. `Model` 内部再引用一个或多个 `Mesh/Material/Texture` + +即: + +1. `.obj -> 简化 Model` +2. `.fbx -> 完整 Model` +3. `.gltf/.glb/... -> 同样走 Model 主链` + +这样做的好处是: + +1. 资源系统主线统一。 +2. 编辑器工作流统一。 +3. 子资产稳定引用机制统一。 +4. 骨骼、动画、blend shape、嵌入纹理等后续扩展都有正式落点。 + +### 3.2 `Mesh` 与 `Model` 的职责边界 + +`Mesh` 只回答一件事: + +1. 这个表面怎么画。 + +它应该承载: + +1. 顶点数据 +2. 索引数据 +3. section +4. bounds +5. 材质槽位数量与 section-material 映射 + +`Model` 回答另一件事: + +1. 这个资源整体怎么组织。 + +它应该承载: + +1. 节点层级 +2. 节点局部 TRS +3. 节点名字与路径 +4. 节点绑定的 mesh 引用 +5. 每个 mesh 节点的材质槽绑定 +6. 根节点信息 +7. 导入元数据 +8. 未来的 skeleton / animation / blend shape 扩展位 + +### 3.3 运行时链路的长期形态 + +长期上应当形成三层: + +1. `ModelImporter` + 负责 source file -> artifact graph +2. `ModelLoader` + 负责读取 `xcmodel` +3. `MeshLoader` + 负责读取 `xcmesh` + +其中: + +1. `MeshLoader` 不再承担完整 source scene import 的主责任。 +2. `MeshLoader` 更适合退化成“运行时 mesh artifact 加载器 + builtin mesh loader”。 +3. 所有 source model 的正式导入都应该通过 `ModelImporter` 的专用实现完成。 + +--- + +## 4. 本轮范围与明确不做的内容 + +### 4.1 本轮必须完成 + +1. `Model` 资源类型与 `xcmodel` artifact。 +2. 统一的 `ModelImporter` 主链。 +3. `.meta` 中的 Model Import Settings 持久化。 +4. 保留层级的静态模型导入。 +5. 场景中实例化模型层级的工作流。 +6. 子资产与稳定 `LocalID` 规则。 +7. 编辑器中的模型导入设置 Inspector。 +8. `OBJ/FBX` 静态模型的正式测试闭环。 + +### 4.2 本轮暂不做 + +1. `SkinnedMeshRenderer` 正式渲染链。 +2. 骨骼矩阵上传与 GPU skinning。 +3. 动画 clip runtime 播放系统。 +4. blend shape runtime。 +5. 完整相机/灯光从 `FBX` 到 scene 的自动生成。 +6. 模型子资产在 Project 面板中的完整可展开 UI。 + +### 4.3 本轮只预留不落地的内容 + +1. `Skeleton` 资源类型接口 +2. `AnimationClip` 资源类型接口 +3. `Model` 中 skeleton/animation 的 artifact 扩展位 +4. skinning/blend shape 所需的稳定 source 标识规则 + +--- + +## 5. 目标数据结构设计 + +### 5.1 新增资源类型 + +建议在 `ResourceType` 中正式新增: + +1. `Model` + +同时保留已有: + +1. `Mesh` +2. `Material` +3. `Texture` +4. `AnimationClip` +5. `Skeleton` + +即使 `AnimationClip/Skeleton` 本轮不真正导出,也应在设计上与 `Model` 并列存在。 + +### 5.2 `Model` 资源结构建议 + +`Model` 至少包含以下结构: + +1. `ModelNode` + - `name` + - `parentIndex` + - `firstChildIndex / childCount` 或 child index array + - `localPosition` + - `localRotation` + - `localScale` + - `meshBindingStart` + - `meshBindingCount` +2. `ModelMeshBinding` + - `meshLocalID` + - `materialBindingStart` + - `materialBindingCount` +3. `ModelMaterialBinding` + - `slotIndex` + - `materialLocalID` +4. `ModelImportMetadata` + - importer version + - source file signature + - import settings snapshot + +### 5.3 `Mesh` 资源职责调整 + +`Mesh` 继续作为低层渲染资源保留,但职责要收紧: + +1. 不再把 source model file 当成它的长期主输入。 +2. 主要负责: + - `xcmesh` + - builtin mesh +3. source format 直接读取只作为过渡能力,后续应弱化。 + +### 5.4 `Model` artifact 文件布局 + +建议 artifact 目录布局统一为: + +1. `main.xcmodel` +2. `mesh_0.xcmesh` +3. `mesh_1.xcmesh` +4. `material_0.xcmat` +5. `material_1.xcmat` +6. `texture_0.xctex` +7. `texture_1.xctex` +8. 未来可扩展: + - `skeleton_0.xcskel` + - `anim_0.xcanim` + +### 5.5 `LocalID` 稳定规则 + +这是整个 Unity 风格方案里最关键的一部分。 + +不能简单使用“导入顺序下标”作为长期稳定标识。应当引入基于源语义的稳定 `LocalID` 生成策略: + +1. Node:基于节点路径生成 +2. Mesh:基于节点路径 + mesh name + source mesh index 生成 +3. Material:基于 material name + source material index 生成 +4. Texture:基于 source texture path 或 embedded texture key 生成 +5. 后续骨骼/动画:基于骨骼名、clip 名、source index 生成 + +目标是: + +1. 同一个模型在普通 reimport 后,子资产 `LocalID` 不变。 +2. 场景/prefab/材质槽引用不因为 artifact 目录变化而失效。 + +--- + +## 6. 导入链路重构方案 + +### 6.1 引入专用 source importer + +建议新增专用模块,例如: + +1. `AssimpModelImporter` + +它的职责是: + +1. 读取 `.obj/.fbx/.gltf/.glb/...` +2. 产出统一中间结构 `ImportedModel` +3. 再由 artifact writer 写出 `xcmodel/xcmesh/xcmat/xctex` + +不建议继续把这部分主逻辑堆在 `MeshLoader` 内部。 + +### 6.2 导入中间结构 + +建议先建立 importer 内部中间结构: + +1. `ImportedModel` +2. `ImportedNode` +3. `ImportedMesh` +4. `ImportedMaterial` +5. `ImportedTexture` + +这样可以把: + +1. Assimp scene 读取 +2. 引擎内部资源表示 +3. artifact 写出 + +三者解耦。 + +### 6.3 节点与变换处理原则 + +这是与当前实现最大的差异之一。 + +当前做法是把 `node` 变换乘到顶点上,再把所有几何收进同一个 `Mesh`。本轮要改成: + +1. Mesh 顶点保持在 mesh local space +2. Node local transform 单独存进 `Model` +3. Scene 实例化时再把 node local transform 还原到 `GameObject` + +只有这样才能保留: + +1. 层级 +2. pivot +3. 本地 TRS +4. 后续动画骨骼的基础结构 + +### 6.4 轴系与缩放的处理原则 + +建议明确以下规则: + +1. 轴系转换属于导入标准化步骤,但不能以“摧毁原始层级”为代价。 +2. 全局 import scale 应优先体现在 root 侧或 mesh 侧的单一标准化策略中,不要 mesh 与 node 两边重复施加。 +3. 一旦规则确定,要在 `ModelImportSettings` 中固定,并进入 `metaHash`。 + +本轮必须把“坐标转换在哪里做、缩放在哪里做”写成正式约束,而不是散落在 loader 里。 + +### 6.5 `OBJ` 的导入策略 + +`OBJ` 仍然统一走 `ModelImporter`,但导入结果通常是一个简化模型: + +1. 一个 root node +2. 一个或少量 mesh node +3. 少量 material bindings + +即: + +1. `OBJ` 不是继续作为架构特例保留在 `Mesh` 主链外面 +2. 它只是“语义很简单的 model source” + +### 6.6 `FBX` 的导入策略 + +`FBX` 则要保留完整静态结构: + +1. node hierarchy +2. local transform +3. mesh attachments +4. material slots +5. embedded/external textures +6. 后续可扩展 skeleton/animation metadata + +即使本轮不做动画,也不应该再把 `FBX` 烘成单个主 `Mesh`。 + +--- + +## 7. AssetDatabase 与 ArtifactDatabase 改造计划 + +### 7.1 主资产类型调整 + +当前 `ModelImporter` 的主资产仍是 `Mesh`。本轮要改成: + +1. `ModelImporter` 主资产类型为 `Model` +2. `mainLocalID` 指向 `Model` 主资产 +3. `Mesh/Material/Texture` 成为其子资产 + +### 7.2 `EnsureArtifact` 语义调整 + +对 `Assets/Models/robot.fbx` 执行 `EnsureArtifact(..., ResourceType::Model)` 时: + +1. 返回主 `xcmodel` +2. 内部子资产可通过 `AssetRef(assetGuid, localID, resourceType)` 访问 + +若后续有需要,`MeshFilter` 等组件可直接引用某个 `Mesh` 子资产,而不是引用主 `Model`。 + +### 7.3 依赖追踪调整 + +当前依赖追踪对 `obj -> mtl -> texture` 已有基础覆盖,但本轮要升级成通用模型依赖追踪: + +1. source model file +2. 外部纹理 +3. embedded texture 的派生 key +4. 导入设置 meta +5. future:外部 animation source / skeleton source + +目标是: + +1. 改 texture 会触发模型 reimport +2. 改 importer settings 会触发模型 reimport +3. 子资产 artifact 会稳定重建 + +### 7.4 资源查询接口调整 + +建议新增或调整: + +1. `TryGetAssetRef(path, ResourceType::Model, ...)` +2. `TryGetSubAssetRef(path, subAssetKey or localID, ResourceType::Mesh, ...)` +3. `TryGetPrimaryAssetPath(guid, ...)` 对 `Model` 继续保持主资产语义 + +同时要保证: + +1. 主 `Model` 与子 `Mesh` 的引用路径语义清晰 +2. 编辑器与场景序列化知道自己引用的是主资产还是子资产 + +--- + +## 8. `.meta` 与导入设置计划 + +### 8.1 当前问题 + +当前 `.meta` 只有: + +1. `guid` +2. `folderAsset` +3. `importer` +4. `importerVersion` + +这不足以支撑 Unity 风格模型工作流。 + +### 8.2 新增 `ModelImportSettings` + +建议正式引入或升级为: + +1. `ModelImportSettings` + +至少包括: + +1. `globalScale` +2. `axisConversion` +3. `flipUVs` +4. `flipWindingOrder` +5. `generateNormals` +6. `generateTangents` +7. `importMaterials` +8. `extractEmbeddedTextures` +9. `preserveHierarchy` +10. `mergeStaticMeshes` +11. 预留: + - `importSkinning` + - `importAnimations` + - `importCameras` + - `importLights` + +### 8.3 `.meta` 持久化规则 + +应当把上述设置正式写入 `.meta`,并参与 `metaHash`。 + +目标是: + +1. 切换 importer setting 后 artifact key 会变化 +2. `Reimport` 有明确语义 +3. Inspector 改完设置能稳定反映到导入结果 + +### 8.4 版本策略 + +建议: + +1. `ModelImporter` 单独维护 importer version +2. 当 `xcmodel` schema 或子资产布局变化时,显式提升版本号 +3. 不再使用“整个 AssetDatabase 共用一个笼统版本号”来覆盖全部导入器变化 + +--- + +## 9. 编辑器工作流计划 + +### 9.1 Project 面板语义 + +`.obj/.fbx/.gltf/.glb/...` 在 Project 面板中应统一显示为: + +1. `Model` + +而不是继续让用户默认把这些资源理解成“单个 mesh 文件”。 + +### 9.2 Model Importer Inspector + +选中模型资源时,Inspector 需要出现真正的导入设置面板: + +1. Import Settings +2. Apply +3. Revert +4. Reimport + +首批显示的设置至少包括: + +1. Scale +2. Axis Conversion +3. Preserve Hierarchy +4. Import Materials +5. Generate Normals +6. Generate Tangents +7. Flip UVs + +### 9.3 场景拖拽行为 + +把模型资源拖进场景时,不应只创建一个空物体加一个 `MeshFilter`。 + +推荐行为: + +1. 读取 `Model` +2. 生成对应 `GameObject` 树 +3. 按节点局部变换恢复 TRS +4. 对带 mesh 的节点挂 `MeshFilter + MeshRenderer` +5. 材质槽按 `Model` 内的绑定还原 + +这才接近 Unity 的“拖入模型得到层级实例”。 + +### 9.4 子资产访问 + +本轮不强制要求 Project 面板完整展开子资产树,但至少应保证: + +1. `MeshFilter` 能引用模型产出的某个 `Mesh` 子资产 +2. 场景序列化后引用稳定 +3. 后续补 Project 子资产展开 UI 时不需要推倒重来 + +--- + +## 10. 场景实例化与运行时路径 + +### 10.1 不建议增加 `ModelComponent` + +为了贴近 Unity,建议不要把“整个模型实例”抽成一个新的 `ModelComponent` 挂在单个对象上。 + +更合理的做法是: + +1. `Model` 是资源 +2. “实例化模型”是一个 utility / service +3. 实例化结果是 `GameObject` 层级 + +### 10.2 建议新增实例化工具层 + +可以新增例如: + +1. `ModelInstantiationUtility` +2. `InstantiateModelAsset(...)` + +职责: + +1. 根据 `Model` 创建场景层级 +2. 为每个节点恢复局部 TRS +3. 绑定子 `Mesh` +4. 绑定默认材质或导入材质 + +### 10.3 与现有渲染链的兼容策略 + +现有渲染链以 `MeshFilter + MeshRenderer` 为中心,这一层本轮不应推翻。 + +本轮应当: + +1. 保持 runtime 渲染主链稳定 +2. 通过新的 `Model -> GameObject hierarchy` 实例化路径,把 `Model` 翻译回现有组件体系 + +这样风险最小,且后续加 `SkinnedMeshRenderer` 时也有自然落点。 + +--- + +## 11. 测试与验证计划 + +### 11.1 Fixture 规划 + +必须新增真实模型 fixture,不再只停留在字符串层面的 `CanLoad("test.fbx")`。 + +建议至少准备: + +1. `single_mesh_static.obj` +2. `single_mesh_static.fbx` +3. `multi_node_static.fbx` +4. `multi_material_static.fbx` +5. `embedded_texture_static.fbx` +6. `external_texture_static.fbx` + +### 11.2 Unit Test + +建议新增或扩展: + +1. `ModelLoader` 基础加载测试 +2. `ModelImportSettings` 持久化测试 +3. `ModelArtifact` 写入/回读测试 +4. `LocalID` 稳定性测试 +5. `AssetDatabase` 主资产/子资产引用测试 + +### 11.3 Integration Test + +建议新增: + +1. `FBX -> xcmodel -> scene instantiate` 集成测试 +2. reimport 后 `AssetRef` 稳定性测试 +3. 多节点模型实例化后层级与局部变换恢复测试 +4. 多材质槽绑定测试 + +### 11.4 Editor Test + +建议补: + +1. Project 面板模型类型识别 +2. Model Importer Inspector 设置编辑 +3. Apply/Reimport 行为 +4. 拖模型到场景生成层级 + +### 11.5 验收原则 + +本轮不能以“能读进一个 FBX 文件”作为完成标准。 + +必须同时满足: + +1. artifact 正确 +2. hierarchy 正确 +3. reimport 稳定 +4. editor 工作流成立 + +--- + +## 12. 分阶段执行计划 + +### Phase 1:`Model` 资源与 artifact 基础设施落地 + +目标: + +1. 新增 `ResourceType::Model` +2. 新增 `Model` 类与 `ModelLoader` +3. 新增 `xcmodel` artifact 格式 + +任务: + +1. 设计 `ModelNode / ModelMeshBinding / ModelMaterialBinding` +2. 在 `ArtifactFormats` 中定义 `ModelArtifactHeader` +3. 实现 `WriteModelArtifactFile / LoadModelArtifact` +4. 让 `ResourceManager` 注册 `ModelLoader` + +验收标准: + +1. `xcmodel` 能写出与读回 +2. `Model` 可被 `ResourceManager` 正式加载 + +### Phase 2:source import 主链从 `Mesh` 迁移到 `Model` + +目标: + +1. 建立 `AssimpModelImporter` +2. 将 `.obj/.fbx/...` 的正式导入主链切换到 `Model` + +任务: + +1. 建立 `ImportedModel` 中间结构 +2. 从 Assimp scene 提取节点层级、局部 TRS、mesh、material、texture +3. 停止在导入主链中把 node transform 烘平到单个主 mesh +4. 输出 `main.xcmodel + sub assets` + +验收标准: + +1. `OBJ` 被导入成简化 `Model` +2. `FBX` 被导入成保留 hierarchy 的 `Model` + +### Phase 3:`AssetDatabase` 子资产与稳定 `LocalID` + +目标: + +1. 完成主资产/子资产语义 +2. 建立稳定 `LocalID` 规则 + +任务: + +1. 修改 `ModelImporter` 的主资源类型 +2. 实现模型子资产 `AssetRef` +3. 让 `EnsureArtifact`、`TryGetAssetRef`、序列化链路理解 `Model` 主资产与子 `Mesh` +4. 建立 reimport 稳定性测试 + +验收标准: + +1. reimport 后子资产引用不漂移 +2. 场景中的 mesh 引用可稳定恢复 + +### Phase 4:`.meta` 与 Model Import Settings 正式化 + +目标: + +1. 让模型导入参数进入正式工作流 + +任务: + +1. 定义 `ModelImportSettings` +2. 把 settings 写入 `.meta` +3. 调整 `metaHash` +4. 保证 settings 变化触发 reimport + +验收标准: + +1. 改 scale/axis/material 等设置会稳定影响导入结果 +2. artifact key 与导入设置一致变化 + +### Phase 5:编辑器 Inspector 与场景实例化工作流 + +目标: + +1. 建立 Unity 风格的模型资源使用体验 + +任务: + +1. Project 面板统一把模型文件识别为 `Model` +2. 新增 Model Importer Inspector +3. 实现 `Apply/Reimport` +4. 实现拖模型到场景生成 `GameObject` 层级 + +验收标准: + +1. 从 editor 可完整配置模型导入 +2. 拖入场景后层级与局部变换正确 + +### Phase 6:清理过渡路径与补齐文档测试 + +目标: + +1. 收紧旧路径,避免双轨架构长期并存 + +任务: + +1. 评估并逐步弱化“source file 直接由 `MeshLoader` 作为主入口”的旧路径 +2. 清理命名、文档、注释、测试目录结构 +3. 输出阶段总结 + +验收标准: + +1. 主链路清晰,旧路径只保留必要兼容 +2. 文档与测试同步完成 + +--- + +## 13. 关键风险与应对 + +### 13.1 最大风险:`LocalID` 不稳定 + +风险: + +1. reimport 后子资产重排 +2. 场景与 prefab 引用失效 + +应对: + +1. 在 Phase 1 之前先写清 `LocalID` 规则 +2. 在 Phase 3 前就建立稳定性测试 + +### 13.2 第二风险:轴系与缩放规则前后不一致 + +风险: + +1. 同一模型在 direct load、artifact load、scene instantiate 三条路径下结果不一致 + +应对: + +1. 明确标准化规则只允许一个真值来源 +2. 写入 importer settings 并进入测试 + +### 13.3 第三风险:编辑器体验与底层数据不同步 + +风险: + +1. Inspector 改了设置但 reimport 行为不稳定 +2. Project 面板显示的是 `Model`,内部实际仍按 `Mesh` 走 + +应对: + +1. 先完成 Resource/AssetDatabase 语义,再接 editor UI +2. 不允许 editor 先行伪装完成 + +### 13.4 第四风险:过渡期双轨逻辑长期共存 + +风险: + +1. `MeshLoader` source import 与 `ModelImporter` source import 两套路径并行,长期维护成本失控 + +应对: + +1. 在 Phase 6 明确收紧旧路径 +2. 把 source import 主入口统一到 `ModelImporter` + +--- + +## 14. 本轮完成标志 + +当以下条件同时成立时,本轮才算真正完成: + +1. `.obj/.fbx/.gltf/.glb/...` 的主资产统一为 `Model` +2. `Model` artifact 已正式落地并进入 `ResourceManager` +3. `OBJ` 能导入成简化 `Model` +4. `FBX` 能导入成保留 hierarchy 的静态 `Model` +5. 模型导入设置已写入 `.meta` 并进入 reimport 逻辑 +6. editor 中已具备 Model Importer Inspector +7. 拖模型到场景时会生成 `GameObject` 层级,而非单个烘平 mesh +8. 子资产 `LocalID` 在普通 reimport 下稳定 +9. 测试已覆盖真实 `FBX` fixture 的关键路径 + +--- + +## 15. 一句话结论 + +这一轮不是“给 FBX 补支持”,而是把 XCEngine 的外部模型资源体系从“以 `Mesh` 为主资产的静态导入器”升级成“以 `Model` 为主资产、以子资产和稳定 reimport 为基础、可持续扩展到骨骼动画的 Unity 风格模型工作流”。 diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 77de58dc..7f63d3ed 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -363,6 +363,13 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Texture/TextureLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Texture/TextureImportSettings.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/BuiltinResources.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Model/AssimpModelImporter.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Model/Model.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Model/ModelArtifactIO.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Model/ModelLoader.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/GaussianSplat/GaussianSplat.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/GaussianSplat/GaussianSplatArtifactIO.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/GaussianSplat/GaussianSplatLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Mesh/Mesh.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Mesh/MeshLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Resources/Mesh/MeshImportSettings.h @@ -382,6 +389,14 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Texture/TextureLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Texture/TextureImportSettings.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/BuiltinResources.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Model/AssimpModelImporter.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Model/Model.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Model/ModelArtifactIO.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Model/ModelLoader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/GaussianSplat/GaussianSplat.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/GaussianSplat/GaussianSplatArtifactIO.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/GaussianSplat/GaussianSplatLoader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Mesh/Mesh.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Mesh/MeshLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/Resources/Mesh/MeshImportSettings.cpp diff --git a/engine/include/XCEngine/Core/Asset/ArtifactFormats.h b/engine/include/XCEngine/Core/Asset/ArtifactFormats.h index 5c61e29d..1b16dbfe 100644 --- a/engine/include/XCEngine/Core/Asset/ArtifactFormats.h +++ b/engine/include/XCEngine/Core/Asset/ArtifactFormats.h @@ -1,10 +1,13 @@ #pragma once +#include +#include #include #include #include #include #include +#include #include namespace XCEngine { @@ -16,6 +19,8 @@ constexpr Core::uint32 kMeshArtifactSchemaVersion = 2; constexpr Core::uint32 kShaderArtifactSchemaVersion = 5; constexpr Core::uint32 kUIDocumentArtifactSchemaVersion = 2; constexpr Core::uint32 kVolumeFieldArtifactSchemaVersion = 2; +constexpr Core::uint32 kModelArtifactSchemaVersion = 1; +constexpr Core::uint32 kGaussianSplatArtifactSchemaVersion = 1; struct TextureArtifactHeader { char magic[8] = { 'X', 'C', 'T', 'E', 'X', '0', '1', '\0' }; @@ -66,6 +71,38 @@ struct MaterialPropertyArtifact { MaterialProperty::Value value = {}; }; +struct ModelArtifactFileHeader { + char magic[8] = { 'X', 'C', 'M', 'O', 'D', '0', '1', '\0' }; + Core::uint32 schemaVersion = kModelArtifactSchemaVersion; +}; + +struct ModelArtifactHeader { + Core::uint32 nodeCount = 0; + Core::uint32 meshBindingCount = 0; + Core::uint32 materialBindingCount = 0; + Core::uint32 rootNodeIndex = 0xffffffffu; +}; + +struct ModelNodeArtifactHeader { + Core::int32 parentIndex = -1; + Core::uint32 meshBindingStart = 0; + Core::uint32 meshBindingCount = 0; + Math::Vector3 localPosition = Math::Vector3::Zero(); + Math::Quaternion localRotation = Math::Quaternion::Identity(); + Math::Vector3 localScale = Math::Vector3(1.0f, 1.0f, 1.0f); +}; + +struct ModelMeshBindingArtifact { + LocalID meshLocalID = kInvalidLocalID; + Core::uint32 materialBindingStart = 0; + Core::uint32 materialBindingCount = 0; +}; + +struct ModelMaterialBindingArtifact { + Core::uint32 slotIndex = 0; + LocalID materialLocalID = kInvalidLocalID; +}; + struct ShaderArtifactFileHeader { char magic[8] = { 'X', 'C', 'S', 'H', 'D', '0', '5', '\0' }; Core::uint32 schemaVersion = kShaderArtifactSchemaVersion; @@ -144,5 +181,36 @@ struct VolumeFieldArtifactHeader { Core::uint64 payloadSize = 0; }; +struct GaussianSplatArtifactFileHeader { + char magic[8] = { 'X', 'C', 'G', 'S', 'P', '0', '1', '\0' }; + Core::uint32 schemaVersion = kGaussianSplatArtifactSchemaVersion; +}; + +struct GaussianSplatArtifactHeader { + Core::uint32 contentVersion = 1; + Core::uint32 splatCount = 0; + Core::uint32 chunkCount = 0; + Core::uint32 cameraCount = 0; + Math::Vector3 boundsMin = Math::Vector3::Zero(); + Math::Vector3 boundsMax = Math::Vector3::Zero(); + Core::uint32 positionFormat = static_cast(GaussianSplatSectionFormat::VectorFloat32); + Core::uint32 otherFormat = static_cast(GaussianSplatSectionFormat::OtherFloat32); + Core::uint32 colorFormat = static_cast(GaussianSplatSectionFormat::ColorRGBA32F); + Core::uint32 shFormat = static_cast(GaussianSplatSectionFormat::SHFloat32); + Core::uint32 chunkFormat = static_cast(GaussianSplatSectionFormat::Unknown); + Core::uint32 cameraFormat = static_cast(GaussianSplatSectionFormat::Unknown); + Core::uint32 sectionCount = 0; + Core::uint64 payloadSize = 0; +}; + +struct GaussianSplatArtifactSectionRecord { + Core::uint32 sectionType = static_cast(GaussianSplatSectionType::Unknown); + Core::uint32 format = static_cast(GaussianSplatSectionFormat::Unknown); + Core::uint64 payloadOffset = 0; + Core::uint64 dataSize = 0; + Core::uint32 elementCount = 0; + Core::uint32 elementStride = 0; +}; + } // namespace Resources } // namespace XCEngine diff --git a/engine/include/XCEngine/Core/Asset/AssetDatabase.h b/engine/include/XCEngine/Core/Asset/AssetDatabase.h index 04e38d8b..834fda3e 100644 --- a/engine/include/XCEngine/Core/Asset/AssetDatabase.h +++ b/engine/include/XCEngine/Core/Asset/AssetDatabase.h @@ -82,6 +82,7 @@ public: bool TryGetAssetGuid(const Containers::String& requestPath, AssetGUID& outGuid) const; bool TryGetImportableResourceType(const Containers::String& requestPath, ResourceType& outType) const; bool TryGetAssetRef(const Containers::String& requestPath, ResourceType resourceType, AssetRef& outRef) const; + bool TryResolveAssetPath(const AssetRef& assetRef, Containers::String& outPath); bool ReimportAsset(const Containers::String& requestPath, ResolvedAsset& outAsset, MaintenanceStats* outStats = nullptr); @@ -139,6 +140,8 @@ private: ArtifactRecord& outRecord); bool ImportShaderAsset(const SourceAssetRecord& sourceRecord, ArtifactRecord& outRecord); + bool ImportGaussianSplatAsset(const SourceAssetRecord& sourceRecord, + ArtifactRecord& outRecord); bool ImportVolumeFieldAsset(const SourceAssetRecord& sourceRecord, ArtifactRecord& outRecord); bool ImportUIDocumentAsset(const SourceAssetRecord& sourceRecord, @@ -161,7 +164,7 @@ private: ArtifactDependencyRecord& outRecord) const; bool AreDependenciesCurrent(const std::vector& dependencies) const; bool CollectModelDependencies(const SourceAssetRecord& sourceRecord, - const Mesh& mesh, + const std::vector& importedTexturePaths, std::vector& outDependencies) const; bool CollectMaterialDependencies(const Material& material, std::vector& outDependencies) const; diff --git a/engine/include/XCEngine/Core/Asset/AssetImportService.h b/engine/include/XCEngine/Core/Asset/AssetImportService.h index 795b3cd6..f747940f 100644 --- a/engine/include/XCEngine/Core/Asset/AssetImportService.h +++ b/engine/include/XCEngine/Core/Asset/AssetImportService.h @@ -75,6 +75,8 @@ public: bool TryGetAssetRef(const Containers::String& requestPath, ResourceType resourceType, AssetRef& outRef) const; + bool TryResolveAssetPath(const AssetRef& assetRef, + Containers::String& outPath); bool TryGetPrimaryAssetPath(const AssetGUID& guid, Containers::String& outRelativePath) const; void BuildLookupSnapshot(LookupSnapshot& outSnapshot) const; diff --git a/engine/include/XCEngine/Core/Asset/ProjectAssetIndex.h b/engine/include/XCEngine/Core/Asset/ProjectAssetIndex.h index de72bcb3..2e58f170 100644 --- a/engine/include/XCEngine/Core/Asset/ProjectAssetIndex.h +++ b/engine/include/XCEngine/Core/Asset/ProjectAssetIndex.h @@ -21,7 +21,7 @@ public: const Containers::String& path, ResourceType resourceType, AssetRef& outRef) const; - bool TryResolveAssetPath(const AssetImportService& importService, + bool TryResolveAssetPath(AssetImportService& importService, const AssetRef& assetRef, Containers::String& outPath) const; void RememberResolvedPath(const AssetGUID& assetGuid, const Containers::String& relativePath); diff --git a/engine/include/XCEngine/Core/Asset/ResourceTypes.h b/engine/include/XCEngine/Core/Asset/ResourceTypes.h index 87a2f25c..2d9e04a3 100644 --- a/engine/include/XCEngine/Core/Asset/ResourceTypes.h +++ b/engine/include/XCEngine/Core/Asset/ResourceTypes.h @@ -26,7 +26,9 @@ enum class ResourceType : Core::uint8 { UIView, UITheme, UISchema, - VolumeField + VolumeField, + Model, + GaussianSplat }; constexpr const char* GetResourceTypeName(ResourceType type) { @@ -47,6 +49,8 @@ constexpr const char* GetResourceTypeName(ResourceType type) { case ResourceType::UITheme: return "UITheme"; case ResourceType::UISchema: return "UISchema"; case ResourceType::VolumeField: return "VolumeField"; + case ResourceType::Model: return "Model"; + case ResourceType::GaussianSplat: return "GaussianSplat"; default: return "Unknown"; } } @@ -101,6 +105,8 @@ template<> inline ResourceType GetResourceType() { return Resource template<> inline ResourceType GetResourceType() { return ResourceType::UITheme; } template<> inline ResourceType GetResourceType() { return ResourceType::UISchema; } template<> inline ResourceType GetResourceType() { return ResourceType::VolumeField; } +template<> inline ResourceType GetResourceType() { return ResourceType::Model; } +template<> inline ResourceType GetResourceType() { return ResourceType::GaussianSplat; } } // namespace Resources } // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplat.h b/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplat.h new file mode 100644 index 00000000..957b99f6 --- /dev/null +++ b/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplat.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +enum class GaussianSplatSectionType : Core::uint32 { + Unknown = 0, + Positions = 1, + Other = 2, + Color = 3, + SH = 4, + Chunks = 5, + Cameras = 6 +}; + +enum class GaussianSplatSectionFormat : Core::uint32 { + Unknown = 0, + VectorFloat32 = 1, + VectorFloat16 = 2, + VectorNorm16 = 3, + VectorNorm11 = 4, + VectorNorm6 = 5, + OtherFloat32 = 16, + OtherPacked32 = 17, + ColorRGBA32F = 32, + ColorRGBA16F = 33, + ColorRGBA8 = 34, + ColorBC7 = 35, + SHFloat32 = 48, + SHFloat16 = 49, + SHNorm11 = 50, + SHNorm6 = 51, + ChunkFloat32 = 64, + CameraFloat32 = 80 +}; + +constexpr Core::uint32 kGaussianSplatSHCoefficientCount = 45; + +struct GaussianSplatPositionRecord { + Math::Vector3 position = Math::Vector3::Zero(); +}; + +struct GaussianSplatOtherRecord { + Math::Quaternion rotation = Math::Quaternion::Identity(); + Math::Vector3 scale = Math::Vector3::Zero(); + float reserved = 0.0f; +}; + +struct GaussianSplatColorRecord { + Math::Vector4 colorOpacity = Math::Vector4::Zero(); +}; + +struct GaussianSplatSHRecord { + float coefficients[kGaussianSplatSHCoefficientCount] = {}; +}; + +struct GaussianSplatSection { + GaussianSplatSectionType type = GaussianSplatSectionType::Unknown; + GaussianSplatSectionFormat format = GaussianSplatSectionFormat::Unknown; + Core::uint64 dataOffset = 0; + Core::uint64 dataSize = 0; + Core::uint32 elementCount = 0; + Core::uint32 elementStride = 0; +}; + +struct GaussianSplatMetadata { + Core::uint32 contentVersion = 1; + Core::uint32 splatCount = 0; + Core::uint32 chunkCount = 0; + Core::uint32 cameraCount = 0; + Math::Bounds bounds; + GaussianSplatSectionFormat positionFormat = GaussianSplatSectionFormat::VectorFloat32; + GaussianSplatSectionFormat otherFormat = GaussianSplatSectionFormat::OtherFloat32; + GaussianSplatSectionFormat colorFormat = GaussianSplatSectionFormat::ColorRGBA32F; + GaussianSplatSectionFormat shFormat = GaussianSplatSectionFormat::SHFloat32; + GaussianSplatSectionFormat chunkFormat = GaussianSplatSectionFormat::Unknown; + GaussianSplatSectionFormat cameraFormat = GaussianSplatSectionFormat::Unknown; +}; + +class GaussianSplat : public IResource { +public: + GaussianSplat(); + ~GaussianSplat() override; + + ResourceType GetType() const override { return ResourceType::GaussianSplat; } + const Containers::String& GetName() const override { return m_name; } + const Containers::String& GetPath() const override { return m_path; } + ResourceGUID GetGUID() const override { return m_guid; } + bool IsValid() const override { return m_isValid; } + size_t GetMemorySize() const override { return m_memorySize; } + void Release() override; + + bool CreateOwned(const GaussianSplatMetadata& metadata, + Containers::Array&& sections, + Containers::Array&& payload); + void Clear(); + + const GaussianSplatMetadata& GetMetadata() const { return m_metadata; } + Core::uint32 GetContentVersion() const { return m_metadata.contentVersion; } + Core::uint32 GetSplatCount() const { return m_metadata.splatCount; } + Core::uint32 GetChunkCount() const { return m_metadata.chunkCount; } + Core::uint32 GetCameraCount() const { return m_metadata.cameraCount; } + const Math::Bounds& GetBounds() const { return m_metadata.bounds; } + GaussianSplatSectionFormat GetPositionFormat() const { return m_metadata.positionFormat; } + GaussianSplatSectionFormat GetOtherFormat() const { return m_metadata.otherFormat; } + GaussianSplatSectionFormat GetColorFormat() const { return m_metadata.colorFormat; } + GaussianSplatSectionFormat GetSHFormat() const { return m_metadata.shFormat; } + GaussianSplatSectionFormat GetChunkFormat() const { return m_metadata.chunkFormat; } + GaussianSplatSectionFormat GetCameraFormat() const { return m_metadata.cameraFormat; } + const Containers::Array& GetSections() const { return m_sections; } + const GaussianSplatSection* FindSection(GaussianSplatSectionType type) const; + const void* GetSectionData(GaussianSplatSectionType type) const; + const GaussianSplatPositionRecord* GetPositionRecords() const; + const GaussianSplatOtherRecord* GetOtherRecords() const; + const GaussianSplatColorRecord* GetColorRecords() const; + const GaussianSplatSHRecord* GetSHRecords() const; + const Core::uint8* GetPayloadData() const { return m_payload.Data(); } + size_t GetPayloadSize() const { return m_payload.Size(); } + +private: + bool ValidateSections(const Containers::Array& sections, size_t payloadSize) const; + void UpdateMemorySize(); + + GaussianSplatMetadata m_metadata = {}; + Containers::Array m_sections; + Containers::Array m_payload; +}; + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatArtifactIO.h b/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatArtifactIO.h new file mode 100644 index 00000000..97d8a4a2 --- /dev/null +++ b/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatArtifactIO.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace XCEngine { +namespace Resources { + +class GaussianSplat; + +bool WriteGaussianSplatArtifactFile(const Containers::String& artifactPath, + const GaussianSplat& gaussianSplat, + Containers::String* outErrorMessage = nullptr); + +LoadResult LoadGaussianSplatArtifact(const Containers::String& path); + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatLoader.h b/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatLoader.h new file mode 100644 index 00000000..31aa6437 --- /dev/null +++ b/engine/include/XCEngine/Resources/GaussianSplat/GaussianSplatLoader.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace XCEngine { +namespace Resources { + +class GaussianSplatLoader : public IResourceLoader { +public: + GaussianSplatLoader(); + ~GaussianSplatLoader() override; + + ResourceType GetResourceType() const override { return ResourceType::GaussianSplat; } + Containers::Array GetSupportedExtensions() const override; + bool CanLoad(const Containers::String& path) const override; + LoadResult Load(const Containers::String& path, const ImportSettings* settings = nullptr) override; + ImportSettings* GetDefaultSettings() const override; +}; + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/Model/AssimpModelImporter.h b/engine/include/XCEngine/Resources/Model/AssimpModelImporter.h new file mode 100644 index 00000000..8af8a0ed --- /dev/null +++ b/engine/include/XCEngine/Resources/Model/AssimpModelImporter.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#include + +namespace XCEngine { +namespace Resources { + +class Model; +class Mesh; +class Material; +class Texture; + +struct ImportedModelMesh { + LocalID localID = kInvalidLocalID; + Mesh* mesh = nullptr; + std::vector materialLocalIDs; +}; + +struct ImportedModelMaterial { + LocalID localID = kInvalidLocalID; + Material* material = nullptr; +}; + +struct ImportedModelData { + Model* model = nullptr; + std::vector meshes; + std::vector materials; + std::vector textures; + + void Reset(); +}; + +bool ImportAssimpModel(const Containers::String& sourcePath, + const MeshImportSettings& settings, + ImportedModelData& outData, + Containers::String* outErrorMessage = nullptr); + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/Model/Model.h b/engine/include/XCEngine/Resources/Model/Model.h new file mode 100644 index 00000000..680b4297 --- /dev/null +++ b/engine/include/XCEngine/Resources/Model/Model.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +constexpr Core::uint32 kInvalidModelNodeIndex = 0xffffffffu; + +struct ModelMaterialBinding { + Core::uint32 slotIndex = 0; + LocalID materialLocalID = kInvalidLocalID; +}; + +struct ModelMeshBinding { + LocalID meshLocalID = kInvalidLocalID; + Core::uint32 materialBindingStart = 0; + Core::uint32 materialBindingCount = 0; +}; + +struct ModelNode { + Containers::String name; + Core::int32 parentIndex = -1; + Core::uint32 meshBindingStart = 0; + Core::uint32 meshBindingCount = 0; + Math::Vector3 localPosition = Math::Vector3::Zero(); + Math::Quaternion localRotation = Math::Quaternion::Identity(); + Math::Vector3 localScale = Math::Vector3(1.0f, 1.0f, 1.0f); +}; + +class Model : public IResource { +public: + Model(); + ~Model() override; + + ResourceType GetType() const override { return ResourceType::Model; } + const Containers::String& GetName() const override { return m_name; } + const Containers::String& GetPath() const override { return m_path; } + ResourceGUID GetGUID() const override { return m_guid; } + bool IsValid() const override { return m_isValid; } + size_t GetMemorySize() const override { return m_memorySize; } + void Release() override; + + void SetRootNodeIndex(Core::uint32 index); + Core::uint32 GetRootNodeIndex() const { return m_rootNodeIndex; } + bool HasRootNode() const { return m_rootNodeIndex != kInvalidModelNodeIndex; } + + void AddNode(const ModelNode& node); + void AddMeshBinding(const ModelMeshBinding& binding); + void AddMaterialBinding(const ModelMaterialBinding& binding); + void ClearGraph(); + + const Containers::Array& GetNodes() const { return m_nodes; } + const Containers::Array& GetMeshBindings() const { return m_meshBindings; } + const Containers::Array& GetMaterialBindings() const { return m_materialBindings; } + +private: + void UpdateMemorySize(); + + Core::uint32 m_rootNodeIndex = kInvalidModelNodeIndex; + Containers::Array m_nodes; + Containers::Array m_meshBindings; + Containers::Array m_materialBindings; +}; + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/Model/ModelArtifactIO.h b/engine/include/XCEngine/Resources/Model/ModelArtifactIO.h new file mode 100644 index 00000000..d69cdeaf --- /dev/null +++ b/engine/include/XCEngine/Resources/Model/ModelArtifactIO.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace XCEngine { +namespace Resources { + +class Model; + +bool WriteModelArtifactFile(const Containers::String& artifactPath, + const Model& model, + Containers::String* outErrorMessage = nullptr); + +LoadResult LoadModelArtifact(const Containers::String& path); + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/Model/ModelLoader.h b/engine/include/XCEngine/Resources/Model/ModelLoader.h new file mode 100644 index 00000000..1bd55709 --- /dev/null +++ b/engine/include/XCEngine/Resources/Model/ModelLoader.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace XCEngine { +namespace Resources { + +class ModelLoader : public IResourceLoader { +public: + ModelLoader(); + ~ModelLoader() override; + + ResourceType GetResourceType() const override { return ResourceType::Model; } + Containers::Array GetSupportedExtensions() const override; + bool CanLoad(const Containers::String& path) const override; + LoadResult Load(const Containers::String& path, const ImportSettings* settings = nullptr) override; + ImportSettings* GetDefaultSettings() const override; +}; + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/Resources.h b/engine/include/XCEngine/Resources/Resources.h index f91ac02d..faf6f2e4 100644 --- a/engine/include/XCEngine/Resources/Resources.h +++ b/engine/include/XCEngine/Resources/Resources.h @@ -14,6 +14,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/engine/src/Core/Asset/AssetDatabase.cpp b/engine/src/Core/Asset/AssetDatabase.cpp index a05ba140..3b953a49 100644 --- a/engine/src/Core/Asset/AssetDatabase.cpp +++ b/engine/src/Core/Asset/AssetDatabase.cpp @@ -2,8 +2,13 @@ #include #include +#include #include +#include #include +#include +#include +#include "Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.h" #include #include #include @@ -48,6 +53,13 @@ Containers::String ToContainersString(const std::string& value) { Containers::String NormalizeArtifactPathString(const fs::path& path); Containers::String NormalizeArtifactPathString(const Containers::String& path); +constexpr const char* kModelSubAssetManifestFileName = "subassets.tsv"; + +struct ModelSubAssetManifestEntry { + LocalID localID = kInvalidLocalID; + ResourceType resourceType = ResourceType::Unknown; + Containers::String artifactPath; +}; void PopulateResolvedAssetResult(const Containers::String& projectRoot, const AssetDatabase::SourceAssetRecord& sourceRecord, @@ -320,6 +332,85 @@ std::vector SplitFields(const std::string& line) { return fields; } +bool WriteModelSubAssetManifest(const fs::path& manifestPath, + const std::vector& entries) { + std::ofstream output(manifestPath, std::ios::out | std::ios::trunc); + if (!output.is_open()) { + return false; + } + + output << "# localID\tresourceType\tartifactPath\n"; + for (const ModelSubAssetManifestEntry& entry : entries) { + if (entry.localID == kInvalidLocalID || + entry.resourceType == ResourceType::Unknown || + entry.artifactPath.Empty()) { + continue; + } + + output << entry.localID << '\t' + << static_cast(entry.resourceType) << '\t' + << EscapeField(ToStdString(entry.artifactPath)) << '\n'; + } + + return static_cast(output); +} + +bool TryReadModelSubAssetManifest(const fs::path& manifestPath, + std::vector& outEntries) { + outEntries.clear(); + + std::ifstream input(manifestPath); + if (!input.is_open()) { + return false; + } + + std::string line; + while (std::getline(input, line)) { + if (line.empty() || line[0] == '#') { + continue; + } + + const std::vector fields = SplitFields(line); + if (fields.size() < 3) { + continue; + } + + ModelSubAssetManifestEntry entry; + entry.localID = static_cast(std::stoull(fields[0])); + entry.resourceType = static_cast(std::stoul(fields[1])); + entry.artifactPath = ToContainersString(fields[2]); + if (entry.localID == kInvalidLocalID || + entry.resourceType == ResourceType::Unknown || + entry.artifactPath.Empty()) { + continue; + } + + outEntries.push_back(entry); + } + + return true; +} + +bool TryResolveModelSubAssetArtifactPath(const fs::path& manifestPath, + const AssetRef& assetRef, + Containers::String& outArtifactPath) { + std::vector entries; + if (!TryReadModelSubAssetManifest(manifestPath, entries)) { + return false; + } + + for (const ModelSubAssetManifestEntry& entry : entries) { + if (entry.localID != assetRef.localID || entry.resourceType != assetRef.resourceType) { + continue; + } + + outArtifactPath = entry.artifactPath; + return true; + } + + return false; +} + void WriteString(std::ofstream& stream, const Containers::String& value) { const Core::uint32 length = static_cast(value.Length()); stream.write(reinterpret_cast(&length), sizeof(length)); @@ -825,6 +916,74 @@ bool AssetDatabase::TryGetAssetRef(const Containers::String& requestPath, return true; } +bool AssetDatabase::TryResolveAssetPath(const AssetRef& assetRef, Containers::String& outPath) { + outPath.Clear(); + ClearLastErrorMessage(); + + if (!assetRef.IsValid()) { + return false; + } + + if (assetRef.localID == kMainAssetLocalID) { + return TryGetPrimaryAssetPath(assetRef.assetGuid, outPath); + } + + const auto sourceIt = m_sourcesByGuid.find(assetRef.assetGuid); + if (sourceIt == m_sourcesByGuid.end()) { + SetLastErrorMessage(Containers::String("Unknown asset GUID for sub-asset path resolution: ") + + assetRef.assetGuid.ToString()); + return false; + } + + const SourceAssetRecord& sourceRecord = sourceIt->second; + const ResourceType primaryType = GetPrimaryResourceTypeForImporter(sourceRecord.importerName); + if (primaryType == ResourceType::Unknown) { + SetLastErrorMessage(Containers::String("Asset does not have an importable primary type: ") + + sourceRecord.relativePath); + return false; + } + + auto resolveFromArtifactRecord = [&](const ArtifactRecord& artifactRecord) -> bool { + const fs::path manifestPath = + fs::path(m_projectRoot.CStr()) / + artifactRecord.artifactDirectory.CStr() / + kModelSubAssetManifestFileName; + + Containers::String artifactPath; + if (!TryResolveModelSubAssetArtifactPath(manifestPath, assetRef, artifactPath)) { + return false; + } + + outPath = NormalizePathString(fs::path(m_projectRoot.CStr()) / artifactPath.CStr()); + return true; + }; + + auto artifactIt = m_artifactsByGuid.find(assetRef.assetGuid); + if (artifactIt != m_artifactsByGuid.end() && + !ShouldReimport(sourceRecord, &artifactIt->second) && + resolveFromArtifactRecord(artifactIt->second)) { + return true; + } + + ResolvedAsset resolvedAsset; + if (!EnsureArtifact(sourceRecord.relativePath, primaryType, resolvedAsset)) { + if (m_lastErrorMessage.Empty()) { + SetLastErrorMessage(Containers::String("Failed to import asset while resolving sub-asset path: ") + + sourceRecord.relativePath); + } + return false; + } + + artifactIt = m_artifactsByGuid.find(assetRef.assetGuid); + if (artifactIt != m_artifactsByGuid.end() && resolveFromArtifactRecord(artifactIt->second)) { + return true; + } + + SetLastErrorMessage(Containers::String("Sub-asset localID was not found in artifact manifest: ") + + sourceRecord.relativePath); + return false; +} + bool AssetDatabase::ReimportAsset(const Containers::String& requestPath, ResolvedAsset& outAsset, MaintenanceStats* outStats) { @@ -1442,6 +1601,9 @@ Containers::String AssetDatabase::GetImporterNameForPath(const Containers::Strin if (ext == ".obj" || ext == ".fbx" || ext == ".gltf" || ext == ".glb" || ext == ".dae" || ext == ".stl") { return Containers::String("ModelImporter"); } + if (ext == ".ply") { + return Containers::String("GaussianSplatImporter"); + } if (ext == ".shader") { return Containers::String("ShaderImporter"); } @@ -1468,7 +1630,10 @@ ResourceType AssetDatabase::GetPrimaryResourceTypeForImporter(const Containers:: return ResourceType::Texture; } if (importerName == "ModelImporter") { - return ResourceType::Mesh; + return ResourceType::Model; + } + if (importerName == "GaussianSplatImporter") { + return ResourceType::GaussianSplat; } if (importerName == "MaterialImporter") { return ResourceType::Material; @@ -1488,6 +1653,10 @@ bool AssetDatabase::ShouldReimport(const SourceAssetRecord& sourceRecord, return true; } + if (artifactRecord->resourceType != GetPrimaryResourceTypeForImporter(sourceRecord.importerName)) { + return true; + } + if (artifactRecord->artifactKey.Empty() || artifactRecord->mainArtifactPath.Empty()) { return true; @@ -1525,8 +1694,10 @@ bool AssetDatabase::ImportAsset(const SourceAssetRecord& sourceRecord, return ImportTextureAsset(sourceRecord, outRecord); case ResourceType::Material: return ImportMaterialAsset(sourceRecord, outRecord); - case ResourceType::Mesh: + case ResourceType::Model: return ImportModelAsset(sourceRecord, outRecord); + case ResourceType::GaussianSplat: + return ImportGaussianSplatAsset(sourceRecord, outRecord); case ResourceType::Shader: return ImportShaderAsset(sourceRecord, outRecord); case ResourceType::VolumeField: @@ -1764,34 +1935,51 @@ bool AssetDatabase::ImportMaterialAsset(const SourceAssetRecord& sourceRecord, bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord, ArtifactRecord& outRecord) { - MeshLoader loader; - const Containers::String absolutePath = NormalizePathString(fs::path(m_projectRoot.CStr()) / sourceRecord.relativePath.CStr()); - LoadResult result = loader.Load(absolutePath); - if (!result || result.resource == nullptr) { + const Containers::String absolutePath = + NormalizePathString(fs::path(m_projectRoot.CStr()) / sourceRecord.relativePath.CStr()); + + MeshImportSettings importSettings; + ImportedModelData importedModel; + Containers::String importErrorMessage; + if (!ImportAssimpModel(absolutePath, importSettings, importedModel, &importErrorMessage)) { + if (!importErrorMessage.Empty()) { + SetLastErrorMessage(importErrorMessage); + } return false; } - Mesh* mesh = static_cast(result.resource); + std::vector importedTexturePaths; + importedTexturePaths.reserve(importedModel.textures.size()); + for (Texture* texture : importedModel.textures) { + if (texture == nullptr || texture->GetPath().Empty()) { + continue; + } + + importedTexturePaths.push_back(texture->GetPath()); + } + std::vector dependencies; - CollectModelDependencies(sourceRecord, *mesh, dependencies); + CollectModelDependencies(sourceRecord, importedTexturePaths, dependencies); const Containers::String artifactKey = BuildArtifactKey(sourceRecord, dependencies); const Containers::String artifactDir = BuildArtifactDirectory(artifactKey); - const Containers::String mainArtifactPath = NormalizePathString(fs::path(artifactDir.CStr()) / "main.xcmesh"); + const Containers::String mainArtifactPath = + NormalizePathString(fs::path(artifactDir.CStr()) / "main.xcmodel"); std::error_code ec; fs::remove_all(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec); ec.clear(); fs::create_directories(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec); if (ec) { - DestroyImportedMesh(mesh); + importedModel.Reset(); return false; } bool writeOk = true; + std::vector subAssetManifestEntries; std::unordered_map textureArtifactPaths; std::unordered_map textureAssetRefs; - for (size_t textureIndex = 0; writeOk && textureIndex < mesh->GetTextures().Size(); ++textureIndex) { - Texture* texture = mesh->GetTextures()[textureIndex]; + for (size_t textureIndex = 0; writeOk && textureIndex < importedModel.textures.size(); ++textureIndex) { + Texture* texture = importedModel.textures[textureIndex]; if (texture == nullptr) { continue; } @@ -1815,12 +2003,10 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord, } } - std::vector materialArtifactPaths; - materialArtifactPaths.reserve(mesh->GetMaterials().Size()); - for (size_t materialIndex = 0; writeOk && materialIndex < mesh->GetMaterials().Size(); ++materialIndex) { - Material* material = mesh->GetMaterials()[materialIndex]; - if (material == nullptr) { - materialArtifactPaths.emplace_back(); + std::unordered_map materialArtifactPathsByLocalID; + for (size_t materialIndex = 0; writeOk && materialIndex < importedModel.materials.size(); ++materialIndex) { + const ImportedModelMaterial& materialEntry = importedModel.materials[materialIndex]; + if (materialEntry.material == nullptr) { continue; } @@ -1828,7 +2014,7 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord, NormalizePathString(fs::path(artifactDir.CStr()) / ("material_" + std::to_string(materialIndex) + ".xcmat")); writeOk = WriteMaterialArtifactFile( fs::path(m_projectRoot.CStr()) / materialArtifactPath.CStr(), - *material, + *materialEntry.material, textureArtifactPaths, textureAssetRefs, this); @@ -1836,17 +2022,58 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord, break; } - materialArtifactPaths.push_back(materialArtifactPath); + materialArtifactPathsByLocalID.emplace(materialEntry.localID, materialArtifactPath); + subAssetManifestEntries.push_back( + ModelSubAssetManifestEntry{ materialEntry.localID, ResourceType::Material, materialArtifactPath }); } - writeOk = writeOk && - WriteMeshArtifactFile( - fs::path(m_projectRoot.CStr()) / mainArtifactPath.CStr(), - *mesh, - materialArtifactPaths); + for (size_t meshIndex = 0; writeOk && meshIndex < importedModel.meshes.size(); ++meshIndex) { + const ImportedModelMesh& meshEntry = importedModel.meshes[meshIndex]; + if (meshEntry.mesh == nullptr) { + continue; + } - DestroyImportedMesh(mesh); + std::vector meshMaterialArtifactPaths; + meshMaterialArtifactPaths.reserve(meshEntry.materialLocalIDs.size()); + for (LocalID materialLocalID : meshEntry.materialLocalIDs) { + const auto materialPathIt = materialArtifactPathsByLocalID.find(materialLocalID); + meshMaterialArtifactPaths.push_back( + materialPathIt != materialArtifactPathsByLocalID.end() + ? materialPathIt->second + : Containers::String()); + } + + const Containers::String meshArtifactPath = + NormalizePathString(fs::path(artifactDir.CStr()) / ("mesh_" + std::to_string(meshIndex) + ".xcmesh")); + writeOk = WriteMeshArtifactFile( + fs::path(m_projectRoot.CStr()) / meshArtifactPath.CStr(), + *meshEntry.mesh, + meshMaterialArtifactPaths); + if (writeOk) { + subAssetManifestEntries.push_back( + ModelSubAssetManifestEntry{ meshEntry.localID, ResourceType::Mesh, meshArtifactPath }); + } + } + + Containers::String modelWriteErrorMessage; + if (writeOk) { + writeOk = WriteModelArtifactFile( + NormalizePathString(fs::path(m_projectRoot.CStr()) / mainArtifactPath.CStr()), + *importedModel.model, + &modelWriteErrorMessage); + } + + if (writeOk) { + writeOk = WriteModelSubAssetManifest( + fs::path(m_projectRoot.CStr()) / artifactDir.CStr() / kModelSubAssetManifestFileName, + subAssetManifestEntries); + } + + importedModel.Reset(); if (!writeOk) { + if (!modelWriteErrorMessage.Empty()) { + SetLastErrorMessage(modelWriteErrorMessage); + } return false; } @@ -1854,7 +2081,7 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord, outRecord.assetGuid = sourceRecord.guid; outRecord.importerName = sourceRecord.importerName; outRecord.importerVersion = sourceRecord.importerVersion; - outRecord.resourceType = ResourceType::Mesh; + outRecord.resourceType = ResourceType::Model; outRecord.artifactDirectory = artifactDir; outRecord.mainArtifactPath = mainArtifactPath; outRecord.sourceHash = sourceRecord.sourceHash; @@ -1919,6 +2146,65 @@ bool AssetDatabase::ImportShaderAsset(const SourceAssetRecord& sourceRecord, return true; } +bool AssetDatabase::ImportGaussianSplatAsset(const SourceAssetRecord& sourceRecord, + ArtifactRecord& outRecord) { + const Containers::String absolutePath = + NormalizePathString(fs::path(m_projectRoot.CStr()) / sourceRecord.relativePath.CStr()); + + LoadResult result = Internal::ImportGaussianSplatPlyFile(absolutePath); + if (!result || result.resource == nullptr) { + if (!result.errorMessage.Empty()) { + SetLastErrorMessage(result.errorMessage); + } + return false; + } + + GaussianSplat* gaussianSplat = static_cast(result.resource); + const Containers::String artifactKey = BuildArtifactKey(sourceRecord); + const Containers::String artifactDir = BuildArtifactDirectory(artifactKey); + const Containers::String mainArtifactPath = + NormalizePathString(fs::path(artifactDir.CStr()) / "main.xcgsplat"); + + std::error_code ec; + fs::remove_all(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec); + ec.clear(); + fs::create_directories(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec); + if (ec) { + delete gaussianSplat; + return false; + } + + Containers::String writeErrorMessage; + const Containers::String gaussianSplatArtifactWritePath = + NormalizePathString(fs::path(m_projectRoot.CStr()) / mainArtifactPath.CStr()); + const bool writeOk = WriteGaussianSplatArtifactFile( + gaussianSplatArtifactWritePath, + *gaussianSplat, + &writeErrorMessage); + delete gaussianSplat; + if (!writeOk) { + if (!writeErrorMessage.Empty()) { + SetLastErrorMessage(writeErrorMessage); + } + return false; + } + + outRecord.artifactKey = artifactKey; + outRecord.assetGuid = sourceRecord.guid; + outRecord.importerName = sourceRecord.importerName; + outRecord.importerVersion = sourceRecord.importerVersion; + outRecord.resourceType = ResourceType::GaussianSplat; + outRecord.artifactDirectory = artifactDir; + outRecord.mainArtifactPath = mainArtifactPath; + outRecord.sourceHash = sourceRecord.sourceHash; + outRecord.metaHash = sourceRecord.metaHash; + outRecord.sourceFileSize = sourceRecord.sourceFileSize; + outRecord.sourceWriteTime = sourceRecord.sourceWriteTime; + outRecord.mainLocalID = kMainAssetLocalID; + outRecord.dependencies.clear(); + return true; +} + bool AssetDatabase::ImportVolumeFieldAsset(const SourceAssetRecord& sourceRecord, ArtifactRecord& outRecord) { VolumeFieldLoader loader; @@ -2157,7 +2443,7 @@ bool AssetDatabase::AreDependenciesCurrent( } bool AssetDatabase::CollectModelDependencies(const AssetDatabase::SourceAssetRecord& sourceRecord, - const Mesh& mesh, + const std::vector& importedTexturePaths, std::vector& outDependencies) const { outDependencies.clear(); @@ -2177,12 +2463,12 @@ bool AssetDatabase::CollectModelDependencies(const AssetDatabase::SourceAssetRec } } - for (Texture* texture : mesh.GetTextures()) { - if (texture == nullptr || texture->GetPath().Empty()) { + for (const Containers::String& texturePathValue : importedTexturePaths) { + if (texturePathValue.Empty()) { continue; } - const std::string texturePath = ToStdString(texture->GetPath()); + const std::string texturePath = ToStdString(texturePathValue); if (texturePath.find('#') != std::string::npos) { continue; } diff --git a/engine/src/Core/Asset/AssetImportService.cpp b/engine/src/Core/Asset/AssetImportService.cpp index b5f57264..861ed444 100644 --- a/engine/src/Core/Asset/AssetImportService.cpp +++ b/engine/src/Core/Asset/AssetImportService.cpp @@ -302,6 +302,16 @@ bool AssetImportService::TryGetAssetRef(const Containers::String& requestPath, return m_assetDatabase.TryGetAssetRef(requestPath, resourceType, outRef); } +bool AssetImportService::TryResolveAssetPath(const AssetRef& assetRef, + Containers::String& outPath) { + std::lock_guard lock(m_mutex); + if (m_projectRoot.Empty()) { + return false; + } + + return m_assetDatabase.TryResolveAssetPath(assetRef, outPath); +} + bool AssetImportService::TryGetPrimaryAssetPath(const AssetGUID& guid, Containers::String& outRelativePath) const { std::lock_guard lock(m_mutex); if (m_projectRoot.Empty()) { diff --git a/engine/src/Core/Asset/ProjectAssetIndex.cpp b/engine/src/Core/Asset/ProjectAssetIndex.cpp index 6502007d..f40db15e 100644 --- a/engine/src/Core/Asset/ProjectAssetIndex.cpp +++ b/engine/src/Core/Asset/ProjectAssetIndex.cpp @@ -131,13 +131,17 @@ bool ProjectAssetIndex::TryGetAssetRef(AssetImportService& importService, return resolved; } -bool ProjectAssetIndex::TryResolveAssetPath(const AssetImportService& importService, +bool ProjectAssetIndex::TryResolveAssetPath(AssetImportService& importService, const AssetRef& assetRef, Containers::String& outPath) const { if (!assetRef.IsValid()) { return false; } + if (assetRef.localID != kMainAssetLocalID) { + return importService.TryResolveAssetPath(assetRef, outPath); + } + bool resolved = false; { std::shared_lock lock(m_mutex); @@ -149,7 +153,7 @@ bool ProjectAssetIndex::TryResolveAssetPath(const AssetImportService& importServ } if (!resolved) { - resolved = importService.TryGetPrimaryAssetPath(assetRef.assetGuid, outPath); + resolved = importService.TryResolveAssetPath(assetRef, outPath); if (resolved) { const_cast(this)->RememberResolvedPath(assetRef.assetGuid, outPath); } diff --git a/engine/src/Core/Asset/ResourceManager.cpp b/engine/src/Core/Asset/ResourceManager.cpp index 191c5a05..3d23d656 100644 --- a/engine/src/Core/Asset/ResourceManager.cpp +++ b/engine/src/Core/Asset/ResourceManager.cpp @@ -2,7 +2,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -43,7 +45,9 @@ void RegisterBuiltinLoader(ResourceManager& manager, TLoader& loader) { } } +GaussianSplatLoader g_gaussianSplatLoader; MaterialLoader g_materialLoader; +ModelLoader g_modelLoader; MeshLoader g_meshLoader; ShaderLoader g_shaderLoader; TextureLoader g_textureLoader; @@ -87,7 +91,9 @@ void ResourceManager::EnsureInitialized() { Core::UniqueRef asyncLoader = Core::MakeUnique(); asyncLoader->Initialize(2); + RegisterBuiltinLoader(*this, g_gaussianSplatLoader); RegisterBuiltinLoader(*this, g_materialLoader); + RegisterBuiltinLoader(*this, g_modelLoader); RegisterBuiltinLoader(*this, g_meshLoader); RegisterBuiltinLoader(*this, g_shaderLoader); RegisterBuiltinLoader(*this, g_textureLoader); @@ -522,8 +528,6 @@ LoadResult ResourceManager::LoadResource(const Containers::String& path, ImportSettings* settings) { EnsureInitialized(); - const ResourceGUID guid = ResourceGUID::Generate(path); - if (ShouldTraceResourcePath(path)) { Debug::Logger::Get().Info( Debug::LogCategory::FileSystem, @@ -535,15 +539,6 @@ LoadResult ResourceManager::LoadResource(const Containers::String& path, m_resourceRoot); } - if (IResource* cached = FindInCache(guid)) { - if (ShouldTraceResourcePath(path)) { - Debug::Logger::Get().Info( - Debug::LogCategory::FileSystem, - Containers::String("[ResourceManager] LoadResource cache-hit path=") + path); - } - return LoadResult(cached); - } - IResourceLoader* loader = FindLoader(type); if (loader == nullptr) { Debug::Logger::Get().Warning(Debug::LogCategory::FileSystem, @@ -552,6 +547,50 @@ LoadResult ResourceManager::LoadResource(const Containers::String& path, return LoadResult(false, "Loader not found"); } + Containers::String loadPath = path; + AssetImportService::ImportedAsset resolvedAsset; + ResourceType importableType = ResourceType::Unknown; + const bool shouldUseProjectArtifact = + !m_resourceRoot.Empty() && + m_assetImportService.TryGetImportableResourceType(path, importableType) && + importableType == type; + + if (shouldUseProjectArtifact && + m_assetImportService.EnsureArtifact(path, type, resolvedAsset) && + resolvedAsset.artifactReady) { + m_projectAssetIndex.RememberResolvedPath(resolvedAsset.assetGuid, resolvedAsset.relativePath); + loadPath = resolvedAsset.runtimeLoadPath; + if (ShouldTraceResourcePath(path)) { + Debug::Logger::Get().Info( + Debug::LogCategory::FileSystem, + Containers::String("[ResourceManager] LoadResource artifact path=") + + path + + " artifact=" + + loadPath); + } + } else if (ShouldTraceResourcePath(path)) { + Debug::Logger::Get().Info( + Debug::LogCategory::FileSystem, + Containers::String("[ResourceManager] LoadResource direct path=") + + path + + " loadPath=" + + loadPath); + } + + const ResourceGUID guid = ResourceGUID::Generate(loadPath); + + if (IResource* cached = FindInCache(guid)) { + if (ShouldTraceResourcePath(path)) { + Debug::Logger::Get().Info( + Debug::LogCategory::FileSystem, + Containers::String("[ResourceManager] LoadResource cache-hit path=") + + path + + " loadPath=" + + loadPath); + } + return LoadResult(cached); + } + const InFlightLoadKey inFlightKey{ guid, type }; std::shared_ptr inFlightState; bool shouldExecuteLoad = false; @@ -610,36 +649,6 @@ LoadResult ResourceManager::LoadResource(const Containers::String& path, : Containers::String("In-flight load completed without cached resource")); } - Containers::String loadPath = path; - AssetImportService::ImportedAsset resolvedAsset; - ResourceType importableType = ResourceType::Unknown; - const bool shouldUseProjectArtifact = - !m_resourceRoot.Empty() && - m_assetImportService.TryGetImportableResourceType(path, importableType) && - importableType == type; - - if (shouldUseProjectArtifact && - m_assetImportService.EnsureArtifact(path, type, resolvedAsset) && - resolvedAsset.artifactReady) { - m_projectAssetIndex.RememberResolvedPath(resolvedAsset.assetGuid, resolvedAsset.relativePath); - loadPath = resolvedAsset.runtimeLoadPath; - if (ShouldTraceResourcePath(path)) { - Debug::Logger::Get().Info( - Debug::LogCategory::FileSystem, - Containers::String("[ResourceManager] LoadResource artifact path=") + - path + - " artifact=" + - loadPath); - } - } else if (ShouldTraceResourcePath(path)) { - Debug::Logger::Get().Info( - Debug::LogCategory::FileSystem, - Containers::String("[ResourceManager] LoadResource direct path=") + - path + - " loadPath=" + - loadPath); - } - LoadResult result; try { result = loader->Load(loadPath, settings); diff --git a/engine/src/Resources/GaussianSplat/GaussianSplat.cpp b/engine/src/Resources/GaussianSplat/GaussianSplat.cpp new file mode 100644 index 00000000..1e5b8e3f --- /dev/null +++ b/engine/src/Resources/GaussianSplat/GaussianSplat.cpp @@ -0,0 +1,109 @@ +#include + +namespace XCEngine { +namespace Resources { + +GaussianSplat::GaussianSplat() = default; + +GaussianSplat::~GaussianSplat() = default; + +void GaussianSplat::Release() { + delete this; +} + +bool GaussianSplat::CreateOwned(const GaussianSplatMetadata& metadata, + Containers::Array&& sections, + Containers::Array&& payload) { + if (!ValidateSections(sections, payload.Size())) { + return false; + } + + m_metadata = metadata; + m_sections = std::move(sections); + m_payload = std::move(payload); + m_isValid = true; + UpdateMemorySize(); + return true; +} + +void GaussianSplat::Clear() { + m_metadata = {}; + m_sections.Clear(); + m_payload.Clear(); + m_isValid = false; + UpdateMemorySize(); +} + +const GaussianSplatSection* GaussianSplat::FindSection(GaussianSplatSectionType type) const { + for (const GaussianSplatSection& section : m_sections) { + if (section.type == type) { + return §ion; + } + } + + return nullptr; +} + +const void* GaussianSplat::GetSectionData(GaussianSplatSectionType type) const { + const GaussianSplatSection* section = FindSection(type); + if (section == nullptr || section->dataSize == 0) { + return nullptr; + } + + return m_payload.Data() + static_cast(section->dataOffset); +} + +const GaussianSplatPositionRecord* GaussianSplat::GetPositionRecords() const { + return static_cast(GetSectionData(GaussianSplatSectionType::Positions)); +} + +const GaussianSplatOtherRecord* GaussianSplat::GetOtherRecords() const { + return static_cast(GetSectionData(GaussianSplatSectionType::Other)); +} + +const GaussianSplatColorRecord* GaussianSplat::GetColorRecords() const { + return static_cast(GetSectionData(GaussianSplatSectionType::Color)); +} + +const GaussianSplatSHRecord* GaussianSplat::GetSHRecords() const { + return static_cast(GetSectionData(GaussianSplatSectionType::SH)); +} + +bool GaussianSplat::ValidateSections(const Containers::Array& sections, + size_t payloadSize) const { + for (size_t index = 0; index < sections.Size(); ++index) { + const GaussianSplatSection& section = sections[index]; + if (section.type == GaussianSplatSectionType::Unknown) { + return false; + } + + if (section.dataOffset > payloadSize || section.dataSize > payloadSize) { + return false; + } + + const Core::uint64 sectionEnd = section.dataOffset + section.dataSize; + if (sectionEnd < section.dataOffset || sectionEnd > payloadSize) { + return false; + } + + for (size_t otherIndex = index + 1; otherIndex < sections.Size(); ++otherIndex) { + if (sections[otherIndex].type == section.type) { + return false; + } + } + } + + return true; +} + +void GaussianSplat::UpdateMemorySize() { + m_memorySize = + sizeof(GaussianSplat) + + m_name.Length() + + m_path.Length() + + (m_sections.Size() * sizeof(GaussianSplatSection)) + + m_payload.Size(); +} + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/GaussianSplat/GaussianSplatArtifactIO.cpp b/engine/src/Resources/GaussianSplat/GaussianSplatArtifactIO.cpp new file mode 100644 index 00000000..ac1e22a3 --- /dev/null +++ b/engine/src/Resources/GaussianSplat/GaussianSplatArtifactIO.cpp @@ -0,0 +1,202 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +namespace { + +Containers::String GetResourceNameFromPath(const Containers::String& path) { + const std::filesystem::path filePath(path.CStr()); + const std::string fileName = filePath.filename().string(); + if (!fileName.empty()) { + return Containers::String(fileName.c_str()); + } + return path; +} + +std::filesystem::path ResolveArtifactPath(const Containers::String& path) { + std::filesystem::path resolvedPath(path.CStr()); + if (!resolvedPath.is_absolute() && !std::filesystem::exists(resolvedPath)) { + const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot(); + if (!resourceRoot.Empty()) { + resolvedPath = std::filesystem::path(resourceRoot.CStr()) / resolvedPath; + } + } + + return resolvedPath.lexically_normal(); +} + +LoadResult CreateOwnedGaussianSplatResource(const Containers::String& path, + const GaussianSplatMetadata& metadata, + Containers::Array&& sections, + Containers::Array&& payload) { + auto* gaussianSplat = new GaussianSplat(); + + IResource::ConstructParams params; + params.name = GetResourceNameFromPath(path); + params.path = path; + params.guid = ResourceGUID::Generate(path); + params.memorySize = payload.Size(); + gaussianSplat->Initialize(params); + + if (!gaussianSplat->CreateOwned(metadata, std::move(sections), std::move(payload))) { + delete gaussianSplat; + return LoadResult(Containers::String("Failed to create GaussianSplat resource: ") + path); + } + + return LoadResult(gaussianSplat); +} + +} // namespace + +bool WriteGaussianSplatArtifactFile(const Containers::String& artifactPath, + const GaussianSplat& gaussianSplat, + Containers::String* outErrorMessage) { + const std::filesystem::path resolvedPath = ResolveArtifactPath(artifactPath); + std::error_code ec; + const std::filesystem::path parentPath = resolvedPath.parent_path(); + if (!parentPath.empty()) { + std::filesystem::create_directories(parentPath, ec); + if (ec) { + if (outErrorMessage != nullptr) { + *outErrorMessage = + Containers::String("Failed to create GaussianSplat artifact directory: ") + + Containers::String(parentPath.generic_string().c_str()); + } + return false; + } + } + + std::ofstream output(resolvedPath, std::ios::binary | std::ios::trunc); + if (!output.is_open()) { + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to open GaussianSplat artifact for write: ") + artifactPath; + } + return false; + } + + GaussianSplatArtifactFileHeader fileHeader; + output.write(reinterpret_cast(&fileHeader), sizeof(fileHeader)); + + const GaussianSplatMetadata& metadata = gaussianSplat.GetMetadata(); + GaussianSplatArtifactHeader header; + header.contentVersion = metadata.contentVersion; + header.splatCount = metadata.splatCount; + header.chunkCount = metadata.chunkCount; + header.cameraCount = metadata.cameraCount; + header.boundsMin = metadata.bounds.GetMin(); + header.boundsMax = metadata.bounds.GetMax(); + header.positionFormat = static_cast(metadata.positionFormat); + header.otherFormat = static_cast(metadata.otherFormat); + header.colorFormat = static_cast(metadata.colorFormat); + header.shFormat = static_cast(metadata.shFormat); + header.chunkFormat = static_cast(metadata.chunkFormat); + header.cameraFormat = static_cast(metadata.cameraFormat); + header.sectionCount = static_cast(gaussianSplat.GetSections().Size()); + header.payloadSize = static_cast(gaussianSplat.GetPayloadSize()); + output.write(reinterpret_cast(&header), sizeof(header)); + + for (const GaussianSplatSection& section : gaussianSplat.GetSections()) { + GaussianSplatArtifactSectionRecord sectionRecord; + sectionRecord.sectionType = static_cast(section.type); + sectionRecord.format = static_cast(section.format); + sectionRecord.payloadOffset = section.dataOffset; + sectionRecord.dataSize = section.dataSize; + sectionRecord.elementCount = section.elementCount; + sectionRecord.elementStride = section.elementStride; + output.write(reinterpret_cast(§ionRecord), sizeof(sectionRecord)); + } + + if (gaussianSplat.GetPayloadSize() > 0) { + output.write(reinterpret_cast(gaussianSplat.GetPayloadData()), gaussianSplat.GetPayloadSize()); + } + + if (!output && outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to write GaussianSplat artifact: ") + artifactPath; + } + + return static_cast(output); +} + +LoadResult LoadGaussianSplatArtifact(const Containers::String& path) { + const std::filesystem::path resolvedPath = ResolveArtifactPath(path); + + std::ifstream input(resolvedPath, std::ios::binary); + if (!input.is_open()) { + return LoadResult(Containers::String("Failed to read GaussianSplat artifact: ") + path); + } + + GaussianSplatArtifactFileHeader fileHeader; + input.read(reinterpret_cast(&fileHeader), sizeof(fileHeader)); + if (!input) { + return LoadResult(Containers::String("Failed to parse GaussianSplat artifact file header: ") + path); + } + + const bool validFileHeader = + std::memcmp(fileHeader.magic, "XCGSP01", 7) == 0 && + fileHeader.schemaVersion == kGaussianSplatArtifactSchemaVersion; + if (!validFileHeader) { + return LoadResult(Containers::String("Invalid GaussianSplat artifact file header: ") + path); + } + + GaussianSplatArtifactHeader header; + input.read(reinterpret_cast(&header), sizeof(header)); + if (!input) { + return LoadResult(Containers::String("Failed to parse GaussianSplat artifact header: ") + path); + } + + Containers::Array sections; + sections.Reserve(header.sectionCount); + for (Core::uint32 index = 0; index < header.sectionCount; ++index) { + GaussianSplatArtifactSectionRecord sectionRecord; + input.read(reinterpret_cast(§ionRecord), sizeof(sectionRecord)); + if (!input) { + return LoadResult(Containers::String("Failed to read GaussianSplat artifact section table: ") + path); + } + + GaussianSplatSection section; + section.type = static_cast(sectionRecord.sectionType); + section.format = static_cast(sectionRecord.format); + section.dataOffset = sectionRecord.payloadOffset; + section.dataSize = sectionRecord.dataSize; + section.elementCount = sectionRecord.elementCount; + section.elementStride = sectionRecord.elementStride; + sections.PushBack(section); + } + + Containers::Array payload; + payload.Resize(static_cast(header.payloadSize)); + if (header.payloadSize > 0) { + input.read(reinterpret_cast(payload.Data()), static_cast(header.payloadSize)); + if (!input) { + return LoadResult(Containers::String("Failed to read GaussianSplat artifact payload: ") + path); + } + } + + GaussianSplatMetadata metadata; + metadata.contentVersion = header.contentVersion; + metadata.splatCount = header.splatCount; + metadata.chunkCount = header.chunkCount; + metadata.cameraCount = header.cameraCount; + metadata.bounds.SetMinMax(header.boundsMin, header.boundsMax); + metadata.positionFormat = static_cast(header.positionFormat); + metadata.otherFormat = static_cast(header.otherFormat); + metadata.colorFormat = static_cast(header.colorFormat); + metadata.shFormat = static_cast(header.shFormat); + metadata.chunkFormat = static_cast(header.chunkFormat); + metadata.cameraFormat = static_cast(header.cameraFormat); + + return CreateOwnedGaussianSplatResource(path, metadata, std::move(sections), std::move(payload)); +} + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/GaussianSplat/GaussianSplatLoader.cpp b/engine/src/Resources/GaussianSplat/GaussianSplatLoader.cpp new file mode 100644 index 00000000..e137ec49 --- /dev/null +++ b/engine/src/Resources/GaussianSplat/GaussianSplatLoader.cpp @@ -0,0 +1,40 @@ +#include + +#include +#include + +namespace XCEngine { +namespace Resources { + +GaussianSplatLoader::GaussianSplatLoader() = default; + +GaussianSplatLoader::~GaussianSplatLoader() = default; + +Containers::Array GaussianSplatLoader::GetSupportedExtensions() const { + Containers::Array extensions; + extensions.PushBack("xcgsplat"); + return extensions; +} + +bool GaussianSplatLoader::CanLoad(const Containers::String& path) const { + return GetExtension(path).ToLower() == "xcgsplat"; +} + +LoadResult GaussianSplatLoader::Load(const Containers::String& path, const ImportSettings* settings) { + (void)settings; + + if (!CanLoad(path)) { + return LoadResult(Containers::String("Unsupported GaussianSplat format: ") + GetExtension(path).ToLower()); + } + + return LoadGaussianSplatArtifact(path); +} + +ImportSettings* GaussianSplatLoader::GetDefaultSettings() const { + return nullptr; +} + +REGISTER_RESOURCE_LOADER(GaussianSplatLoader); + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.cpp b/engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.cpp new file mode 100644 index 00000000..87fd6b44 --- /dev/null +++ b/engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.cpp @@ -0,0 +1,634 @@ +#include "Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Resources { +namespace Internal { + +namespace { + +constexpr float kSHC0 = 0.2820948f; + +enum class PlyScalarType { + Invalid = 0, + Int8, + UInt8, + Int16, + UInt16, + Int32, + UInt32, + Float32, + Float64 +}; + +enum class GaussianSplatSemantic { + Ignore = 0, + PositionX, + PositionY, + PositionZ, + DC0R, + DC0G, + DC0B, + Opacity, + Scale0, + Scale1, + Scale2, + Rot0, + Rot1, + Rot2, + Rot3, + SHRest0 +}; + +struct PlyPropertyDesc { + std::string name; + PlyScalarType type = PlyScalarType::Invalid; + GaussianSplatSemantic semantic = GaussianSplatSemantic::Ignore; + Core::uint32 shRestIndex = 0; +}; + +struct ParsedPlyHeader { + Core::uint32 vertexCount = 0; + Core::uint32 vertexStride = 0; + std::vector vertexProperties; +}; + +Containers::String ToContainersString(const std::string& value) { + return Containers::String(value.c_str()); +} + +std::string TrimAscii(const std::string& value) { + const auto begin = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }); + const auto end = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }).base(); + + if (begin >= end) { + return std::string(); + } + + return std::string(begin, end); +} + +PlyScalarType ParsePlyScalarType(const std::string& typeName) { + if (typeName == "char" || typeName == "int8") { + return PlyScalarType::Int8; + } + if (typeName == "uchar" || typeName == "uint8") { + return PlyScalarType::UInt8; + } + if (typeName == "short" || typeName == "int16") { + return PlyScalarType::Int16; + } + if (typeName == "ushort" || typeName == "uint16") { + return PlyScalarType::UInt16; + } + if (typeName == "int" || typeName == "int32") { + return PlyScalarType::Int32; + } + if (typeName == "uint" || typeName == "uint32") { + return PlyScalarType::UInt32; + } + if (typeName == "float" || typeName == "float32") { + return PlyScalarType::Float32; + } + if (typeName == "double" || typeName == "float64") { + return PlyScalarType::Float64; + } + return PlyScalarType::Invalid; +} + +Core::uint32 GetPlyScalarTypeSize(PlyScalarType type) { + switch (type) { + case PlyScalarType::Int8: + case PlyScalarType::UInt8: + return 1u; + case PlyScalarType::Int16: + case PlyScalarType::UInt16: + return 2u; + case PlyScalarType::Int32: + case PlyScalarType::UInt32: + case PlyScalarType::Float32: + return 4u; + case PlyScalarType::Float64: + return 8u; + default: + return 0u; + } +} + +bool TryParseSHRestIndex(const std::string& propertyName, Core::uint32& outIndex) { + static constexpr const char* kPrefix = "f_rest_"; + if (propertyName.rfind(kPrefix, 0) != 0) { + return false; + } + + const std::string suffix = propertyName.substr(std::char_traits::length(kPrefix)); + if (suffix.empty()) { + return false; + } + + for (char ch : suffix) { + if (!std::isdigit(static_cast(ch))) { + return false; + } + } + + outIndex = static_cast(std::stoul(suffix)); + return true; +} + +GaussianSplatSemantic MapPropertySemantic(const std::string& propertyName, Core::uint32& outSHRestIndex) { + outSHRestIndex = 0u; + + if (propertyName == "x") return GaussianSplatSemantic::PositionX; + if (propertyName == "y") return GaussianSplatSemantic::PositionY; + if (propertyName == "z") return GaussianSplatSemantic::PositionZ; + if (propertyName == "f_dc_0") return GaussianSplatSemantic::DC0R; + if (propertyName == "f_dc_1") return GaussianSplatSemantic::DC0G; + if (propertyName == "f_dc_2") return GaussianSplatSemantic::DC0B; + if (propertyName == "opacity") return GaussianSplatSemantic::Opacity; + if (propertyName == "scale_0") return GaussianSplatSemantic::Scale0; + if (propertyName == "scale_1") return GaussianSplatSemantic::Scale1; + if (propertyName == "scale_2") return GaussianSplatSemantic::Scale2; + if (propertyName == "rot_0") return GaussianSplatSemantic::Rot0; + if (propertyName == "rot_1") return GaussianSplatSemantic::Rot1; + if (propertyName == "rot_2") return GaussianSplatSemantic::Rot2; + if (propertyName == "rot_3") return GaussianSplatSemantic::Rot3; + if (TryParseSHRestIndex(propertyName, outSHRestIndex)) return GaussianSplatSemantic::SHRest0; + return GaussianSplatSemantic::Ignore; +} + +bool ReadScalarAsFloat(std::ifstream& input, PlyScalarType type, float& outValue) { + switch (type) { + case PlyScalarType::Int8: { + std::int8_t value = 0; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = static_cast(value); + return static_cast(input); + } + case PlyScalarType::UInt8: { + std::uint8_t value = 0; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = static_cast(value); + return static_cast(input); + } + case PlyScalarType::Int16: { + std::int16_t value = 0; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = static_cast(value); + return static_cast(input); + } + case PlyScalarType::UInt16: { + std::uint16_t value = 0; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = static_cast(value); + return static_cast(input); + } + case PlyScalarType::Int32: { + std::int32_t value = 0; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = static_cast(value); + return static_cast(input); + } + case PlyScalarType::UInt32: { + std::uint32_t value = 0; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = static_cast(value); + return static_cast(input); + } + case PlyScalarType::Float32: { + float value = 0.0f; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = value; + return static_cast(input); + } + case PlyScalarType::Float64: { + double value = 0.0; + input.read(reinterpret_cast(&value), sizeof(value)); + outValue = static_cast(value); + return static_cast(input); + } + default: + return false; + } +} + +bool ParsePlyHeader(std::ifstream& input, + ParsedPlyHeader& outHeader, + Containers::String& outErrorMessage) { + outHeader = {}; + outErrorMessage.Clear(); + + std::string line; + if (!std::getline(input, line)) { + outErrorMessage = "PLY header is empty"; + return false; + } + + line = TrimAscii(line); + if (line != "ply") { + outErrorMessage = "PLY header missing magic 'ply'"; + return false; + } + + if (!std::getline(input, line)) { + outErrorMessage = "PLY header missing format line"; + return false; + } + + line = TrimAscii(line); + if (line != "format binary_little_endian 1.0") { + outErrorMessage = Containers::String("Unsupported PLY format: ") + ToContainersString(line); + return false; + } + + bool seenVertexElement = false; + bool sawNonVertexElementBeforeVertex = false; + bool parsingVertexProperties = false; + std::unordered_set propertyNames; + + while (std::getline(input, line)) { + line = TrimAscii(line); + if (line.empty() || line == "comment" || line.rfind("comment ", 0) == 0 || line.rfind("obj_info ", 0) == 0) { + continue; + } + + if (line == "end_header") { + break; + } + + std::istringstream lineStream(line); + std::string token; + lineStream >> token; + if (token == "element") { + std::string elementName; + std::uint64_t elementCount = 0; + lineStream >> elementName >> elementCount; + if (!lineStream) { + outErrorMessage = Containers::String("Malformed PLY element line: ") + ToContainersString(line); + return false; + } + + parsingVertexProperties = (elementName == "vertex"); + if (parsingVertexProperties) { + if (sawNonVertexElementBeforeVertex) { + outErrorMessage = "Unsupported PLY layout: vertex element must be the first data element"; + return false; + } + outHeader.vertexCount = static_cast(elementCount); + seenVertexElement = true; + } else if (!seenVertexElement) { + sawNonVertexElementBeforeVertex = true; + } + + continue; + } + + if (token == "property") { + std::string typeName; + lineStream >> typeName; + if (!lineStream) { + outErrorMessage = Containers::String("Malformed PLY property line: ") + ToContainersString(line); + return false; + } + + if (typeName == "list") { + outErrorMessage = Containers::String("Unsupported PLY list property: ") + ToContainersString(line); + return false; + } + + std::string propertyName; + lineStream >> propertyName; + if (!lineStream) { + outErrorMessage = Containers::String("Malformed PLY property line: ") + ToContainersString(line); + return false; + } + + if (!parsingVertexProperties) { + continue; + } + + if (!propertyNames.insert(propertyName).second) { + outErrorMessage = Containers::String("Duplicate PLY vertex property: ") + ToContainersString(propertyName); + return false; + } + + PlyPropertyDesc desc; + desc.name = propertyName; + desc.type = ParsePlyScalarType(typeName); + if (desc.type == PlyScalarType::Invalid) { + outErrorMessage = Containers::String("Unsupported PLY property type: ") + ToContainersString(typeName); + return false; + } + + desc.semantic = MapPropertySemantic(propertyName, desc.shRestIndex); + outHeader.vertexStride += GetPlyScalarTypeSize(desc.type); + outHeader.vertexProperties.push_back(desc); + continue; + } + } + + if (!input) { + outErrorMessage = "Failed while parsing PLY header"; + return false; + } + + if (!seenVertexElement || outHeader.vertexCount == 0u) { + outErrorMessage = "PLY file does not declare a valid vertex element"; + return false; + } + + static constexpr const char* kRequiredProperties[] = { + "x", "y", "z", + "f_dc_0", "f_dc_1", "f_dc_2", + "opacity", + "scale_0", "scale_1", "scale_2", + "rot_0", "rot_1", "rot_2", "rot_3" + }; + for (const char* propertyName : kRequiredProperties) { + if (propertyNames.find(propertyName) == propertyNames.end()) { + outErrorMessage = Containers::String("PLY file is missing required 3DGS property: ") + propertyName; + return false; + } + } + + for (Core::uint32 index = 0; index < kGaussianSplatSHCoefficientCount; ++index) { + const std::string propertyName = "f_rest_" + std::to_string(index); + if (propertyNames.find(propertyName) == propertyNames.end()) { + outErrorMessage = Containers::String("PLY file is missing required 3DGS SH property: ") + + ToContainersString(propertyName); + return false; + } + } + + return true; +} + +Containers::String GetResourceNameFromPath(const Containers::String& path) { + const std::filesystem::path filePath(path.CStr()); + const std::string fileName = filePath.filename().string(); + if (!fileName.empty()) { + return Containers::String(fileName.c_str()); + } + return path; +} + +std::filesystem::path ResolveSourcePath(const Containers::String& path) { + std::filesystem::path resolvedPath(path.CStr()); + if (!resolvedPath.is_absolute() && !std::filesystem::exists(resolvedPath)) { + const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot(); + if (!resourceRoot.Empty()) { + resolvedPath = std::filesystem::path(resourceRoot.CStr()) / resolvedPath; + } + } + + return resolvedPath.lexically_normal(); +} + +float Sigmoid(float value) { + return 1.0f / (1.0f + std::exp(-value)); +} + +Math::Vector3 SH0ToColor(const std::array& dc0) { + return Math::Vector3( + dc0[0] * kSHC0 + 0.5f, + dc0[1] * kSHC0 + 0.5f, + dc0[2] * kSHC0 + 0.5f); +} + +Math::Vector3 LinearScale(const std::array& logScale) { + return Math::Vector3( + std::abs(std::exp(logScale[0])), + std::abs(std::exp(logScale[1])), + std::abs(std::exp(logScale[2]))); +} + +Math::Quaternion NormalizeRotationWXYZ(const std::array& rotationWXYZ) { + const float magnitudeSq = + rotationWXYZ[0] * rotationWXYZ[0] + + rotationWXYZ[1] * rotationWXYZ[1] + + rotationWXYZ[2] * rotationWXYZ[2] + + rotationWXYZ[3] * rotationWXYZ[3]; + if (magnitudeSq <= std::numeric_limits::epsilon()) { + return Math::Quaternion::Identity(); + } + + const float inverseMagnitude = 1.0f / std::sqrt(magnitudeSq); + return Math::Quaternion( + rotationWXYZ[1] * inverseMagnitude, + rotationWXYZ[2] * inverseMagnitude, + rotationWXYZ[3] * inverseMagnitude, + rotationWXYZ[0] * inverseMagnitude); +} + +bool IsFinite(const Math::Vector3& value) { + return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z); +} + +bool IsFinite(const Math::Vector4& value) { + return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z) && std::isfinite(value.w); +} + +bool IsFinite(const Math::Quaternion& value) { + return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z) && std::isfinite(value.w); +} + +void BuildSections(Core::uint32 vertexCount, + Containers::Array& outSections, + size_t& outPayloadSize) { + outSections.Clear(); + outSections.Reserve(4u); + + size_t payloadOffset = 0u; + auto appendSection = [&](GaussianSplatSectionType type, + GaussianSplatSectionFormat format, + size_t elementStride) { + GaussianSplatSection section; + section.type = type; + section.format = format; + section.dataOffset = payloadOffset; + section.dataSize = elementStride * static_cast(vertexCount); + section.elementCount = vertexCount; + section.elementStride = static_cast(elementStride); + outSections.PushBack(section); + payloadOffset += section.dataSize; + }; + + appendSection( + GaussianSplatSectionType::Positions, + GaussianSplatSectionFormat::VectorFloat32, + sizeof(GaussianSplatPositionRecord)); + appendSection( + GaussianSplatSectionType::Other, + GaussianSplatSectionFormat::OtherFloat32, + sizeof(GaussianSplatOtherRecord)); + appendSection( + GaussianSplatSectionType::Color, + GaussianSplatSectionFormat::ColorRGBA32F, + sizeof(GaussianSplatColorRecord)); + appendSection( + GaussianSplatSectionType::SH, + GaussianSplatSectionFormat::SHFloat32, + sizeof(GaussianSplatSHRecord)); + + outPayloadSize = payloadOffset; +} + +} // namespace + +LoadResult ImportGaussianSplatPlyFile(const Containers::String& path) { + const std::filesystem::path resolvedPath = ResolveSourcePath(path); + std::ifstream input(resolvedPath, std::ios::binary); + if (!input.is_open()) { + return LoadResult(Containers::String("Failed to read GaussianSplat source file: ") + path); + } + + ParsedPlyHeader header; + Containers::String headerError; + if (!ParsePlyHeader(input, header, headerError)) { + return LoadResult(Containers::String("Failed to parse GaussianSplat PLY header: ") + path + " - " + headerError); + } + + Containers::Array sections; + size_t payloadSize = 0u; + BuildSections(header.vertexCount, sections, payloadSize); + + Containers::Array payload; + payload.Resize(payloadSize); + + auto* positions = reinterpret_cast( + payload.Data() + static_cast(sections[0].dataOffset)); + auto* other = reinterpret_cast( + payload.Data() + static_cast(sections[1].dataOffset)); + auto* colors = reinterpret_cast( + payload.Data() + static_cast(sections[2].dataOffset)); + auto* shRecords = reinterpret_cast( + payload.Data() + static_cast(sections[3].dataOffset)); + + Math::Bounds bounds; + bool hasBounds = false; + + for (Core::uint32 vertexIndex = 0; vertexIndex < header.vertexCount; ++vertexIndex) { + std::array position = { 0.0f, 0.0f, 0.0f }; + std::array dc0 = { 0.0f, 0.0f, 0.0f }; + std::array scale = { 0.0f, 0.0f, 0.0f }; + std::array rotation = { 1.0f, 0.0f, 0.0f, 0.0f }; + std::array sh = {}; + float opacity = 0.0f; + + for (const PlyPropertyDesc& property : header.vertexProperties) { + float value = 0.0f; + if (!ReadScalarAsFloat(input, property.type, value)) { + return LoadResult( + Containers::String("Failed to read GaussianSplat vertex data: ") + + path + + " at vertex " + + Containers::String(std::to_string(vertexIndex).c_str())); + } + + switch (property.semantic) { + case GaussianSplatSemantic::PositionX: position[0] = value; break; + case GaussianSplatSemantic::PositionY: position[1] = value; break; + case GaussianSplatSemantic::PositionZ: position[2] = value; break; + case GaussianSplatSemantic::DC0R: dc0[0] = value; break; + case GaussianSplatSemantic::DC0G: dc0[1] = value; break; + case GaussianSplatSemantic::DC0B: dc0[2] = value; break; + case GaussianSplatSemantic::Opacity: opacity = value; break; + case GaussianSplatSemantic::Scale0: scale[0] = value; break; + case GaussianSplatSemantic::Scale1: scale[1] = value; break; + case GaussianSplatSemantic::Scale2: scale[2] = value; break; + case GaussianSplatSemantic::Rot0: rotation[0] = value; break; + case GaussianSplatSemantic::Rot1: rotation[1] = value; break; + case GaussianSplatSemantic::Rot2: rotation[2] = value; break; + case GaussianSplatSemantic::Rot3: rotation[3] = value; break; + case GaussianSplatSemantic::SHRest0: + if (property.shRestIndex < sh.size()) { + sh[property.shRestIndex] = value; + } + break; + case GaussianSplatSemantic::Ignore: + default: + break; + } + } + + positions[vertexIndex].position = Math::Vector3(position[0], position[1], position[2]); + other[vertexIndex].rotation = NormalizeRotationWXYZ(rotation); + other[vertexIndex].scale = LinearScale(scale); + other[vertexIndex].reserved = 0.0f; + + const Math::Vector3 color = SH0ToColor(dc0); + colors[vertexIndex].colorOpacity = Math::Vector4(color, Sigmoid(opacity)); + + for (size_t shIndex = 0; shIndex < sh.size(); ++shIndex) { + shRecords[vertexIndex].coefficients[shIndex] = sh[shIndex]; + } + + if (!IsFinite(positions[vertexIndex].position) || + !IsFinite(other[vertexIndex].rotation) || + !IsFinite(other[vertexIndex].scale) || + !IsFinite(colors[vertexIndex].colorOpacity)) { + return LoadResult( + Containers::String("GaussianSplat source contains non-finite values: ") + + path + + " at vertex " + + Containers::String(std::to_string(vertexIndex).c_str())); + } + + if (!hasBounds) { + bounds.SetMinMax(positions[vertexIndex].position, positions[vertexIndex].position); + hasBounds = true; + } else { + bounds.Encapsulate(positions[vertexIndex].position); + } + } + + GaussianSplatMetadata metadata; + metadata.contentVersion = 1u; + metadata.splatCount = header.vertexCount; + metadata.chunkCount = 0u; + metadata.cameraCount = 0u; + metadata.bounds = bounds; + metadata.positionFormat = GaussianSplatSectionFormat::VectorFloat32; + metadata.otherFormat = GaussianSplatSectionFormat::OtherFloat32; + metadata.colorFormat = GaussianSplatSectionFormat::ColorRGBA32F; + metadata.shFormat = GaussianSplatSectionFormat::SHFloat32; + metadata.chunkFormat = GaussianSplatSectionFormat::Unknown; + metadata.cameraFormat = GaussianSplatSectionFormat::Unknown; + + auto* gaussianSplat = new GaussianSplat(); + IResource::ConstructParams params; + params.name = GetResourceNameFromPath(path); + params.path = path; + params.guid = ResourceGUID::Generate(path); + params.memorySize = payload.Size(); + gaussianSplat->Initialize(params); + + if (!gaussianSplat->CreateOwned(metadata, std::move(sections), std::move(payload))) { + delete gaussianSplat; + return LoadResult(Containers::String("Failed to create GaussianSplat resource from PLY: ") + path); + } + + return LoadResult(gaussianSplat); +} + +} // namespace Internal +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.h b/engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.h new file mode 100644 index 00000000..9f3eb552 --- /dev/null +++ b/engine/src/Resources/GaussianSplat/Internal/GaussianSplatPlyImporter.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +namespace XCEngine { +namespace Resources { +namespace Internal { + +LoadResult ImportGaussianSplatPlyFile(const Containers::String& path); + +} // namespace Internal +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/Model/AssimpModelImporter.cpp b/engine/src/Resources/Model/AssimpModelImporter.cpp new file mode 100644 index 00000000..18b8150e --- /dev/null +++ b/engine/src/Resources/Model/AssimpModelImporter.cpp @@ -0,0 +1,1054 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +namespace fs = std::filesystem; + +namespace { + +struct ImportedMeshData { + std::vector vertices; + std::vector indices; + VertexAttribute attributes = VertexAttribute::Position; + Math::Bounds bounds; +}; + +struct TextureImportContext { + const aiScene& scene; + const Containers::String& sourcePath; + fs::path sourceDirectory; + TextureLoader textureLoader; + std::unordered_map textureCache; + std::vector ownedTextures; + + struct ObjMaterialTextureFallbacks { + fs::path baseColorPath; + fs::path normalPath; + fs::path specularPath; + }; + + bool objMaterialFallbacksInitialized = false; + std::unordered_map objMaterialFallbacks; +}; + +struct ImportContext { + const aiScene& scene; + const Containers::String& sourcePath; + const MeshImportSettings& settings; + ImportedModelData& outData; + TextureImportContext textureContext; + std::unordered_set usedLocalIDs; + std::vector materialLocalIDsBySourceIndex; + Containers::String errorMessage; +}; + +Containers::String ToContainersString(const std::string& value) { + return Containers::String(value.c_str()); +} + +Containers::String NormalizePathString(const fs::path& path) { + return Containers::String(path.lexically_normal().generic_string().c_str()); +} + +std::string ToLowerCopy(const std::string& value) { + std::string lowered = value; + std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return lowered; +} + +std::string TrimCopy(const std::string& value) { + const auto begin = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }); + if (begin == value.end()) { + return std::string(); + } + + const auto end = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }).base(); + return std::string(begin, end); +} + +std::vector SplitWhitespaceTokens(const std::string& text) { + std::vector tokens; + std::string current; + for (const char ch : text) { + if (std::isspace(static_cast(ch)) != 0) { + if (!current.empty()) { + tokens.push_back(current); + current.clear(); + } + } else { + current.push_back(ch); + } + } + + if (!current.empty()) { + tokens.push_back(current); + } + return tokens; +} + +Containers::String BuildSubResourcePath(const Containers::String& sourcePath, + const char* category, + Core::uint32 index, + const Containers::String& extension = Containers::String()) { + std::string path = sourcePath.CStr(); + path += "#"; + path += category; + path += "["; + path += std::to_string(index); + path += "]"; + if (!extension.Empty()) { + path += extension.CStr(); + } + return Containers::String(path.c_str()); +} + +Containers::String GetResourceNameFromPath(const Containers::String& path) { + const fs::path filePath(path.CStr()); + const std::string fileName = filePath.filename().string(); + if (!fileName.empty()) { + return Containers::String(fileName.c_str()); + } + return path; +} + +Math::Bounds ComputeBounds(const std::vector& vertices) { + if (vertices.empty()) { + return Math::Bounds(); + } + + Math::Vector3 min = vertices.front().position; + Math::Vector3 max = vertices.front().position; + for (const StaticMeshVertex& vertex : vertices) { + min.x = std::min(min.x, vertex.position.x); + min.y = std::min(min.y, vertex.position.y); + min.z = std::min(min.z, vertex.position.z); + max.x = std::max(max.x, vertex.position.x); + max.y = std::max(max.y, vertex.position.y); + max.z = std::max(max.z, vertex.position.z); + } + + Math::Bounds bounds; + bounds.SetMinMax(min, max); + return bounds; +} + +Math::Matrix4 ConvertAssimpMatrix(const aiMatrix4x4& matrix) { + Math::Matrix4 result; + result.m[0][0] = matrix.a1; result.m[0][1] = matrix.a2; result.m[0][2] = matrix.a3; result.m[0][3] = matrix.a4; + result.m[1][0] = matrix.b1; result.m[1][1] = matrix.b2; result.m[1][2] = matrix.b3; result.m[1][3] = matrix.b4; + result.m[2][0] = matrix.c1; result.m[2][1] = matrix.c2; result.m[2][2] = matrix.c3; result.m[2][3] = matrix.c4; + result.m[3][0] = matrix.d1; result.m[3][1] = matrix.d2; result.m[3][2] = matrix.d3; result.m[3][3] = matrix.d4; + return result; +} + +Core::uint32 BuildPostProcessFlags(const MeshImportSettings& settings) { + Core::uint32 flags = aiProcess_Triangulate | + aiProcess_JoinIdenticalVertices | + aiProcess_SortByPType | + aiProcess_ValidateDataStructure | + aiProcess_FindInvalidData; + + if (settings.GetAxisConversion()) { + flags |= aiProcess_MakeLeftHanded; + flags |= aiProcess_FlipWindingOrder; + } + + if (settings.HasImportFlag(MeshImportFlags::FlipUVs)) { + flags |= aiProcess_FlipUVs; + } + + if (settings.HasImportFlag(MeshImportFlags::FlipWindingOrder)) { + flags |= aiProcess_FlipWindingOrder; + } + + if (settings.HasImportFlag(MeshImportFlags::GenerateNormals)) { + flags |= aiProcess_GenSmoothNormals; + } + + if (settings.HasImportFlag(MeshImportFlags::GenerateTangents)) { + flags |= aiProcess_CalcTangentSpace; + } + + if (settings.HasImportFlag(MeshImportFlags::OptimizeMesh)) { + flags |= aiProcess_ImproveCacheLocality; + flags |= aiProcess_OptimizeMeshes; + flags |= aiProcess_OptimizeGraph; + } + + return flags; +} + +TextureImportSettings BuildMaterialTextureImportSettings(const char* propertyName) { + TextureImportSettings settings; + (void)propertyName; + settings.SetSRGB(false); + settings.SetTargetFormat(TextureFormat::RGBA8_UNORM); + return settings; +} + +std::string BuildTextureCacheKey(const std::string& pathKey, const TextureImportSettings& settings) { + std::string cacheKey = pathKey; + cacheKey += settings.GetSRGB() ? "|srgb" : "|linear"; + cacheKey += "|fmt="; + cacheKey += std::to_string(static_cast(settings.GetTargetFormat())); + return cacheKey; +} + +fs::path ResolveSourcePathAbsolute(const Containers::String& sourcePath) { + fs::path resolvedPath(sourcePath.CStr()); + if (resolvedPath.is_absolute()) { + return resolvedPath.lexically_normal(); + } + + const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot(); + if (!resourceRoot.Empty()) { + return (fs::path(resourceRoot.CStr()) / resolvedPath).lexically_normal(); + } + + return resolvedPath.lexically_normal(); +} + +Texture* CreateRawTexture(const Containers::String& texturePath, + TextureFormat format, + Core::uint32 width, + Core::uint32 height, + const void* data, + size_t dataSize) { + auto* texture = new Texture(); + + IResource::ConstructParams params; + params.name = GetResourceNameFromPath(texturePath); + params.path = texturePath; + params.guid = ResourceGUID::Generate(texturePath); + params.memorySize = dataSize; + texture->Initialize(params); + + if (!texture->Create( + width, + height, + 1, + 1, + TextureType::Texture2D, + format, + data, + dataSize)) { + delete texture; + return nullptr; + } + + return texture; +} + +Core::uint32 FindEmbeddedTextureIndex(const aiScene& scene, const aiTexture& embeddedTexture) { + for (Core::uint32 index = 0; index < scene.mNumTextures; ++index) { + if (scene.mTextures[index] == &embeddedTexture) { + return index; + } + } + return 0; +} + +Texture* LoadEmbeddedTexture(const aiTexture& embeddedTexture, + const Containers::String& texturePath, + const TextureImportSettings& settings, + TextureImportContext& context) { + const std::string cacheKey = BuildTextureCacheKey(texturePath.CStr(), settings); + const auto cacheIt = context.textureCache.find(cacheKey); + if (cacheIt != context.textureCache.end()) { + return cacheIt->second; + } + + Texture* texture = nullptr; + if (embeddedTexture.mHeight == 0) { + LoadResult result = context.textureLoader.LoadFromMemory( + texturePath, + embeddedTexture.pcData, + embeddedTexture.mWidth, + &settings); + if (result) { + texture = static_cast(result.resource); + } + } else { + const size_t texelCount = + static_cast(embeddedTexture.mWidth) * static_cast(embeddedTexture.mHeight); + std::vector rgbaPixels(texelCount * 4u); + for (size_t index = 0; index < texelCount; ++index) { + const aiTexel& texel = embeddedTexture.pcData[index]; + rgbaPixels[index * 4 + 0] = texel.r; + rgbaPixels[index * 4 + 1] = texel.g; + rgbaPixels[index * 4 + 2] = texel.b; + rgbaPixels[index * 4 + 3] = texel.a; + } + + texture = CreateRawTexture( + texturePath, + settings.GetTargetFormat() != TextureFormat::Unknown + ? settings.GetTargetFormat() + : TextureFormat::RGBA8_UNORM, + embeddedTexture.mWidth, + embeddedTexture.mHeight, + rgbaPixels.data(), + rgbaPixels.size()); + } + + if (texture != nullptr) { + context.textureCache.emplace(cacheKey, texture); + context.ownedTextures.push_back(texture); + } + + return texture; +} + +Texture* LoadExternalTexture(const fs::path& textureFilePath, + const TextureImportSettings& settings, + TextureImportContext& context) { + const std::string normalizedPath = textureFilePath.lexically_normal().string(); + const std::string cacheKey = BuildTextureCacheKey(normalizedPath, settings); + const auto cacheIt = context.textureCache.find(cacheKey); + if (cacheIt != context.textureCache.end()) { + return cacheIt->second; + } + + LoadResult result = context.textureLoader.Load(Containers::String(normalizedPath.c_str()), &settings); + if (!result) { + return nullptr; + } + + Texture* texture = static_cast(result.resource); + context.textureCache.emplace(cacheKey, texture); + context.ownedTextures.push_back(texture); + return texture; +} + +Texture* LoadTextureReference(const aiString& textureReference, + const TextureImportSettings& settings, + TextureImportContext& context) { + if (textureReference.length == 0) { + return nullptr; + } + + const aiTexture* embeddedTexture = context.scene.GetEmbeddedTexture(textureReference.C_Str()); + if (embeddedTexture != nullptr) { + const Core::uint32 embeddedTextureIndex = FindEmbeddedTextureIndex(context.scene, *embeddedTexture); + std::string extension = ".tex"; + if (embeddedTexture->mHeight == 0 && embeddedTexture->achFormatHint[0] != '\0') { + extension = "."; + extension += embeddedTexture->achFormatHint; + } + + return LoadEmbeddedTexture( + *embeddedTexture, + BuildSubResourcePath( + context.sourcePath, + "embedded_texture", + embeddedTextureIndex, + Containers::String(extension.c_str())), + settings, + context); + } + + fs::path resolvedPath(textureReference.C_Str()); + if (!resolvedPath.is_absolute()) { + resolvedPath = context.sourceDirectory / resolvedPath; + } + + return LoadExternalTexture(resolvedPath, settings, context); +} + +Texture* LoadMaterialTexture(const aiMaterial& assimpMaterial, + std::initializer_list textureTypes, + const TextureImportSettings& settings, + TextureImportContext& context) { + for (aiTextureType textureType : textureTypes) { + if (assimpMaterial.GetTextureCount(textureType) == 0) { + continue; + } + + aiString texturePath; + if (assimpMaterial.GetTexture(textureType, 0, &texturePath) != AI_SUCCESS) { + continue; + } + + Texture* texture = LoadTextureReference(texturePath, settings, context); + if (texture != nullptr) { + return texture; + } + } + + return nullptr; +} + +enum class ObjMaterialTextureSemantic { + BaseColor, + Normal, + Specular +}; + +void InitializeObjMaterialTextureFallbacks(TextureImportContext& context) { + if (context.objMaterialFallbacksInitialized) { + return; + } + + context.objMaterialFallbacksInitialized = true; + + const fs::path sourcePath = ResolveSourcePathAbsolute(context.sourcePath); + if (ToLowerCopy(sourcePath.extension().string()) != ".obj") { + return; + } + + std::ifstream objInput(sourcePath); + if (!objInput.is_open()) { + return; + } + + std::vector mtlPaths; + const fs::path sourceDirectory = sourcePath.parent_path(); + std::string line; + while (std::getline(objInput, line)) { + const std::string trimmed = TrimCopy(line); + if (trimmed.empty() || trimmed[0] == '#') { + continue; + } + + const std::string lowered = ToLowerCopy(trimmed); + if (lowered.rfind("mtllib", 0) != 0 || + (trimmed.size() > 6 && std::isspace(static_cast(trimmed[6])) == 0)) { + continue; + } + + const std::string remainder = TrimCopy(trimmed.substr(6)); + for (const std::string& token : SplitWhitespaceTokens(remainder)) { + mtlPaths.push_back((sourceDirectory / token).lexically_normal()); + } + } + + for (const fs::path& mtlPath : mtlPaths) { + std::ifstream mtlInput(mtlPath); + if (!mtlInput.is_open()) { + continue; + } + + const fs::path mtlDirectory = mtlPath.parent_path(); + std::string currentMaterialName; + while (std::getline(mtlInput, line)) { + const std::string trimmed = TrimCopy(line); + if (trimmed.empty() || trimmed[0] == '#') { + continue; + } + + const std::vector tokens = SplitWhitespaceTokens(trimmed); + if (tokens.empty()) { + continue; + } + + const std::string keyword = ToLowerCopy(tokens.front()); + if (keyword == "newmtl") { + currentMaterialName = TrimCopy(trimmed.substr(tokens.front().size())); + continue; + } + + if (currentMaterialName.empty() || tokens.size() < 2) { + continue; + } + + const std::string& textureToken = tokens.back(); + if (textureToken.empty() || textureToken[0] == '-') { + continue; + } + + TextureImportContext::ObjMaterialTextureFallbacks& fallbacks = + context.objMaterialFallbacks[currentMaterialName]; + const fs::path resolvedTexturePath = (mtlDirectory / textureToken).lexically_normal(); + + if (keyword == "map_kd") { + fallbacks.baseColorPath = resolvedTexturePath; + } else if (keyword == "map_bump" || keyword == "bump" || keyword == "norm") { + fallbacks.normalPath = resolvedTexturePath; + } else if (keyword == "map_ks") { + fallbacks.specularPath = resolvedTexturePath; + } + } + } +} + +Texture* LoadObjMaterialTextureFallback(const aiMaterial& assimpMaterial, + ObjMaterialTextureSemantic semantic, + const TextureImportSettings& settings, + TextureImportContext& context) { + InitializeObjMaterialTextureFallbacks(context); + + aiString assimpName; + if (assimpMaterial.Get(AI_MATKEY_NAME, assimpName) != AI_SUCCESS || assimpName.length == 0) { + return nullptr; + } + + const auto fallbackIt = context.objMaterialFallbacks.find(std::string(assimpName.C_Str())); + if (fallbackIt == context.objMaterialFallbacks.end()) { + return nullptr; + } + + const TextureImportContext::ObjMaterialTextureFallbacks& fallbacks = fallbackIt->second; + const fs::path* fallbackPath = nullptr; + switch (semantic) { + case ObjMaterialTextureSemantic::BaseColor: + fallbackPath = &fallbacks.baseColorPath; + break; + case ObjMaterialTextureSemantic::Normal: + fallbackPath = &fallbacks.normalPath; + break; + case ObjMaterialTextureSemantic::Specular: + fallbackPath = &fallbacks.specularPath; + break; + default: + break; + } + + if (fallbackPath == nullptr || fallbackPath->empty()) { + return nullptr; + } + + return LoadExternalTexture(*fallbackPath, settings, context); +} + +bool HasMaterialTexture(const aiMaterial& assimpMaterial, + std::initializer_list textureTypes) { + for (aiTextureType textureType : textureTypes) { + if (assimpMaterial.GetTextureCount(textureType) > 0) { + return true; + } + } + return false; +} + +Math::Vector4 ResolveImportedBaseColor(const aiMaterial& assimpMaterial, bool hasBaseColorTexture) { + float opacity = 1.0f; + assimpMaterial.Get(AI_MATKEY_OPACITY, opacity); + + aiColor4D baseColor; + if (assimpMaterial.Get( + AI_MATKEY_GLTF_PBRMETALLICROUGHNESS_BASE_COLOR_FACTOR, + baseColor) == AI_SUCCESS) { + return Math::Vector4(baseColor.r, baseColor.g, baseColor.b, baseColor.a); + } + + aiColor3D diffuseColor; + if (assimpMaterial.Get(AI_MATKEY_COLOR_DIFFUSE, diffuseColor) == AI_SUCCESS) { + return hasBaseColorTexture + ? Math::Vector4(1.0f, 1.0f, 1.0f, opacity) + : Math::Vector4(diffuseColor.r, diffuseColor.g, diffuseColor.b, opacity); + } + + return Math::Vector4(1.0f, 1.0f, 1.0f, opacity); +} + +void ImportMaterialProperties(const aiMaterial& assimpMaterial, Material& material) { + const bool hasBaseColorTexture = + HasMaterialTexture(assimpMaterial, { aiTextureType_BASE_COLOR, aiTextureType_DIFFUSE }); + material.SetFloat4("_BaseColor", ResolveImportedBaseColor(assimpMaterial, hasBaseColorTexture)); + + int twoSided = 0; + if (assimpMaterial.Get(AI_MATKEY_TWOSIDED, twoSided) == AI_SUCCESS && twoSided != 0) { + MaterialRenderState renderState = material.GetRenderState(); + renderState.cullMode = MaterialCullMode::None; + material.SetRenderState(renderState); + } +} + +void ImportMaterialTextures(const aiMaterial& assimpMaterial, + Material& material, + TextureImportContext& context) { + auto assignTexture = [&](const char* propertyName, + ObjMaterialTextureSemantic semantic, + std::initializer_list textureTypes) { + const TextureImportSettings settings = BuildMaterialTextureImportSettings(propertyName); + Texture* texture = LoadMaterialTexture(assimpMaterial, textureTypes, settings, context); + if (texture == nullptr) { + texture = LoadObjMaterialTextureFallback(assimpMaterial, semantic, settings, context); + } + if (texture != nullptr) { + material.SetTexture(Containers::String(propertyName), ResourceHandle(texture)); + } + }; + + assignTexture( + "_MainTex", + ObjMaterialTextureSemantic::BaseColor, + { aiTextureType_BASE_COLOR, aiTextureType_DIFFUSE }); +} + +Material* ImportSingleMaterial(const aiMaterial& assimpMaterial, + const Containers::String& sourcePath, + Core::uint32 materialIndex, + TextureImportContext& context) { + aiString assimpName; + const bool hasName = + assimpMaterial.Get(AI_MATKEY_NAME, assimpName) == AI_SUCCESS && assimpName.length > 0; + const std::string materialName = hasName + ? std::string(assimpName.C_Str()) + : std::string("Material_") + std::to_string(materialIndex); + + auto* material = new Material(); + + IResource::ConstructParams params; + params.name = Containers::String(materialName.c_str()); + params.path = BuildSubResourcePath(sourcePath, "material", materialIndex, Containers::String(".mat")); + params.guid = ResourceGUID::Generate(params.path); + params.memorySize = materialName.length() + params.path.Length(); + material->Initialize(params); + + material->SetShader(ResourceManager::Get().Load(GetBuiltinForwardLitShaderPath())); + ImportMaterialProperties(assimpMaterial, *material); + ImportMaterialTextures(assimpMaterial, *material, context); + material->RecalculateMemorySize(); + return material; +} + +ImportedMeshData ImportSingleMesh(const aiMesh& mesh) { + ImportedMeshData result; + result.vertices.reserve(mesh.mNumVertices); + result.indices.reserve(mesh.mNumFaces * 3); + + VertexAttribute attributes = VertexAttribute::Position; + if (mesh.HasNormals()) { + attributes = attributes | VertexAttribute::Normal; + } + if (mesh.HasTangentsAndBitangents()) { + attributes = attributes | VertexAttribute::Tangent | VertexAttribute::Bitangent; + } + if (mesh.HasTextureCoords(0)) { + attributes = attributes | VertexAttribute::UV0; + } + + for (Core::uint32 vertexIndex = 0; vertexIndex < mesh.mNumVertices; ++vertexIndex) { + StaticMeshVertex vertex; + const aiVector3D& position = mesh.mVertices[vertexIndex]; + vertex.position = Math::Vector3(position.x, position.y, position.z); + + if (mesh.HasNormals()) { + const aiVector3D& normal = mesh.mNormals[vertexIndex]; + vertex.normal = Math::Vector3(normal.x, normal.y, normal.z).Normalized(); + } + + if (mesh.HasTangentsAndBitangents()) { + const aiVector3D& tangent = mesh.mTangents[vertexIndex]; + const aiVector3D& bitangent = mesh.mBitangents[vertexIndex]; + vertex.tangent = Math::Vector3(tangent.x, tangent.y, tangent.z).Normalized(); + vertex.bitangent = Math::Vector3(bitangent.x, bitangent.y, bitangent.z).Normalized(); + } + + if (mesh.HasTextureCoords(0)) { + const aiVector3D& uv = mesh.mTextureCoords[0][vertexIndex]; + vertex.uv0 = Math::Vector2(uv.x, uv.y); + } + + result.vertices.push_back(vertex); + } + + for (Core::uint32 faceIndex = 0; faceIndex < mesh.mNumFaces; ++faceIndex) { + const aiFace& face = mesh.mFaces[faceIndex]; + if (face.mNumIndices != 3) { + continue; + } + + result.indices.push_back(face.mIndices[0]); + result.indices.push_back(face.mIndices[1]); + result.indices.push_back(face.mIndices[2]); + } + + result.attributes = attributes; + result.bounds = ComputeBounds(result.vertices); + return result; +} + +Mesh* CreateImportedMeshResource(const aiMesh& assimpMesh, + const Containers::String& meshPath, + const Containers::String& meshName) { + const ImportedMeshData meshData = ImportSingleMesh(assimpMesh); + if (meshData.vertices.empty() || meshData.indices.empty()) { + return nullptr; + } + + auto* mesh = new Mesh(); + + IResource::ConstructParams params; + params.name = meshName; + params.path = meshPath; + params.guid = ResourceGUID::Generate(meshPath); + params.memorySize = 0; + mesh->Initialize(params); + + mesh->SetVertexData( + meshData.vertices.data(), + meshData.vertices.size() * sizeof(StaticMeshVertex), + static_cast(meshData.vertices.size()), + sizeof(StaticMeshVertex), + meshData.attributes); + + const bool use32BitIndex = + meshData.vertices.size() > static_cast(std::numeric_limits::max()); + if (use32BitIndex) { + mesh->SetIndexData( + meshData.indices.data(), + meshData.indices.size() * sizeof(Core::uint32), + static_cast(meshData.indices.size()), + true); + } else { + std::vector compactIndices; + compactIndices.reserve(meshData.indices.size()); + for (Core::uint32 index : meshData.indices) { + compactIndices.push_back(static_cast(index)); + } + mesh->SetIndexData( + compactIndices.data(), + compactIndices.size() * sizeof(Core::uint16), + static_cast(compactIndices.size()), + false); + } + + MeshSection section = {}; + section.baseVertex = 0u; + section.vertexCount = static_cast(meshData.vertices.size()); + section.startIndex = 0u; + section.indexCount = static_cast(meshData.indices.size()); + section.materialID = 0u; + section.bounds = meshData.bounds; + mesh->AddSection(section); + mesh->SetBounds(meshData.bounds); + return mesh; +} + +Containers::String GetNodeName(const aiNode& node, Core::uint32 fallbackIndex) { + if (node.mName.length > 0) { + return Containers::String(node.mName.C_Str()); + } + + return Containers::String(("Node_" + std::to_string(fallbackIndex)).c_str()); +} + +Containers::String BuildNodePath(const Containers::String& parentPath, + const Containers::String& nodeName, + Core::uint32 siblingIndex) { + std::string path = parentPath.CStr(); + if (!path.empty()) { + path += "/"; + } + path += nodeName.CStr(); + path += "#"; + path += std::to_string(siblingIndex); + return Containers::String(path.c_str()); +} + +LocalID GenerateStableLocalID(const Containers::String& semanticKey, + std::unordered_set& usedLocalIDs) { + AssetGUID guid = HashStringToAssetGUID(semanticKey); + LocalID localID = guid.low; + if (localID <= kMainAssetLocalID) { + localID ^= guid.high; + if (localID <= kMainAssetLocalID) { + localID += 2u; + } + } + + while (!usedLocalIDs.insert(localID).second) { + localID += 0x9e3779b97f4a7c15ULL; + if (localID <= kMainAssetLocalID) { + localID += 2u; + } + } + + return localID; +} + +bool ImportMaterials(ImportContext& context) { + context.materialLocalIDsBySourceIndex.assign(context.scene.mNumMaterials, kInvalidLocalID); + context.outData.materials.reserve(context.scene.mNumMaterials); + + for (Core::uint32 materialIndex = 0; materialIndex < context.scene.mNumMaterials; ++materialIndex) { + const aiMaterial* assimpMaterial = context.scene.mMaterials[materialIndex]; + if (assimpMaterial == nullptr) { + continue; + } + + Material* material = + ImportSingleMaterial(*assimpMaterial, context.sourcePath, materialIndex, context.textureContext); + if (material == nullptr) { + context.errorMessage = + Containers::String("Failed to import material from model: ") + context.sourcePath; + return false; + } + + const Containers::String semanticKey = + Containers::String("material:") + material->GetName() + ":" + + Containers::String(std::to_string(materialIndex).c_str()); + + ImportedModelMaterial importedMaterial; + importedMaterial.localID = GenerateStableLocalID(semanticKey, context.usedLocalIDs); + importedMaterial.material = material; + context.materialLocalIDsBySourceIndex[materialIndex] = importedMaterial.localID; + context.outData.materials.push_back(importedMaterial); + } + + context.outData.textures = context.textureContext.ownedTextures; + return true; +} + +bool ImportNodeRecursive(const aiNode& assimpNode, + Core::int32 parentIndex, + const Containers::String& parentPath, + Core::uint32 siblingIndex, + bool isRootNode, + ImportContext& context) { + ModelNode node; + node.name = GetNodeName(assimpNode, siblingIndex); + node.parentIndex = parentIndex; + + Math::Vector3 localPosition; + Math::Quaternion localRotation; + Math::Vector3 localScale; + ConvertAssimpMatrix(assimpNode.mTransformation).Decompose(localPosition, localRotation, localScale); + + if (isRootNode) { + localPosition = localPosition + context.settings.GetOffset(); + const float globalScale = context.settings.GetScale() * context.settings.GetImportScale(); + localScale = Math::Vector3( + localScale.x * globalScale, + localScale.y * globalScale, + localScale.z * globalScale); + } + + node.localPosition = localPosition; + node.localRotation = localRotation; + node.localScale = localScale; + node.meshBindingStart = static_cast(context.outData.model->GetMeshBindings().Size()); + node.meshBindingCount = 0u; + + const Containers::String nodePath = BuildNodePath(parentPath, node.name, siblingIndex); + + for (Core::uint32 attachedMeshIndex = 0; attachedMeshIndex < assimpNode.mNumMeshes; ++attachedMeshIndex) { + const Core::uint32 sourceMeshIndex = assimpNode.mMeshes[attachedMeshIndex]; + if (sourceMeshIndex >= context.scene.mNumMeshes) { + continue; + } + + const aiMesh* assimpMesh = context.scene.mMeshes[sourceMeshIndex]; + if (assimpMesh == nullptr || assimpMesh->mNumVertices == 0) { + continue; + } + + std::string meshName = "Mesh_" + std::to_string(sourceMeshIndex); + if (assimpMesh->mName.length > 0) { + meshName = assimpMesh->mName.C_Str(); + } + + const Containers::String meshPath = + BuildSubResourcePath(context.sourcePath, "mesh", sourceMeshIndex, Containers::String(".mesh")); + Mesh* mesh = CreateImportedMeshResource( + *assimpMesh, + meshPath, + Containers::String(meshName.c_str())); + if (mesh == nullptr) { + continue; + } + + const Containers::String meshSemanticKey = + Containers::String("mesh:") + nodePath + ":" + + Containers::String(std::to_string(sourceMeshIndex).c_str()); + + ImportedModelMesh importedMesh; + importedMesh.localID = GenerateStableLocalID(meshSemanticKey, context.usedLocalIDs); + importedMesh.mesh = mesh; + + const LocalID materialLocalID = + assimpMesh->mMaterialIndex < context.materialLocalIDsBySourceIndex.size() + ? context.materialLocalIDsBySourceIndex[assimpMesh->mMaterialIndex] + : kInvalidLocalID; + + if (materialLocalID != kInvalidLocalID) { + importedMesh.materialLocalIDs.push_back(materialLocalID); + } + + const Core::uint32 materialBindingStart = + static_cast(context.outData.model->GetMaterialBindings().Size()); + for (LocalID boundMaterialLocalID : importedMesh.materialLocalIDs) { + ModelMaterialBinding materialBinding; + materialBinding.slotIndex = static_cast( + context.outData.model->GetMaterialBindings().Size() - materialBindingStart); + materialBinding.materialLocalID = boundMaterialLocalID; + context.outData.model->AddMaterialBinding(materialBinding); + } + + ModelMeshBinding meshBinding; + meshBinding.meshLocalID = importedMesh.localID; + meshBinding.materialBindingStart = materialBindingStart; + meshBinding.materialBindingCount = static_cast(importedMesh.materialLocalIDs.size()); + context.outData.model->AddMeshBinding(meshBinding); + ++node.meshBindingCount; + + context.outData.meshes.push_back(importedMesh); + } + + const Core::uint32 nodeIndex = static_cast(context.outData.model->GetNodes().Size()); + context.outData.model->AddNode(node); + if (isRootNode) { + context.outData.model->SetRootNodeIndex(nodeIndex); + } + + for (Core::uint32 childIndex = 0; childIndex < assimpNode.mNumChildren; ++childIndex) { + const aiNode* child = assimpNode.mChildren[childIndex]; + if (child == nullptr) { + continue; + } + + if (!ImportNodeRecursive( + *child, + static_cast(nodeIndex), + nodePath, + childIndex, + false, + context)) { + return false; + } + } + + return true; +} + +Model* CreateImportedModelResource(const Containers::String& sourcePath) { + auto* model = new Model(); + + IResource::ConstructParams params; + params.name = GetResourceNameFromPath(sourcePath); + params.path = sourcePath; + params.guid = ResourceGUID::Generate(sourcePath); + params.memorySize = 0; + model->Initialize(params); + return model; +} + +} // namespace + +void ImportedModelData::Reset() { + if (model != nullptr) { + delete model; + model = nullptr; + } + + for (ImportedModelMesh& mesh : meshes) { + delete mesh.mesh; + mesh.mesh = nullptr; + mesh.materialLocalIDs.clear(); + mesh.localID = kInvalidLocalID; + } + meshes.clear(); + + for (ImportedModelMaterial& material : materials) { + delete material.material; + material.material = nullptr; + material.localID = kInvalidLocalID; + } + materials.clear(); + + for (Texture* texture : textures) { + delete texture; + } + textures.clear(); +} + +bool ImportAssimpModel(const Containers::String& sourcePath, + const MeshImportSettings& settings, + ImportedModelData& outData, + Containers::String* outErrorMessage) { + outData.Reset(); + + fs::path resolvedPath(sourcePath.CStr()); + if (!resolvedPath.is_absolute()) { + resolvedPath = ResolveSourcePathAbsolute(sourcePath); + } + + Assimp::Importer importer; + const aiScene* scene = importer.ReadFile(resolvedPath.string().c_str(), BuildPostProcessFlags(settings)); + if (scene == nullptr || scene->mRootNode == nullptr || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) != 0) { + if (outErrorMessage != nullptr) { + const char* errorText = importer.GetErrorString(); + *outErrorMessage = Containers::String("Assimp failed to import model: ") + + Containers::String(errorText != nullptr ? errorText : "Unknown error"); + } + return false; + } + + outData.model = CreateImportedModelResource(sourcePath); + + ImportContext context{ + *scene, + sourcePath, + settings, + outData, + TextureImportContext{ *scene, sourcePath, resolvedPath.parent_path() } + }; + + if (!ImportMaterials(context)) { + outData.Reset(); + if (outErrorMessage != nullptr) { + *outErrorMessage = context.errorMessage; + } + return false; + } + + if (!ImportNodeRecursive(*scene->mRootNode, -1, Containers::String(), 0u, true, context)) { + outData.Reset(); + if (outErrorMessage != nullptr) { + *outErrorMessage = !context.errorMessage.Empty() + ? context.errorMessage + : Containers::String("Failed to import model node hierarchy: ") + sourcePath; + } + return false; + } + + if (outData.meshes.empty()) { + outData.Reset(); + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("No triangle mesh data found in: ") + sourcePath; + } + return false; + } + + return true; +} + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/Model/Model.cpp b/engine/src/Resources/Model/Model.cpp new file mode 100644 index 00000000..8a12e16c --- /dev/null +++ b/engine/src/Resources/Model/Model.cpp @@ -0,0 +1,57 @@ +#include + +namespace XCEngine { +namespace Resources { + +Model::Model() = default; + +Model::~Model() { + Release(); +} + +void Model::Release() { + ClearGraph(); + SetInvalid(); +} + +void Model::SetRootNodeIndex(Core::uint32 index) { + m_rootNodeIndex = index; + UpdateMemorySize(); +} + +void Model::AddNode(const ModelNode& node) { + m_nodes.PushBack(node); + UpdateMemorySize(); +} + +void Model::AddMeshBinding(const ModelMeshBinding& binding) { + m_meshBindings.PushBack(binding); + UpdateMemorySize(); +} + +void Model::AddMaterialBinding(const ModelMaterialBinding& binding) { + m_materialBindings.PushBack(binding); + UpdateMemorySize(); +} + +void Model::ClearGraph() { + m_rootNodeIndex = kInvalidModelNodeIndex; + m_nodes.Clear(); + m_meshBindings.Clear(); + m_materialBindings.Clear(); + UpdateMemorySize(); +} + +void Model::UpdateMemorySize() { + m_memorySize = + m_nodes.Size() * sizeof(ModelNode) + + m_meshBindings.Size() * sizeof(ModelMeshBinding) + + m_materialBindings.Size() * sizeof(ModelMaterialBinding); + + for (const ModelNode& node : m_nodes) { + m_memorySize += node.name.Length(); + } +} + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/Model/ModelArtifactIO.cpp b/engine/src/Resources/Model/ModelArtifactIO.cpp new file mode 100644 index 00000000..d074ce88 --- /dev/null +++ b/engine/src/Resources/Model/ModelArtifactIO.cpp @@ -0,0 +1,261 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +namespace { + +Containers::String GetResourceNameFromPath(const Containers::String& path) { + const std::filesystem::path filePath(path.CStr()); + const std::string fileName = filePath.filename().string(); + if (!fileName.empty()) { + return Containers::String(fileName.c_str()); + } + return path; +} + +std::filesystem::path ResolveArtifactPath(const Containers::String& path) { + std::filesystem::path resolvedPath(path.CStr()); + if (!resolvedPath.is_absolute() && !std::filesystem::exists(resolvedPath)) { + const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot(); + if (!resourceRoot.Empty()) { + resolvedPath = std::filesystem::path(resourceRoot.CStr()) / resolvedPath; + } + } + + return resolvedPath.lexically_normal(); +} + +void WriteString(std::ofstream& stream, const Containers::String& value) { + const Core::uint32 length = static_cast(value.Length()); + stream.write(reinterpret_cast(&length), sizeof(length)); + if (length > 0) { + stream.write(value.CStr(), length); + } +} + +Containers::String ReadString(std::ifstream& stream) { + Core::uint32 length = 0; + stream.read(reinterpret_cast(&length), sizeof(length)); + if (!stream || length == 0) { + return Containers::String(); + } + + std::string buffer(length, '\0'); + stream.read(buffer.data(), length); + if (!stream) { + return Containers::String(); + } + + return Containers::String(buffer.c_str()); +} + +LoadResult CreateOwnedModelResource(const Containers::String& path, + const ModelArtifactHeader& header, + Containers::Array&& nodes, + Containers::Array&& meshBindings, + Containers::Array&& materialBindings) { + auto* model = new Model(); + + IResource::ConstructParams params; + params.name = GetResourceNameFromPath(path); + params.path = path; + params.guid = ResourceGUID::Generate(path); + params.memorySize = 0; + model->Initialize(params); + + if (header.rootNodeIndex != kInvalidModelNodeIndex) { + model->SetRootNodeIndex(header.rootNodeIndex); + } + + for (const ModelNode& node : nodes) { + model->AddNode(node); + } + + for (const ModelMeshBinding& binding : meshBindings) { + model->AddMeshBinding(binding); + } + + for (const ModelMaterialBinding& binding : materialBindings) { + model->AddMaterialBinding(binding); + } + + return LoadResult(model); +} + +} // namespace + +bool WriteModelArtifactFile(const Containers::String& artifactPath, + const Model& model, + Containers::String* outErrorMessage) { + const std::filesystem::path resolvedPath = ResolveArtifactPath(artifactPath); + std::error_code ec; + const std::filesystem::path parentPath = resolvedPath.parent_path(); + if (!parentPath.empty()) { + std::filesystem::create_directories(parentPath, ec); + if (ec) { + if (outErrorMessage != nullptr) { + *outErrorMessage = + Containers::String("Failed to create model artifact directory: ") + + Containers::String(parentPath.generic_string().c_str()); + } + return false; + } + } + + std::ofstream output(resolvedPath, std::ios::binary | std::ios::trunc); + if (!output.is_open()) { + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to open model artifact for write: ") + artifactPath; + } + return false; + } + + ModelArtifactFileHeader fileHeader; + output.write(reinterpret_cast(&fileHeader), sizeof(fileHeader)); + + ModelArtifactHeader header; + header.nodeCount = static_cast(model.GetNodes().Size()); + header.meshBindingCount = static_cast(model.GetMeshBindings().Size()); + header.materialBindingCount = static_cast(model.GetMaterialBindings().Size()); + header.rootNodeIndex = model.GetRootNodeIndex(); + output.write(reinterpret_cast(&header), sizeof(header)); + + for (const ModelNode& node : model.GetNodes()) { + WriteString(output, node.name); + + ModelNodeArtifactHeader nodeHeader; + nodeHeader.parentIndex = node.parentIndex; + nodeHeader.meshBindingStart = node.meshBindingStart; + nodeHeader.meshBindingCount = node.meshBindingCount; + nodeHeader.localPosition = node.localPosition; + nodeHeader.localRotation = node.localRotation; + nodeHeader.localScale = node.localScale; + output.write(reinterpret_cast(&nodeHeader), sizeof(nodeHeader)); + } + + for (const ModelMeshBinding& binding : model.GetMeshBindings()) { + ModelMeshBindingArtifact bindingArtifact; + bindingArtifact.meshLocalID = binding.meshLocalID; + bindingArtifact.materialBindingStart = binding.materialBindingStart; + bindingArtifact.materialBindingCount = binding.materialBindingCount; + output.write(reinterpret_cast(&bindingArtifact), sizeof(bindingArtifact)); + } + + for (const ModelMaterialBinding& binding : model.GetMaterialBindings()) { + ModelMaterialBindingArtifact bindingArtifact; + bindingArtifact.slotIndex = binding.slotIndex; + bindingArtifact.materialLocalID = binding.materialLocalID; + output.write(reinterpret_cast(&bindingArtifact), sizeof(bindingArtifact)); + } + + if (!output && outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to write model artifact: ") + artifactPath; + } + + return static_cast(output); +} + +LoadResult LoadModelArtifact(const Containers::String& path) { + const std::filesystem::path resolvedPath = ResolveArtifactPath(path); + + std::ifstream input(resolvedPath, std::ios::binary); + if (!input.is_open()) { + return LoadResult(Containers::String("Failed to read model artifact: ") + path); + } + + ModelArtifactFileHeader fileHeader; + input.read(reinterpret_cast(&fileHeader), sizeof(fileHeader)); + if (!input) { + return LoadResult(Containers::String("Failed to parse model artifact file header: ") + path); + } + + const bool validFileHeader = + std::memcmp(fileHeader.magic, "XCMOD01", 7) == 0 && + fileHeader.schemaVersion == kModelArtifactSchemaVersion; + if (!validFileHeader) { + return LoadResult(Containers::String("Invalid model artifact file header: ") + path); + } + + ModelArtifactHeader header; + input.read(reinterpret_cast(&header), sizeof(header)); + if (!input) { + return LoadResult(Containers::String("Failed to parse model artifact header: ") + path); + } + + if (header.rootNodeIndex != kInvalidModelNodeIndex && + header.rootNodeIndex >= header.nodeCount) { + return LoadResult(Containers::String("Invalid model artifact root node index: ") + path); + } + + Containers::Array nodes; + nodes.Reserve(header.nodeCount); + for (Core::uint32 index = 0; index < header.nodeCount; ++index) { + ModelNode node; + node.name = ReadString(input); + + ModelNodeArtifactHeader nodeHeader; + input.read(reinterpret_cast(&nodeHeader), sizeof(nodeHeader)); + if (!input) { + return LoadResult(Containers::String("Failed to read model node artifact: ") + path); + } + + node.parentIndex = nodeHeader.parentIndex; + node.meshBindingStart = nodeHeader.meshBindingStart; + node.meshBindingCount = nodeHeader.meshBindingCount; + node.localPosition = nodeHeader.localPosition; + node.localRotation = nodeHeader.localRotation; + node.localScale = nodeHeader.localScale; + nodes.PushBack(std::move(node)); + } + + Containers::Array meshBindings; + meshBindings.Reserve(header.meshBindingCount); + for (Core::uint32 index = 0; index < header.meshBindingCount; ++index) { + ModelMeshBindingArtifact bindingArtifact; + input.read(reinterpret_cast(&bindingArtifact), sizeof(bindingArtifact)); + if (!input) { + return LoadResult(Containers::String("Failed to read model mesh binding artifact: ") + path); + } + + ModelMeshBinding binding; + binding.meshLocalID = bindingArtifact.meshLocalID; + binding.materialBindingStart = bindingArtifact.materialBindingStart; + binding.materialBindingCount = bindingArtifact.materialBindingCount; + meshBindings.PushBack(binding); + } + + Containers::Array materialBindings; + materialBindings.Reserve(header.materialBindingCount); + for (Core::uint32 index = 0; index < header.materialBindingCount; ++index) { + ModelMaterialBindingArtifact bindingArtifact; + input.read(reinterpret_cast(&bindingArtifact), sizeof(bindingArtifact)); + if (!input) { + return LoadResult(Containers::String("Failed to read model material binding artifact: ") + path); + } + + ModelMaterialBinding binding; + binding.slotIndex = bindingArtifact.slotIndex; + binding.materialLocalID = bindingArtifact.materialLocalID; + materialBindings.PushBack(binding); + } + + return CreateOwnedModelResource( + path, + header, + std::move(nodes), + std::move(meshBindings), + std::move(materialBindings)); +} + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/Model/ModelLoader.cpp b/engine/src/Resources/Model/ModelLoader.cpp new file mode 100644 index 00000000..a274f5a1 --- /dev/null +++ b/engine/src/Resources/Model/ModelLoader.cpp @@ -0,0 +1,40 @@ +#include + +#include +#include + +namespace XCEngine { +namespace Resources { + +ModelLoader::ModelLoader() = default; + +ModelLoader::~ModelLoader() = default; + +Containers::Array ModelLoader::GetSupportedExtensions() const { + Containers::Array extensions; + extensions.PushBack("xcmodel"); + return extensions; +} + +bool ModelLoader::CanLoad(const Containers::String& path) const { + return GetExtension(path).ToLower() == "xcmodel"; +} + +LoadResult ModelLoader::Load(const Containers::String& path, const ImportSettings* settings) { + (void)settings; + + if (!CanLoad(path)) { + return LoadResult(Containers::String("Unsupported model format: ") + GetExtension(path).ToLower()); + } + + return LoadModelArtifact(path); +} + +ImportSettings* ModelLoader::GetDefaultSettings() const { + return nullptr; +} + +REGISTER_RESOURCE_LOADER(ModelLoader); + +} // namespace Resources +} // namespace XCEngine diff --git a/tests/Core/Asset/test_resource_types.cpp b/tests/Core/Asset/test_resource_types.cpp index 1171fdab..8530c017 100644 --- a/tests/Core/Asset/test_resource_types.cpp +++ b/tests/Core/Asset/test_resource_types.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include using namespace XCEngine::Resources; @@ -25,6 +27,8 @@ TEST(Resources_Types, ResourceType_EnumValues) { EXPECT_EQ(static_cast(ResourceType::UITheme), 14); EXPECT_EQ(static_cast(ResourceType::UISchema), 15); EXPECT_EQ(static_cast(ResourceType::VolumeField), 16); + EXPECT_EQ(static_cast(ResourceType::Model), 17); + EXPECT_EQ(static_cast(ResourceType::GaussianSplat), 18); } TEST(Resources_Types, GetResourceTypeName) { @@ -35,6 +39,8 @@ TEST(Resources_Types, GetResourceTypeName) { EXPECT_STREQ(GetResourceTypeName(ResourceType::UITheme), "UITheme"); EXPECT_STREQ(GetResourceTypeName(ResourceType::UISchema), "UISchema"); EXPECT_STREQ(GetResourceTypeName(ResourceType::VolumeField), "VolumeField"); + EXPECT_STREQ(GetResourceTypeName(ResourceType::Model), "Model"); + EXPECT_STREQ(GetResourceTypeName(ResourceType::GaussianSplat), "GaussianSplat"); EXPECT_STREQ(GetResourceTypeName(ResourceType::Unknown), "Unknown"); } @@ -49,6 +55,8 @@ TEST(Resources_Types, GetResourceType_TemplateSpecializations) { EXPECT_EQ(GetResourceType(), ResourceType::UITheme); EXPECT_EQ(GetResourceType(), ResourceType::UISchema); EXPECT_EQ(GetResourceType(), ResourceType::VolumeField); + EXPECT_EQ(GetResourceType(), ResourceType::Model); + EXPECT_EQ(GetResourceType(), ResourceType::GaussianSplat); } } // namespace diff --git a/tests/Resources/CMakeLists.txt b/tests/Resources/CMakeLists.txt index dbc6174a..2659b334 100644 --- a/tests/Resources/CMakeLists.txt +++ b/tests/Resources/CMakeLists.txt @@ -3,6 +3,8 @@ # ============================================================ add_subdirectory(Texture) +add_subdirectory(GaussianSplat) +add_subdirectory(Model) add_subdirectory(Mesh) add_subdirectory(Material) add_subdirectory(Shader) diff --git a/tests/Resources/GaussianSplat/CMakeLists.txt b/tests/Resources/GaussianSplat/CMakeLists.txt new file mode 100644 index 00000000..ad3da791 --- /dev/null +++ b/tests/Resources/GaussianSplat/CMakeLists.txt @@ -0,0 +1,34 @@ +# ============================================================ +# GaussianSplat Tests +# ============================================================ + +set(GAUSSIAN_SPLAT_TEST_SOURCES + test_gaussian_splat.cpp + test_gaussian_splat_loader.cpp +) + +add_executable(gaussian_splat_tests ${GAUSSIAN_SPLAT_TEST_SOURCES}) + +if(MSVC) + set_target_properties(gaussian_splat_tests PROPERTIES + LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib" + ) +endif() + +target_link_libraries(gaussian_splat_tests + PRIVATE + XCEngine + GTest::gtest + GTest::gtest_main +) + +target_include_directories(gaussian_splat_tests PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include +) + +target_compile_definitions(gaussian_splat_tests PRIVATE + XCENGINE_TEST_ROOM_PLY_PATH="${CMAKE_SOURCE_DIR}/mvs/3DGS-Unity/room.ply" +) + +include(GoogleTest) +gtest_discover_tests(gaussian_splat_tests) diff --git a/tests/Resources/GaussianSplat/test_gaussian_splat.cpp b/tests/Resources/GaussianSplat/test_gaussian_splat.cpp new file mode 100644 index 00000000..8c66f4f2 --- /dev/null +++ b/tests/Resources/GaussianSplat/test_gaussian_splat.cpp @@ -0,0 +1,73 @@ +#include + +#include +#include + +using namespace XCEngine::Resources; +using namespace XCEngine::Math; + +namespace { + +TEST(GaussianSplat, CreateOwnedStoresMetadataSectionsAndPayload) { + GaussianSplat gaussianSplat; + + GaussianSplatMetadata metadata; + metadata.contentVersion = 7u; + metadata.splatCount = 2u; + metadata.bounds.SetMinMax(Vector3(-1.0f, -2.0f, -3.0f), Vector3(4.0f, 5.0f, 6.0f)); + metadata.positionFormat = GaussianSplatSectionFormat::VectorFloat32; + metadata.otherFormat = GaussianSplatSectionFormat::OtherFloat32; + metadata.colorFormat = GaussianSplatSectionFormat::ColorRGBA32F; + metadata.shFormat = GaussianSplatSectionFormat::SHFloat32; + + XCEngine::Containers::Array sections; + sections.PushBack(GaussianSplatSection{ + GaussianSplatSectionType::Positions, + GaussianSplatSectionFormat::VectorFloat32, + 0u, + 24u, + 2u, + 12u + }); + + XCEngine::Containers::Array payload; + payload.Resize(24u); + for (size_t index = 0; index < payload.Size(); ++index) { + payload[index] = static_cast(index); + } + + ASSERT_TRUE(gaussianSplat.CreateOwned(metadata, std::move(sections), std::move(payload))); + EXPECT_TRUE(gaussianSplat.IsValid()); + EXPECT_EQ(gaussianSplat.GetType(), ResourceType::GaussianSplat); + EXPECT_EQ(gaussianSplat.GetContentVersion(), 7u); + EXPECT_EQ(gaussianSplat.GetSplatCount(), 2u); + EXPECT_EQ(gaussianSplat.GetBounds().GetMin(), Vector3(-1.0f, -2.0f, -3.0f)); + EXPECT_EQ(gaussianSplat.GetBounds().GetMax(), Vector3(4.0f, 5.0f, 6.0f)); + ASSERT_NE(gaussianSplat.FindSection(GaussianSplatSectionType::Positions), nullptr); + EXPECT_EQ(gaussianSplat.GetPayloadSize(), 24u); + EXPECT_NE(gaussianSplat.GetSectionData(GaussianSplatSectionType::Positions), nullptr); +} + +TEST(GaussianSplat, RejectsInvalidSectionLayout) { + GaussianSplat gaussianSplat; + + GaussianSplatMetadata metadata; + metadata.splatCount = 1u; + + XCEngine::Containers::Array sections; + sections.PushBack(GaussianSplatSection{ + GaussianSplatSectionType::Positions, + GaussianSplatSectionFormat::VectorFloat32, + 16u, + 16u, + 1u, + 12u + }); + + XCEngine::Containers::Array payload; + payload.Resize(24u); + + EXPECT_FALSE(gaussianSplat.CreateOwned(metadata, std::move(sections), std::move(payload))); +} + +} // namespace diff --git a/tests/Resources/GaussianSplat/test_gaussian_splat_loader.cpp b/tests/Resources/GaussianSplat/test_gaussian_splat_loader.cpp new file mode 100644 index 00000000..4d4b4b76 --- /dev/null +++ b/tests/Resources/GaussianSplat/test_gaussian_splat_loader.cpp @@ -0,0 +1,526 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace XCEngine::Resources; +using namespace XCEngine::Math; + +namespace { + +constexpr float kSHC0 = 0.2820948f; + +struct SyntheticGaussianSplatVertex { + Vector3 position = Vector3::Zero(); + Vector3 dc0 = Vector3::Zero(); + float opacity = 0.0f; + Vector3 scaleLog = Vector3::Zero(); + float rotationWXYZ[4] = { 1.0f, 0.0f, 0.0f, 0.0f }; + float sh[kGaussianSplatSHCoefficientCount] = {}; +}; + +struct SampleArtifactData { + GaussianSplatMetadata metadata; + XCEngine::Containers::Array sections; + XCEngine::Containers::Array payload; +}; + +std::filesystem::path GetRoomPlyPath() { + return std::filesystem::path(XCENGINE_TEST_ROOM_PLY_PATH); +} + +void ExpectVector3Near(const Vector3& actual, const Vector3& expected, float epsilon = 1e-5f) { + EXPECT_NEAR(actual.x, expected.x, epsilon); + EXPECT_NEAR(actual.y, expected.y, epsilon); + EXPECT_NEAR(actual.z, expected.z, epsilon); +} + +void ExpectVector4Near(const Vector4& actual, const Vector4& expected, float epsilon = 1e-5f) { + EXPECT_NEAR(actual.x, expected.x, epsilon); + EXPECT_NEAR(actual.y, expected.y, epsilon); + EXPECT_NEAR(actual.z, expected.z, epsilon); + EXPECT_NEAR(actual.w, expected.w, epsilon); +} + +void ExpectQuaternionNear(const Quaternion& actual, const Quaternion& expected, float epsilon = 1e-5f) { + EXPECT_NEAR(actual.x, expected.x, epsilon); + EXPECT_NEAR(actual.y, expected.y, epsilon); + EXPECT_NEAR(actual.z, expected.z, epsilon); + EXPECT_NEAR(actual.w, expected.w, epsilon); +} + +float Sigmoid(float value) { + return 1.0f / (1.0f + std::exp(-value)); +} + +Vector3 LinearScale(const Vector3& value) { + return Vector3( + std::abs(std::exp(value.x)), + std::abs(std::exp(value.y)), + std::abs(std::exp(value.z))); +} + +Quaternion NormalizeRotationWXYZ(float w, float x, float y, float z) { + const float magnitude = std::sqrt(w * w + x * x + y * y + z * z); + if (magnitude <= 1e-8f) { + return Quaternion::Identity(); + } + + return Quaternion(x / magnitude, y / magnitude, z / magnitude, w / magnitude); +} + +Vector4 SH0ToColorOpacity(const Vector3& dc0, float opacityRaw) { + return Vector4( + dc0.x * kSHC0 + 0.5f, + dc0.y * kSHC0 + 0.5f, + dc0.z * kSHC0 + 0.5f, + Sigmoid(opacityRaw)); +} + +void WriteBinaryFloat(std::ofstream& output, float value) { + output.write(reinterpret_cast(&value), sizeof(value)); +} + +void WriteSyntheticGaussianSplatPly( + const std::filesystem::path& path, + const std::vector& vertices) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(output.is_open()); + + output << "ply\n"; + output << "format binary_little_endian 1.0\n"; + output << "element vertex " << vertices.size() << "\n"; + output << "property float opacity\n"; + output << "property float y\n"; + output << "property float scale_2\n"; + output << "property float rot_3\n"; + output << "property float f_dc_1\n"; + output << "property float x\n"; + output << "property float scale_0\n"; + output << "property float rot_1\n"; + output << "property float f_dc_2\n"; + output << "property float z\n"; + output << "property float scale_1\n"; + output << "property float rot_0\n"; + output << "property float f_dc_0\n"; + output << "property float rot_2\n"; + for (XCEngine::Core::uint32 index = 0; index < kGaussianSplatSHCoefficientCount; ++index) { + output << "property float f_rest_" << index << "\n"; + } + output << "end_header\n"; + + for (const SyntheticGaussianSplatVertex& vertex : vertices) { + WriteBinaryFloat(output, vertex.opacity); + WriteBinaryFloat(output, vertex.position.y); + WriteBinaryFloat(output, vertex.scaleLog.z); + WriteBinaryFloat(output, vertex.rotationWXYZ[3]); + WriteBinaryFloat(output, vertex.dc0.y); + WriteBinaryFloat(output, vertex.position.x); + WriteBinaryFloat(output, vertex.scaleLog.x); + WriteBinaryFloat(output, vertex.rotationWXYZ[1]); + WriteBinaryFloat(output, vertex.dc0.z); + WriteBinaryFloat(output, vertex.position.z); + WriteBinaryFloat(output, vertex.scaleLog.y); + WriteBinaryFloat(output, vertex.rotationWXYZ[0]); + WriteBinaryFloat(output, vertex.dc0.x); + WriteBinaryFloat(output, vertex.rotationWXYZ[2]); + for (float coefficient : vertex.sh) { + WriteBinaryFloat(output, coefficient); + } + } +} + +SampleArtifactData BuildSampleArtifactData() { + const GaussianSplatPositionRecord positions[2] = { + { Vector3(0.0f, 1.0f, 2.0f) }, + { Vector3(3.0f, 4.0f, 5.0f) } + }; + const GaussianSplatOtherRecord other[2] = { + { Quaternion::Identity(), Vector3(1.0f, 1.0f, 1.0f), 0.0f }, + { Quaternion(0.0f, 0.5f, 0.0f, 0.8660254f), Vector3(2.0f, 2.0f, 2.0f), 0.0f } + }; + const GaussianSplatColorRecord colors[2] = { + { Vector4(1.0f, 0.0f, 0.0f, 0.25f) }, + { Vector4(0.0f, 1.0f, 0.0f, 0.75f) } + }; + + GaussianSplatSHRecord sh[2]; + for (XCEngine::Core::uint32 index = 0; index < kGaussianSplatSHCoefficientCount; ++index) { + sh[0].coefficients[index] = 0.01f * static_cast(index + 1u); + sh[1].coefficients[index] = -0.02f * static_cast(index + 1u); + } + + SampleArtifactData sample; + sample.metadata.contentVersion = 3u; + sample.metadata.splatCount = 2u; + sample.metadata.bounds.SetMinMax(Vector3(-2.0f, -1.0f, -3.0f), Vector3(5.0f, 4.0f, 6.0f)); + sample.metadata.positionFormat = GaussianSplatSectionFormat::VectorFloat32; + sample.metadata.otherFormat = GaussianSplatSectionFormat::OtherFloat32; + sample.metadata.colorFormat = GaussianSplatSectionFormat::ColorRGBA32F; + sample.metadata.shFormat = GaussianSplatSectionFormat::SHFloat32; + + sample.sections.Reserve(4u); + size_t payloadOffset = 0u; + auto appendSection = [&](GaussianSplatSectionType type, + GaussianSplatSectionFormat format, + const void* data, + size_t dataSize, + XCEngine::Core::uint32 elementCount, + XCEngine::Core::uint32 elementStride) { + GaussianSplatSection section; + section.type = type; + section.format = format; + section.dataOffset = payloadOffset; + section.dataSize = dataSize; + section.elementCount = elementCount; + section.elementStride = elementStride; + sample.sections.PushBack(section); + + const size_t newPayloadSize = sample.payload.Size() + dataSize; + sample.payload.Resize(newPayloadSize); + std::memcpy(sample.payload.Data() + payloadOffset, data, dataSize); + payloadOffset = newPayloadSize; + }; + + appendSection( + GaussianSplatSectionType::Positions, + GaussianSplatSectionFormat::VectorFloat32, + positions, + sizeof(positions), + 2u, + sizeof(GaussianSplatPositionRecord)); + appendSection( + GaussianSplatSectionType::Other, + GaussianSplatSectionFormat::OtherFloat32, + other, + sizeof(other), + 2u, + sizeof(GaussianSplatOtherRecord)); + appendSection( + GaussianSplatSectionType::Color, + GaussianSplatSectionFormat::ColorRGBA32F, + colors, + sizeof(colors), + 2u, + sizeof(GaussianSplatColorRecord)); + appendSection( + GaussianSplatSectionType::SH, + GaussianSplatSectionFormat::SHFloat32, + sh, + sizeof(sh), + 2u, + sizeof(GaussianSplatSHRecord)); + + return sample; +} + +GaussianSplat BuildSampleGaussianSplat(const char* artifactPath) { + SampleArtifactData sample = BuildSampleArtifactData(); + + GaussianSplat gaussianSplat; + XCEngine::Resources::IResource::ConstructParams params; + params.name = "sample.xcgsplat"; + params.path = artifactPath; + params.guid = ResourceGUID::Generate(params.path); + gaussianSplat.Initialize(params); + const bool created = gaussianSplat.CreateOwned( + sample.metadata, + std::move(sample.sections), + std::move(sample.payload)); + EXPECT_TRUE(created); + return gaussianSplat; +} + +std::filesystem::path CreateTestProjectRoot(const char* folderName) { + return std::filesystem::current_path() / "__xc_gaussian_splat_test_runtime" / folderName; +} + +void LinkOrCopyFixture(const std::filesystem::path& sourcePath, const std::filesystem::path& destinationPath) { + std::error_code ec; + std::filesystem::create_hard_link(sourcePath, destinationPath, ec); + if (ec) { + ec.clear(); + std::filesystem::copy_file(sourcePath, destinationPath, std::filesystem::copy_options::overwrite_existing, ec); + } + ASSERT_FALSE(ec); +} + +XCEngine::Core::uint32 ReadPlyVertexCount(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + EXPECT_TRUE(input.is_open()); + + std::string line; + while (std::getline(input, line)) { + if (line == "end_header") { + break; + } + + if (line.rfind("element vertex ", 0) == 0) { + return static_cast(std::stoul(line.substr(15))); + } + } + + return 0u; +} + +TEST(GaussianSplatLoader, GetResourceType) { + GaussianSplatLoader loader; + EXPECT_EQ(loader.GetResourceType(), ResourceType::GaussianSplat); +} + +TEST(GaussianSplatLoader, CanLoad) { + GaussianSplatLoader loader; + EXPECT_TRUE(loader.CanLoad("sample.xcgsplat")); + EXPECT_TRUE(loader.CanLoad("sample.XCGSPLAT")); + EXPECT_FALSE(loader.CanLoad("sample.ply")); +} + +TEST(GaussianSplatLoader, LoadInvalidPath) { + GaussianSplatLoader loader; + const LoadResult result = loader.Load("invalid/path/sample.xcgsplat"); + EXPECT_FALSE(result); +} + +TEST(GaussianSplatLoader, WritesAndLoadsArtifact) { + namespace fs = std::filesystem; + + const fs::path tempDir = fs::temp_directory_path() / "xc_gaussian_splat_artifact_test"; + const fs::path artifactPath = tempDir / "sample.xcgsplat"; + + fs::remove_all(tempDir); + fs::create_directories(tempDir); + + const GaussianSplat source = BuildSampleGaussianSplat(artifactPath.string().c_str()); + XCEngine::Containers::String errorMessage; + ASSERT_TRUE(WriteGaussianSplatArtifactFile(artifactPath.string().c_str(), source, &errorMessage)) + << errorMessage.CStr(); + + GaussianSplatLoader loader; + const LoadResult result = loader.Load(artifactPath.string().c_str()); + ASSERT_TRUE(result); + ASSERT_NE(result.resource, nullptr); + + auto* gaussianSplat = static_cast(result.resource); + ASSERT_NE(gaussianSplat, nullptr); + EXPECT_EQ(gaussianSplat->GetContentVersion(), 3u); + EXPECT_EQ(gaussianSplat->GetSplatCount(), 2u); + EXPECT_EQ(gaussianSplat->GetBounds().GetMin(), Vector3(-2.0f, -1.0f, -3.0f)); + EXPECT_EQ(gaussianSplat->GetBounds().GetMax(), Vector3(5.0f, 4.0f, 6.0f)); + ASSERT_EQ(gaussianSplat->GetSections().Size(), 4u); + const GaussianSplatSection* shSection = gaussianSplat->FindSection(GaussianSplatSectionType::SH); + ASSERT_NE(shSection, nullptr); + EXPECT_EQ(shSection->elementCount, 2u); + EXPECT_EQ(shSection->elementStride, sizeof(GaussianSplatSHRecord)); + ASSERT_NE(gaussianSplat->GetColorRecords(), nullptr); + EXPECT_EQ(gaussianSplat->GetColorRecords()[1].colorOpacity, Vector4(0.0f, 1.0f, 0.0f, 0.75f)); + + delete gaussianSplat; + fs::remove_all(tempDir); +} + +TEST(GaussianSplatLoader, ResourceManagerRegistersGaussianSplatLoader) { + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + + EXPECT_NE(manager.GetLoader(ResourceType::GaussianSplat), nullptr); + + manager.Shutdown(); +} + +TEST(GaussianSplatLoader, ResourceManagerLoadsArtifactByPath) { + namespace fs = std::filesystem; + + const fs::path tempDir = fs::temp_directory_path() / "xc_gaussian_splat_manager_load_test"; + const fs::path artifactPath = tempDir / "sample.xcgsplat"; + + fs::remove_all(tempDir); + fs::create_directories(tempDir); + + const GaussianSplat source = BuildSampleGaussianSplat(artifactPath.string().c_str()); + XCEngine::Containers::String errorMessage; + ASSERT_TRUE(WriteGaussianSplatArtifactFile(artifactPath.string().c_str(), source, &errorMessage)) + << errorMessage.CStr(); + + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + + { + const auto handle = manager.Load(artifactPath.string().c_str()); + ASSERT_TRUE(handle.IsValid()); + EXPECT_EQ(handle->GetSplatCount(), 2u); + EXPECT_EQ(handle->GetContentVersion(), 3u); + ASSERT_NE(handle->FindSection(GaussianSplatSectionType::Color), nullptr); + } + + manager.UnloadAll(); + manager.Shutdown(); + fs::remove_all(tempDir); +} + +TEST(GaussianSplatLoader, AssetDatabaseImportsSyntheticPlyAndLinearizesData) { + namespace fs = std::filesystem; + + const fs::path projectRoot = CreateTestProjectRoot("gaussian_splat_synthetic_import"); + const fs::path assetsDir = projectRoot / "Assets"; + const fs::path sourcePath = assetsDir / "sample.ply"; + + fs::remove_all(projectRoot); + fs::create_directories(assetsDir); + + std::vector vertices(2); + vertices[0].position = Vector3(1.0f, 2.0f, 3.0f); + vertices[0].dc0 = Vector3(0.2f, -0.1f, 0.0f); + vertices[0].opacity = 0.25f; + vertices[0].scaleLog = Vector3(0.0f, std::log(2.0f), std::log(4.0f)); + vertices[0].rotationWXYZ[0] = 2.0f; + vertices[0].rotationWXYZ[1] = 0.0f; + vertices[0].rotationWXYZ[2] = 0.0f; + vertices[0].rotationWXYZ[3] = 0.0f; + for (XCEngine::Core::uint32 index = 0; index < kGaussianSplatSHCoefficientCount; ++index) { + vertices[0].sh[index] = 0.01f * static_cast(index + 1u); + } + + vertices[1].position = Vector3(-4.0f, -5.0f, -6.0f); + vertices[1].dc0 = Vector3(1.0f, 0.5f, -0.5f); + vertices[1].opacity = -1.0f; + vertices[1].scaleLog = Vector3(std::log(0.5f), 0.0f, std::log(3.0f)); + vertices[1].rotationWXYZ[0] = 0.0f; + vertices[1].rotationWXYZ[1] = 0.0f; + vertices[1].rotationWXYZ[2] = 3.0f; + vertices[1].rotationWXYZ[3] = 4.0f; + for (XCEngine::Core::uint32 index = 0; index < kGaussianSplatSHCoefficientCount; ++index) { + vertices[1].sh[index] = -0.02f * static_cast(index + 1u); + } + + WriteSyntheticGaussianSplatPly(sourcePath, vertices); + + AssetDatabase database; + database.Initialize(projectRoot.string().c_str()); + + AssetDatabase::ResolvedAsset resolved; + ASSERT_TRUE(database.EnsureArtifact("Assets/sample.ply", ResourceType::GaussianSplat, resolved)); + ASSERT_TRUE(resolved.artifactReady); + EXPECT_TRUE(fs::exists(resolved.artifactMainPath.CStr())); + EXPECT_EQ(fs::path(resolved.artifactMainPath.CStr()).extension().generic_string(), ".xcgsplat"); + + GaussianSplatLoader loader; + LoadResult result = loader.Load(resolved.artifactMainPath); + ASSERT_TRUE(result); + ASSERT_NE(result.resource, nullptr); + + auto* gaussianSplat = static_cast(result.resource); + ASSERT_NE(gaussianSplat, nullptr); + EXPECT_EQ(gaussianSplat->GetSplatCount(), 2u); + EXPECT_EQ(gaussianSplat->GetChunkCount(), 0u); + EXPECT_EQ(gaussianSplat->GetCameraCount(), 0u); + ExpectVector3Near(gaussianSplat->GetBounds().GetMin(), Vector3(-4.0f, -5.0f, -6.0f)); + ExpectVector3Near(gaussianSplat->GetBounds().GetMax(), Vector3(1.0f, 2.0f, 3.0f)); + + ASSERT_NE(gaussianSplat->GetPositionRecords(), nullptr); + ASSERT_NE(gaussianSplat->GetOtherRecords(), nullptr); + ASSERT_NE(gaussianSplat->GetColorRecords(), nullptr); + ASSERT_NE(gaussianSplat->GetSHRecords(), nullptr); + + ExpectVector3Near(gaussianSplat->GetPositionRecords()[0].position, vertices[0].position); + ExpectVector3Near(gaussianSplat->GetPositionRecords()[1].position, vertices[1].position); + ExpectQuaternionNear( + gaussianSplat->GetOtherRecords()[0].rotation, + NormalizeRotationWXYZ(2.0f, 0.0f, 0.0f, 0.0f)); + ExpectQuaternionNear( + gaussianSplat->GetOtherRecords()[1].rotation, + NormalizeRotationWXYZ(0.0f, 0.0f, 3.0f, 4.0f)); + ExpectVector3Near(gaussianSplat->GetOtherRecords()[0].scale, LinearScale(vertices[0].scaleLog)); + ExpectVector3Near(gaussianSplat->GetOtherRecords()[1].scale, LinearScale(vertices[1].scaleLog)); + ExpectVector4Near(gaussianSplat->GetColorRecords()[0].colorOpacity, SH0ToColorOpacity(vertices[0].dc0, vertices[0].opacity)); + ExpectVector4Near(gaussianSplat->GetColorRecords()[1].colorOpacity, SH0ToColorOpacity(vertices[1].dc0, vertices[1].opacity)); + EXPECT_NEAR(gaussianSplat->GetSHRecords()[0].coefficients[0], vertices[0].sh[0], 1e-6f); + EXPECT_NEAR(gaussianSplat->GetSHRecords()[0].coefficients[44], vertices[0].sh[44], 1e-6f); + EXPECT_NEAR(gaussianSplat->GetSHRecords()[1].coefficients[0], vertices[1].sh[0], 1e-6f); + EXPECT_NEAR(gaussianSplat->GetSHRecords()[1].coefficients[44], vertices[1].sh[44], 1e-6f); + + delete gaussianSplat; + database.Shutdown(); + fs::remove_all(projectRoot); +} + +TEST(GaussianSplatLoader, RoomPlyBuildsArtifactAndLoadsThroughResourceManager) { + namespace fs = std::filesystem; + using namespace std::chrono_literals; + + const fs::path fixturePath = GetRoomPlyPath(); + ASSERT_TRUE(fs::exists(fixturePath)); + + const XCEngine::Core::uint32 expectedVertexCount = ReadPlyVertexCount(fixturePath); + ASSERT_GT(expectedVertexCount, 0u); + + const fs::path projectRoot = CreateTestProjectRoot("gaussian_splat_room_import"); + const fs::path assetsDir = projectRoot / "Assets"; + const fs::path roomPath = assetsDir / "room.ply"; + + fs::remove_all(projectRoot); + fs::create_directories(assetsDir); + LinkOrCopyFixture(fixturePath, roomPath); + + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + manager.SetResourceRoot(projectRoot.string().c_str()); + + { + const auto firstHandle = manager.Load("Assets/room.ply"); + ASSERT_TRUE(firstHandle.IsValid()); + EXPECT_EQ(firstHandle->GetSplatCount(), expectedVertexCount); + EXPECT_EQ(firstHandle->GetPositionFormat(), GaussianSplatSectionFormat::VectorFloat32); + EXPECT_EQ(firstHandle->GetOtherFormat(), GaussianSplatSectionFormat::OtherFloat32); + EXPECT_EQ(firstHandle->GetColorFormat(), GaussianSplatSectionFormat::ColorRGBA32F); + EXPECT_EQ(firstHandle->GetSHFormat(), GaussianSplatSectionFormat::SHFloat32); + + AssetRef assetRef; + ASSERT_TRUE(manager.TryGetAssetRef("Assets/room.ply", ResourceType::GaussianSplat, assetRef)); + EXPECT_TRUE(assetRef.IsValid()); + + AssetDatabase database; + database.Initialize(projectRoot.string().c_str()); + + AssetDatabase::ResolvedAsset firstResolve; + ASSERT_TRUE(database.EnsureArtifact("Assets/room.ply", ResourceType::GaussianSplat, firstResolve)); + ASSERT_TRUE(firstResolve.artifactReady); + EXPECT_TRUE(fs::exists(firstResolve.artifactMainPath.CStr())); + EXPECT_EQ(fs::path(firstResolve.artifactMainPath.CStr()).extension().generic_string(), ".xcgsplat"); + const auto originalArtifactWriteTime = fs::last_write_time(firstResolve.artifactMainPath.CStr()); + + std::this_thread::sleep_for(50ms); + + AssetDatabase::ResolvedAsset secondResolve; + ASSERT_TRUE(database.EnsureArtifact("Assets/room.ply", ResourceType::GaussianSplat, secondResolve)); + EXPECT_EQ(firstResolve.artifactMainPath, secondResolve.artifactMainPath); + EXPECT_EQ(originalArtifactWriteTime, fs::last_write_time(secondResolve.artifactMainPath.CStr())); + + database.Shutdown(); + + manager.UnloadAll(); + + const auto secondHandle = manager.Load(assetRef); + ASSERT_TRUE(secondHandle.IsValid()); + EXPECT_EQ(secondHandle->GetSplatCount(), expectedVertexCount); + ASSERT_NE(secondHandle->FindSection(GaussianSplatSectionType::SH), nullptr); + } + + manager.SetResourceRoot(""); + manager.Shutdown(); + fs::remove_all(projectRoot); +} + +} // namespace diff --git a/tests/Resources/Mesh/test_mesh_loader.cpp b/tests/Resources/Mesh/test_mesh_loader.cpp index 0f338400..c1a48ea1 100644 --- a/tests/Resources/Mesh/test_mesh_loader.cpp +++ b/tests/Resources/Mesh/test_mesh_loader.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -301,11 +302,15 @@ TEST(MeshLoader, ProjectBackpackSampleArtifactRetainsSectionMaterialTextures) { database.Initialize(projectRoot.string().c_str()); AssetDatabase::ResolvedAsset resolvedAsset; - ASSERT_TRUE(database.EnsureArtifact("Assets/Models/backpack/backpack.obj", ResourceType::Mesh, resolvedAsset)); + ASSERT_TRUE(database.EnsureArtifact("Assets/Models/backpack/backpack.obj", ResourceType::Model, resolvedAsset)); ASSERT_TRUE(resolvedAsset.artifactReady); + EXPECT_EQ(fs::path(resolvedAsset.artifactMainPath.CStr()).filename().string(), "main.xcmodel"); + + const fs::path meshArtifactPath = fs::path(resolvedAsset.artifactDirectory.CStr()) / "mesh_0.xcmesh"; + ASSERT_TRUE(fs::exists(meshArtifactPath)); MeshLoader loader; - const LoadResult result = loader.Load(resolvedAsset.artifactMainPath.CStr()); + const LoadResult result = loader.Load(meshArtifactPath.string().c_str()); ASSERT_TRUE(result); ASSERT_NE(result.resource, nullptr); @@ -370,21 +375,25 @@ TEST(MeshLoader, AssetDatabaseCreatesModelArtifactAndReusesItWithoutReimport) { database.Initialize(projectRoot.string().c_str()); AssetDatabase::ResolvedAsset firstResolve; - ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Mesh, firstResolve)); + ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Model, firstResolve)); ASSERT_TRUE(firstResolve.exists); ASSERT_TRUE(firstResolve.artifactReady); + EXPECT_EQ(fs::path(firstResolve.artifactMainPath.CStr()).filename().string(), "main.xcmodel"); EXPECT_TRUE(fs::exists(projectRoot / "Assets" / "textured_triangle.obj.meta")); EXPECT_TRUE(fs::exists(projectRoot / "Library" / "SourceAssetDB" / "assets.db")); EXPECT_TRUE(fs::exists(projectRoot / "Library" / "ArtifactDB" / "artifacts.db")); EXPECT_TRUE(fs::exists(firstResolve.artifactMainPath.CStr())); - EXPECT_TRUE(fs::exists((fs::path(firstResolve.artifactDirectory.CStr()) / - ("material_" + std::to_string(sourceMaterialIndex) + ".xcmat")))); + EXPECT_TRUE(fs::exists(fs::path(firstResolve.artifactDirectory.CStr()) / "mesh_0.xcmesh")); + EXPECT_TRUE(fs::exists( + fs::path(firstResolve.artifactDirectory.CStr()) / + ("material_" + std::to_string(sourceMaterialIndex) + ".xcmat"))); EXPECT_TRUE(fs::exists((fs::path(firstResolve.artifactDirectory.CStr()) / "texture_0.xctex"))); MaterialLoader materialLoader; LoadResult materialArtifactResult = - materialLoader.Load((fs::path(firstResolve.artifactDirectory.CStr()) / - ("material_" + std::to_string(sourceMaterialIndex) + ".xcmat")).string().c_str()); + materialLoader.Load( + (fs::path(firstResolve.artifactDirectory.CStr()) / + ("material_" + std::to_string(sourceMaterialIndex) + ".xcmat")).string().c_str()); ASSERT_TRUE(materialArtifactResult); ASSERT_NE(materialArtifactResult.resource, nullptr); auto* artifactMaterial = static_cast(materialArtifactResult.resource); @@ -405,7 +414,8 @@ TEST(MeshLoader, AssetDatabaseCreatesModelArtifactAndReusesItWithoutReimport) { delete artifactMaterial; MeshLoader meshLoader; - LoadResult meshArtifactResult = meshLoader.Load(firstResolve.artifactMainPath.CStr()); + LoadResult meshArtifactResult = + meshLoader.Load((fs::path(firstResolve.artifactDirectory.CStr()) / "mesh_0.xcmesh").string().c_str()); ASSERT_TRUE(meshArtifactResult); ASSERT_NE(meshArtifactResult.resource, nullptr); auto* artifactMesh = static_cast(meshArtifactResult.resource); @@ -430,14 +440,14 @@ TEST(MeshLoader, AssetDatabaseCreatesModelArtifactAndReusesItWithoutReimport) { delete artifactMesh; AssetRef assetRef; - ASSERT_TRUE(database.TryGetAssetRef("Assets/textured_triangle.obj", ResourceType::Mesh, assetRef)); + ASSERT_TRUE(database.TryGetAssetRef("Assets/textured_triangle.obj", ResourceType::Model, assetRef)); EXPECT_TRUE(assetRef.IsValid()); const auto originalArtifactWriteTime = fs::last_write_time(firstResolve.artifactMainPath.CStr()); std::this_thread::sleep_for(50ms); AssetDatabase::ResolvedAsset secondResolve; - ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Mesh, secondResolve)); + ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Model, secondResolve)); EXPECT_EQ(firstResolve.artifactMainPath, secondResolve.artifactMainPath); EXPECT_EQ(originalArtifactWriteTime, fs::last_write_time(secondResolve.artifactMainPath.CStr())); @@ -469,7 +479,7 @@ TEST(MeshLoader, AssetDatabaseReimportsModelWhenDependencyChanges) { database.Initialize(projectRoot.string().c_str()); AssetDatabase::ResolvedAsset firstResolve; - ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Mesh, firstResolve)); + ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Model, firstResolve)); ASSERT_TRUE(firstResolve.artifactReady); const String firstArtifactPath = firstResolve.artifactMainPath; database.Shutdown(); @@ -484,7 +494,7 @@ TEST(MeshLoader, AssetDatabaseReimportsModelWhenDependencyChanges) { database.Initialize(projectRoot.string().c_str()); AssetDatabase::ResolvedAsset secondResolve; - ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Mesh, secondResolve)); + ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Model, secondResolve)); ASSERT_TRUE(secondResolve.artifactReady); EXPECT_NE(firstArtifactPath, secondResolve.artifactMainPath); const String secondArtifactPath = secondResolve.artifactMainPath; @@ -495,7 +505,7 @@ TEST(MeshLoader, AssetDatabaseReimportsModelWhenDependencyChanges) { database.Initialize(projectRoot.string().c_str()); AssetDatabase::ResolvedAsset thirdResolve; - ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Mesh, thirdResolve)); + ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Model, thirdResolve)); ASSERT_TRUE(thirdResolve.artifactReady); EXPECT_NE(secondArtifactPath, thirdResolve.artifactMainPath); EXPECT_TRUE(fs::exists(thirdResolve.artifactMainPath.CStr())); @@ -504,7 +514,7 @@ TEST(MeshLoader, AssetDatabaseReimportsModelWhenDependencyChanges) { fs::remove_all(projectRoot); } -TEST(MeshLoader, ResourceManagerLoadsModelByAssetRefFromProjectAssets) { +TEST(MeshLoader, ResourceManagerLoadsImportedMeshSubAssetByAssetRefFromProjectAssets) { namespace fs = std::filesystem; ResourceManager& manager = ResourceManager::Get(); @@ -527,60 +537,97 @@ TEST(MeshLoader, ResourceManagerLoadsModelByAssetRefFromProjectAssets) { manager.SetResourceRoot(projectRoot.string().c_str()); - const auto firstHandle = manager.Load("Assets/textured_triangle.obj"); - ASSERT_TRUE(firstHandle.IsValid()); - EXPECT_EQ(firstHandle->GetVertexCount(), 3u); - EXPECT_EQ(firstHandle->GetIndexCount(), 3u); - EXPECT_GE(firstHandle->GetMaterials().Size(), 1u); - EXPECT_EQ(firstHandle->GetTextures().Size(), 0u); - const auto initialMaterialCount = firstHandle->GetMaterials().Size(); - const XCEngine::Core::uint32 firstSectionMaterialIndex = GetFirstSectionMaterialIndex(*firstHandle.Get()); - EXPECT_LT(firstSectionMaterialIndex, initialMaterialCount); - Material* firstMaterial = GetFirstSectionMaterial(*firstHandle.Get()); - ASSERT_NE(firstMaterial, nullptr); - ASSERT_NE(firstMaterial->GetShader(), nullptr); - EXPECT_EQ(firstMaterial->GetShader()->GetPath(), GetBuiltinForwardLitShaderPath()); - EXPECT_EQ(firstMaterial->GetTextureBindingCount(), 1u); - EXPECT_EQ(firstMaterial->GetTextureBindingName(0), "_MainTex"); - EXPECT_FALSE(firstMaterial->GetTextureBindingPath(0).Empty()); - const ResourceHandle firstLazyTexture = firstMaterial->GetTexture("_MainTex"); - EXPECT_FALSE(firstLazyTexture.IsValid()); - EXPECT_GT(manager.GetAsyncPendingCount(), 0u); - ASSERT_TRUE(PumpAsyncLoadsUntilIdle(manager)); - const ResourceHandle firstResolvedTexture = firstMaterial->GetTexture("_MainTex"); - ASSERT_TRUE(firstResolvedTexture.IsValid()); - EXPECT_EQ(firstResolvedTexture->GetWidth(), 2u); - EXPECT_EQ(firstResolvedTexture->GetHeight(), 2u); + { + const auto directSourceMesh = manager.Load("Assets/textured_triangle.obj"); + ASSERT_TRUE(directSourceMesh.IsValid()); + EXPECT_EQ(directSourceMesh->GetVertexCount(), 3u); + EXPECT_EQ(directSourceMesh->GetIndexCount(), 3u); + EXPECT_EQ(directSourceMesh->GetTextures().Size(), 1u); + Material* directMaterial = GetFirstSectionMaterial(*directSourceMesh.Get()); + ASSERT_NE(directMaterial, nullptr); + const ResourceHandle directTexture = directMaterial->GetTexture("_MainTex"); + ASSERT_TRUE(directTexture.IsValid()); + EXPECT_EQ(directTexture->GetWidth(), 2u); + EXPECT_EQ(directTexture->GetHeight(), 2u); + } - AssetRef assetRef; - ASSERT_TRUE(manager.TryGetAssetRef("Assets/textured_triangle.obj", ResourceType::Mesh, assetRef)); - EXPECT_TRUE(assetRef.IsValid()); + AssetRef modelAssetRef; + AssetRef meshAssetRef; + String resolvedMeshPath; + { + const auto modelHandle = manager.Load("Assets/textured_triangle.obj"); + ASSERT_TRUE(modelHandle.IsValid()); + ASSERT_TRUE(manager.TryGetAssetRef("Assets/textured_triangle.obj", ResourceType::Model, modelAssetRef)); + ASSERT_TRUE(modelHandle->GetMeshBindings().Size() >= 1u); + + meshAssetRef.assetGuid = modelAssetRef.assetGuid; + meshAssetRef.localID = modelHandle->GetMeshBindings()[0].meshLocalID; + meshAssetRef.resourceType = ResourceType::Mesh; + ASSERT_TRUE(meshAssetRef.IsValid()); + + ASSERT_TRUE(manager.TryResolveAssetPath(meshAssetRef, resolvedMeshPath)); + EXPECT_EQ(fs::path(resolvedMeshPath.CStr()).filename().string(), "mesh_0.xcmesh"); + } manager.UnloadAll(); - const auto secondHandle = manager.Load(assetRef); - ASSERT_TRUE(secondHandle.IsValid()); - EXPECT_EQ(secondHandle->GetPath(), "Assets/textured_triangle.obj"); - EXPECT_EQ(secondHandle->GetVertexCount(), 3u); - EXPECT_EQ(secondHandle->GetIndexCount(), 3u); - EXPECT_EQ(secondHandle->GetMaterials().Size(), initialMaterialCount); - EXPECT_EQ(secondHandle->GetTextures().Size(), 0u); - EXPECT_EQ(GetFirstSectionMaterialIndex(*secondHandle.Get()), firstSectionMaterialIndex); - Material* secondMaterial = GetFirstSectionMaterial(*secondHandle.Get()); - ASSERT_NE(secondMaterial, nullptr); - ASSERT_NE(secondMaterial->GetShader(), nullptr); - EXPECT_EQ(secondMaterial->GetShader()->GetPath(), GetBuiltinForwardLitShaderPath()); - EXPECT_EQ(secondMaterial->GetTextureBindingCount(), 1u); - EXPECT_EQ(secondMaterial->GetTextureBindingName(0), "_MainTex"); - EXPECT_FALSE(secondMaterial->GetTextureBindingPath(0).Empty()); - const ResourceHandle secondLazyTexture = secondMaterial->GetTexture("_MainTex"); - EXPECT_FALSE(secondLazyTexture.IsValid()); - EXPECT_GT(manager.GetAsyncPendingCount(), 0u); - ASSERT_TRUE(PumpAsyncLoadsUntilIdle(manager)); - const ResourceHandle secondResolvedTexture = secondMaterial->GetTexture("_MainTex"); - ASSERT_TRUE(secondResolvedTexture.IsValid()); - EXPECT_EQ(secondResolvedTexture->GetWidth(), 2u); - EXPECT_EQ(secondResolvedTexture->GetHeight(), 2u); + XCEngine::Core::uint32 initialMaterialCount = 0; + XCEngine::Core::uint32 firstSectionMaterialIndex = 0; + { + const auto firstHandle = manager.Load(meshAssetRef); + ASSERT_TRUE(firstHandle.IsValid()); + EXPECT_EQ(firstHandle->GetPath(), resolvedMeshPath); + EXPECT_EQ(firstHandle->GetVertexCount(), 3u); + EXPECT_EQ(firstHandle->GetIndexCount(), 3u); + EXPECT_GE(firstHandle->GetMaterials().Size(), 1u); + EXPECT_EQ(firstHandle->GetTextures().Size(), 0u); + initialMaterialCount = firstHandle->GetMaterials().Size(); + firstSectionMaterialIndex = GetFirstSectionMaterialIndex(*firstHandle.Get()); + EXPECT_LT(firstSectionMaterialIndex, initialMaterialCount); + Material* firstMaterial = GetFirstSectionMaterial(*firstHandle.Get()); + ASSERT_NE(firstMaterial, nullptr); + ASSERT_NE(firstMaterial->GetShader(), nullptr); + EXPECT_EQ(firstMaterial->GetShader()->GetPath(), GetBuiltinForwardLitShaderPath()); + EXPECT_EQ(firstMaterial->GetTextureBindingCount(), 1u); + EXPECT_EQ(firstMaterial->GetTextureBindingName(0), "_MainTex"); + EXPECT_FALSE(firstMaterial->GetTextureBindingPath(0).Empty()); + const ResourceHandle firstLazyTexture = firstMaterial->GetTexture("_MainTex"); + EXPECT_FALSE(firstLazyTexture.IsValid()); + EXPECT_GT(manager.GetAsyncPendingCount(), 0u); + ASSERT_TRUE(PumpAsyncLoadsUntilIdle(manager)); + const ResourceHandle firstResolvedTexture = firstMaterial->GetTexture("_MainTex"); + ASSERT_TRUE(firstResolvedTexture.IsValid()); + EXPECT_EQ(firstResolvedTexture->GetWidth(), 2u); + EXPECT_EQ(firstResolvedTexture->GetHeight(), 2u); + } + + manager.UnloadAll(); + + { + const auto secondHandle = manager.Load(meshAssetRef); + ASSERT_TRUE(secondHandle.IsValid()); + EXPECT_EQ(secondHandle->GetPath(), resolvedMeshPath); + EXPECT_EQ(secondHandle->GetVertexCount(), 3u); + EXPECT_EQ(secondHandle->GetIndexCount(), 3u); + EXPECT_EQ(secondHandle->GetMaterials().Size(), initialMaterialCount); + EXPECT_EQ(secondHandle->GetTextures().Size(), 0u); + EXPECT_EQ(GetFirstSectionMaterialIndex(*secondHandle.Get()), firstSectionMaterialIndex); + Material* secondMaterial = GetFirstSectionMaterial(*secondHandle.Get()); + ASSERT_NE(secondMaterial, nullptr); + ASSERT_NE(secondMaterial->GetShader(), nullptr); + EXPECT_EQ(secondMaterial->GetShader()->GetPath(), GetBuiltinForwardLitShaderPath()); + EXPECT_EQ(secondMaterial->GetTextureBindingCount(), 1u); + EXPECT_EQ(secondMaterial->GetTextureBindingName(0), "_MainTex"); + EXPECT_FALSE(secondMaterial->GetTextureBindingPath(0).Empty()); + const ResourceHandle secondLazyTexture = secondMaterial->GetTexture("_MainTex"); + EXPECT_FALSE(secondLazyTexture.IsValid()); + EXPECT_GT(manager.GetAsyncPendingCount(), 0u); + ASSERT_TRUE(PumpAsyncLoadsUntilIdle(manager)); + const ResourceHandle secondResolvedTexture = secondMaterial->GetTexture("_MainTex"); + ASSERT_TRUE(secondResolvedTexture.IsValid()); + EXPECT_EQ(secondResolvedTexture->GetWidth(), 2u); + EXPECT_EQ(secondResolvedTexture->GetHeight(), 2u); + } manager.SetResourceRoot(""); manager.Shutdown(); diff --git a/tests/Resources/Model/CMakeLists.txt b/tests/Resources/Model/CMakeLists.txt new file mode 100644 index 00000000..33f41df4 --- /dev/null +++ b/tests/Resources/Model/CMakeLists.txt @@ -0,0 +1,42 @@ +# ============================================================ +# Model Tests +# ============================================================ + +set(MODEL_TEST_SOURCES + test_model.cpp + test_model_loader.cpp + test_model_import_pipeline.cpp +) + +add_executable(model_tests ${MODEL_TEST_SOURCES}) + +if(MSVC) + set_target_properties(model_tests PROPERTIES + LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib" + ) +endif() + +target_link_libraries(model_tests + PRIVATE + XCEngine + GTest::gtest + GTest::gtest_main +) + +target_include_directories(model_tests PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include + ${CMAKE_SOURCE_DIR}/tests/Fixtures +) + +target_compile_definitions(model_tests PRIVATE + XCENGINE_TEST_FIXTURES_DIR="${CMAKE_SOURCE_DIR}/tests/Fixtures" +) + +add_custom_command(TARGET model_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/engine/third_party/assimp/bin/assimp-vc143-mt.dll + $/assimp-vc143-mt.dll +) + +include(GoogleTest) +gtest_discover_tests(model_tests) diff --git a/tests/Resources/Model/test_model.cpp b/tests/Resources/Model/test_model.cpp new file mode 100644 index 00000000..06a979f9 --- /dev/null +++ b/tests/Resources/Model/test_model.cpp @@ -0,0 +1,97 @@ +#include + +#include + +using namespace XCEngine::Resources; +using namespace XCEngine::Math; + +namespace { + +TEST(Model, DefaultConstructor) { + Model model; + EXPECT_EQ(model.GetType(), ResourceType::Model); + EXPECT_FALSE(model.HasRootNode()); + EXPECT_EQ(model.GetRootNodeIndex(), kInvalidModelNodeIndex); + EXPECT_TRUE(model.GetNodes().Empty()); + EXPECT_TRUE(model.GetMeshBindings().Empty()); + EXPECT_TRUE(model.GetMaterialBindings().Empty()); + EXPECT_EQ(model.GetMemorySize(), 0u); +} + +TEST(Model, AddGraphDataUpdatesState) { + Model model; + + IResource::ConstructParams params; + params.name = "robot.fbx"; + params.path = "Library/Artifacts/aa/main.xcmodel"; + params.guid = ResourceGUID::Generate(params.path); + model.Initialize(params); + + model.SetRootNodeIndex(0u); + + ModelNode rootNode; + rootNode.name = "Root"; + rootNode.parentIndex = -1; + rootNode.meshBindingStart = 0u; + rootNode.meshBindingCount = 1u; + rootNode.localPosition = Vector3(1.0f, 2.0f, 3.0f); + rootNode.localScale = Vector3(1.0f, 1.5f, 2.0f); + model.AddNode(rootNode); + + ModelMeshBinding meshBinding; + meshBinding.meshLocalID = 11u; + meshBinding.materialBindingStart = 0u; + meshBinding.materialBindingCount = 2u; + model.AddMeshBinding(meshBinding); + + ModelMaterialBinding materialBinding0; + materialBinding0.slotIndex = 0u; + materialBinding0.materialLocalID = 21u; + model.AddMaterialBinding(materialBinding0); + + ModelMaterialBinding materialBinding1; + materialBinding1.slotIndex = 1u; + materialBinding1.materialLocalID = 22u; + model.AddMaterialBinding(materialBinding1); + + ASSERT_TRUE(model.HasRootNode()); + EXPECT_EQ(model.GetRootNodeIndex(), 0u); + ASSERT_EQ(model.GetNodes().Size(), 1u); + EXPECT_EQ(model.GetNodes()[0].name, "Root"); + EXPECT_EQ(model.GetNodes()[0].localPosition, Vector3(1.0f, 2.0f, 3.0f)); + EXPECT_EQ(model.GetNodes()[0].localScale, Vector3(1.0f, 1.5f, 2.0f)); + ASSERT_EQ(model.GetMeshBindings().Size(), 1u); + EXPECT_EQ(model.GetMeshBindings()[0].meshLocalID, 11u); + ASSERT_EQ(model.GetMaterialBindings().Size(), 2u); + EXPECT_EQ(model.GetMaterialBindings()[1].materialLocalID, 22u); + EXPECT_GT(model.GetMemorySize(), 0u); +} + +TEST(Model, ReleaseClearsGraphData) { + Model model; + + IResource::ConstructParams params; + params.name = "robot.fbx"; + params.path = "Library/Artifacts/aa/main.xcmodel"; + params.guid = ResourceGUID::Generate(params.path); + model.Initialize(params); + + model.SetRootNodeIndex(0u); + + ModelNode node; + node.name = "Root"; + model.AddNode(node); + model.AddMeshBinding(ModelMeshBinding{ 3u, 0u, 0u }); + model.AddMaterialBinding(ModelMaterialBinding{ 0u, 7u }); + + model.Release(); + + EXPECT_FALSE(model.IsValid()); + EXPECT_FALSE(model.HasRootNode()); + EXPECT_TRUE(model.GetNodes().Empty()); + EXPECT_TRUE(model.GetMeshBindings().Empty()); + EXPECT_TRUE(model.GetMaterialBindings().Empty()); + EXPECT_EQ(model.GetMemorySize(), 0u); +} + +} // namespace diff --git a/tests/Resources/Model/test_model_import_pipeline.cpp b/tests/Resources/Model/test_model_import_pipeline.cpp new file mode 100644 index 00000000..7e7cf43d --- /dev/null +++ b/tests/Resources/Model/test_model_import_pipeline.cpp @@ -0,0 +1,185 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +using namespace XCEngine::Resources; + +namespace { + +std::string GetMeshFixturePath(const char* fileName) { + return (std::filesystem::path(XCENGINE_TEST_FIXTURES_DIR) / "Resources" / "Mesh" / fileName).string(); +} + +std::filesystem::path GetRepositoryRoot() { + return std::filesystem::path(__FILE__).parent_path().parent_path().parent_path().parent_path(); +} + +void CopyTexturedTriangleFixture(const std::filesystem::path& assetsDir) { + namespace fs = std::filesystem; + + fs::copy_file( + GetMeshFixturePath("textured_triangle.obj"), + assetsDir / "textured_triangle.obj", + fs::copy_options::overwrite_existing); + fs::copy_file( + GetMeshFixturePath("textured_triangle.mtl"), + assetsDir / "textured_triangle.mtl", + fs::copy_options::overwrite_existing); + fs::copy_file( + GetMeshFixturePath("checker.bmp"), + assetsDir / "checker.bmp", + fs::copy_options::overwrite_existing); +} + +#ifdef _WIN32 +struct AssimpDllGuard { + HMODULE module = nullptr; + + ~AssimpDllGuard() { + if (module != nullptr) { + FreeLibrary(module); + } + } +}; +#endif + +TEST(ModelImportPipeline, AssetDatabaseImportsObjAsModelArtifact) { + namespace fs = std::filesystem; + + const fs::path projectRoot = fs::temp_directory_path() / "xc_model_import_pipeline_asset_database"; + const fs::path assetsDir = projectRoot / "Assets"; + + fs::remove_all(projectRoot); + fs::create_directories(assetsDir); + CopyTexturedTriangleFixture(assetsDir); + +#ifdef _WIN32 + AssimpDllGuard dllGuard; + const fs::path assimpDllPath = GetRepositoryRoot() / "engine" / "third_party" / "assimp" / "bin" / "assimp-vc143-mt.dll"; + ASSERT_TRUE(fs::exists(assimpDllPath)); + dllGuard.module = LoadLibraryW(assimpDllPath.wstring().c_str()); + ASSERT_NE(dllGuard.module, nullptr); +#endif + + AssetDatabase database; + database.Initialize(projectRoot.string().c_str()); + + ResourceType importType = ResourceType::Unknown; + ASSERT_TRUE(database.TryGetImportableResourceType("Assets/textured_triangle.obj", importType)); + EXPECT_EQ(importType, ResourceType::Model); + + AssetDatabase::ResolvedAsset resolvedAsset; + ASSERT_TRUE(database.EnsureArtifact("Assets/textured_triangle.obj", ResourceType::Model, resolvedAsset)); + ASSERT_TRUE(resolvedAsset.artifactReady); + EXPECT_EQ(fs::path(resolvedAsset.artifactMainPath.CStr()).filename().string(), "main.xcmodel"); + EXPECT_TRUE(fs::exists(resolvedAsset.artifactMainPath.CStr())); + EXPECT_TRUE(fs::exists(fs::path(resolvedAsset.artifactDirectory.CStr()) / "mesh_0.xcmesh")); + EXPECT_TRUE(fs::exists(fs::path(resolvedAsset.artifactDirectory.CStr()) / "material_0.xcmat")); + EXPECT_TRUE(fs::exists(fs::path(resolvedAsset.artifactDirectory.CStr()) / "texture_0.xctex")); + + ModelLoader modelLoader; + const LoadResult modelResult = modelLoader.Load(resolvedAsset.artifactMainPath); + ASSERT_TRUE(modelResult); + ASSERT_NE(modelResult.resource, nullptr); + + auto* model = static_cast(modelResult.resource); + ASSERT_NE(model, nullptr); + EXPECT_TRUE(model->HasRootNode()); + EXPECT_GE(model->GetNodes().Size(), 1u); + EXPECT_EQ(model->GetRootNodeIndex(), 0u); + EXPECT_EQ(model->GetMeshBindings().Size(), 1u); + EXPECT_EQ(model->GetMaterialBindings().Size(), 1u); + EXPECT_NE(model->GetMeshBindings()[0].meshLocalID, kInvalidLocalID); + EXPECT_NE(model->GetMaterialBindings()[0].materialLocalID, kInvalidLocalID); + delete model; + + MeshLoader meshLoader; + const LoadResult meshResult = + meshLoader.Load((fs::path(resolvedAsset.artifactDirectory.CStr()) / "mesh_0.xcmesh").string().c_str()); + ASSERT_TRUE(meshResult); + ASSERT_NE(meshResult.resource, nullptr); + + auto* mesh = static_cast(meshResult.resource); + ASSERT_NE(mesh, nullptr); + EXPECT_EQ(mesh->GetVertexCount(), 3u); + EXPECT_EQ(mesh->GetIndexCount(), 3u); + EXPECT_EQ(mesh->GetSections().Size(), 1u); + EXPECT_EQ(mesh->GetMaterials().Size(), 1u); + EXPECT_NE(mesh->GetMaterial(0), nullptr); + delete mesh; + + database.Shutdown(); + fs::remove_all(projectRoot); +} + +TEST(ModelImportPipeline, ResourceManagerLoadsModelFromProjectAsset) { + namespace fs = std::filesystem; + + const fs::path projectRoot = fs::temp_directory_path() / "xc_model_import_pipeline_resource_manager"; + const fs::path assetsDir = projectRoot / "Assets"; + + fs::remove_all(projectRoot); + fs::create_directories(assetsDir); + CopyTexturedTriangleFixture(assetsDir); + +#ifdef _WIN32 + AssimpDllGuard dllGuard; + const fs::path assimpDllPath = GetRepositoryRoot() / "engine" / "third_party" / "assimp" / "bin" / "assimp-vc143-mt.dll"; + ASSERT_TRUE(fs::exists(assimpDllPath)); + dllGuard.module = LoadLibraryW(assimpDllPath.wstring().c_str()); + ASSERT_NE(dllGuard.module, nullptr); +#endif + + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + manager.SetResourceRoot(projectRoot.string().c_str()); + + { + const auto modelHandle = manager.Load("Assets/textured_triangle.obj"); + ASSERT_TRUE(modelHandle.IsValid()); + EXPECT_EQ(modelHandle->GetPath(), "Assets/textured_triangle.obj"); + EXPECT_TRUE(modelHandle->HasRootNode()); + EXPECT_EQ(modelHandle->GetMeshBindings().Size(), 1u); + EXPECT_EQ(modelHandle->GetMaterialBindings().Size(), 1u); + } + + { + const auto meshHandle = manager.Load("Assets/textured_triangle.obj"); + ASSERT_TRUE(meshHandle.IsValid()); + EXPECT_EQ(meshHandle->GetVertexCount(), 3u); + EXPECT_EQ(meshHandle->GetIndexCount(), 3u); + } + + AssetRef modelAssetRef; + ASSERT_TRUE(manager.TryGetAssetRef("Assets/textured_triangle.obj", ResourceType::Model, modelAssetRef)); + EXPECT_TRUE(modelAssetRef.IsValid()); + + manager.UnloadAll(); + + { + const auto modelHandle = manager.Load(modelAssetRef); + ASSERT_TRUE(modelHandle.IsValid()); + EXPECT_EQ(modelHandle->GetMeshBindings().Size(), 1u); + EXPECT_EQ(modelHandle->GetMaterialBindings().Size(), 1u); + } + + manager.SetResourceRoot(""); + manager.Shutdown(); + fs::remove_all(projectRoot); +} + +} // namespace diff --git a/tests/Resources/Model/test_model_loader.cpp b/tests/Resources/Model/test_model_loader.cpp new file mode 100644 index 00000000..faf6ddc6 --- /dev/null +++ b/tests/Resources/Model/test_model_loader.cpp @@ -0,0 +1,122 @@ +#include + +#include +#include +#include +#include + +#include + +using namespace XCEngine::Resources; +using namespace XCEngine::Math; + +namespace { + +Model BuildSampleModel(const char* artifactPath) { + Model model; + + IResource::ConstructParams params; + params.name = "robot.fbx"; + params.path = artifactPath; + params.guid = ResourceGUID::Generate(params.path); + model.Initialize(params); + + model.SetRootNodeIndex(0u); + + ModelNode rootNode; + rootNode.name = "Root"; + rootNode.parentIndex = -1; + rootNode.meshBindingStart = 0u; + rootNode.meshBindingCount = 1u; + rootNode.localPosition = Vector3(0.0f, 1.0f, 2.0f); + rootNode.localRotation = Quaternion::FromEulerAngles(0.1f, 0.2f, 0.3f); + rootNode.localScale = Vector3(1.0f, 1.0f, 1.0f); + model.AddNode(rootNode); + + ModelNode childNode; + childNode.name = "Body"; + childNode.parentIndex = 0; + childNode.meshBindingStart = 1u; + childNode.meshBindingCount = 1u; + childNode.localPosition = Vector3(3.0f, 4.0f, 5.0f); + childNode.localRotation = Quaternion::Identity(); + childNode.localScale = Vector3(0.5f, 0.5f, 0.5f); + model.AddNode(childNode); + + model.AddMeshBinding(ModelMeshBinding{ 101u, 0u, 2u }); + model.AddMeshBinding(ModelMeshBinding{ 102u, 2u, 1u }); + + model.AddMaterialBinding(ModelMaterialBinding{ 0u, 201u }); + model.AddMaterialBinding(ModelMaterialBinding{ 1u, 202u }); + model.AddMaterialBinding(ModelMaterialBinding{ 0u, 203u }); + + return model; +} + +TEST(ModelLoader, GetResourceType) { + ModelLoader loader; + EXPECT_EQ(loader.GetResourceType(), ResourceType::Model); +} + +TEST(ModelLoader, CanLoad) { + ModelLoader loader; + EXPECT_TRUE(loader.CanLoad("test.xcmodel")); + EXPECT_TRUE(loader.CanLoad("test.XCMODEL")); + EXPECT_FALSE(loader.CanLoad("test.fbx")); + EXPECT_FALSE(loader.CanLoad("test.txt")); +} + +TEST(ModelLoader, LoadInvalidPath) { + ModelLoader loader; + const LoadResult result = loader.Load("invalid/path/model.xcmodel"); + EXPECT_FALSE(result); +} + +TEST(ModelLoader, WritesAndLoadsModelArtifact) { + namespace fs = std::filesystem; + + const fs::path tempDir = fs::temp_directory_path() / "xc_model_artifact_test"; + const fs::path artifactPath = tempDir / "sample.xcmodel"; + + fs::remove_all(tempDir); + fs::create_directories(tempDir); + + const Model sourceModel = BuildSampleModel(artifactPath.string().c_str()); + XCEngine::Containers::String errorMessage; + ASSERT_TRUE(WriteModelArtifactFile(artifactPath.string().c_str(), sourceModel, &errorMessage)) + << errorMessage.CStr(); + + ModelLoader loader; + const LoadResult result = loader.Load(artifactPath.string().c_str()); + ASSERT_TRUE(result); + ASSERT_NE(result.resource, nullptr); + + auto* model = static_cast(result.resource); + ASSERT_NE(model, nullptr); + ASSERT_TRUE(model->HasRootNode()); + EXPECT_EQ(model->GetRootNodeIndex(), 0u); + ASSERT_EQ(model->GetNodes().Size(), 2u); + EXPECT_EQ(model->GetNodes()[0].name, "Root"); + EXPECT_EQ(model->GetNodes()[1].name, "Body"); + EXPECT_EQ(model->GetNodes()[1].parentIndex, 0); + EXPECT_EQ(model->GetNodes()[1].localPosition, Vector3(3.0f, 4.0f, 5.0f)); + ASSERT_EQ(model->GetMeshBindings().Size(), 2u); + EXPECT_EQ(model->GetMeshBindings()[0].meshLocalID, 101u); + EXPECT_EQ(model->GetMeshBindings()[1].materialBindingCount, 1u); + ASSERT_EQ(model->GetMaterialBindings().Size(), 3u); + EXPECT_EQ(model->GetMaterialBindings()[1].materialLocalID, 202u); + + delete model; + fs::remove_all(tempDir); +} + +TEST(ModelLoader, ResourceManagerRegistersModelLoader) { + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + + EXPECT_NE(manager.GetLoader(ResourceType::Model), nullptr); + + manager.Shutdown(); +} + +} // namespace