Add Nahida model import and preview pipeline

This commit is contained in:
2026-04-11 20:16:49 +08:00
parent 8f71f99de4
commit 030230eb1f
87 changed files with 7245 additions and 117 deletions

View File

@@ -0,0 +1,174 @@
# Nahida Unity式Model导入与Genshin卡通渲染正式化计划
日期2026-04-11
## 1. 目标
这一轮不再按“静态拆件预览”推进,而是正式切到更接近 Unity 的方案:
- FBX/OBJ 等外部模型统一作为 `Model` 主资产导入
- 场景侧通过 `Model -> GameObject hierarchy` 实例化恢复节点层级
- `Mesh` 继续只负责底层几何与 section/material slot
- Nahida 的卡通渲染改为“对齐 Unity 旧工程 `Genshin.shader` 语义”,而不是继续维持近似版 shader
本轮仍然明确不做:
- 骨骼动画
- GPU skinning
- `SkinnedMeshRenderer` 运行时
- BlendShape
- Animator / AnimationClip 播放系统
## 2. 当前判断
### 2.1 Model链路已经具备的能力
代码里已经有以下基础设施:
- `ResourceType::Model`
- `Model` / `ModelLoader`
- `xcmodel` artifact
- `AssimpModelImporter`
- `AssetDatabase``ModelImporter``Model` 为主资产导入
- sub-asset manifest 与 `LocalID -> artifact path` 解析
这说明“Unity式 Model 主资产”不是从零开始,而是缺最后几段关键闭环。
### 2.2 当前最主要的缺口
真正还没闭环的是:
1. `Model` 还不能正式实例化成场景层级
2. `MeshFilter/MeshRenderer` 还缺稳定的 sub-asset `AssetRef` 绑定入口
3. 编辑器侧还没有把模型资产当成“可实例化层级对象”来用
4. Nahida 当前 `XCCharacterToon.shader` 不是 Unity 旧工程 `Genshin.shader` 的等价移植
5. FBX 导入后的静态 mesh 目前丢失了 Unity shader 依赖的顶点语义,尤其是 `vertex color``UV1/backUV`
### 2.3 关于当前 shader 的明确结论
当前使用的是:
- `project/Assets/Shaders/XCCharacterToon.shader`
Unity 参考原件是:
- `docs/reference/NahidaUnity/Shaders/Genshin.shader`
- `docs/reference/NahidaUnity/Shaders/GenshinInput.hlsl`
- `docs/reference/NahidaUnity/Shaders/GenshinForwardPass.hlsl`
- `docs/reference/NahidaUnity/Shaders/GenshinOutlinePass.hlsl`
当前 shader 只是第一版近似实现,不是原 shader 原样移植。当前效果发怪,核心原因不是简单调参,而是语义缺口:
1. 没有按 Unity 的 `_IS_FACE / _SPECULAR / _RIM / _NORMAL_MAP / _DOUBLE_SIDED` 关键字分支工作
2. 面部阴影没有按 `_FaceDirection + _FaceLightMap + _FaceShadow + _FaceShadowOffset` 的逻辑算
3. 阴影 ramp 还没有按 Unity 的 material ID 分层抽样
4. specular 还不是 Unity 那套 `lightMap + metalMap + matcap` 路径
5. rim 不是基于 scene depth 的 URP 风格边缘检测
6. outline pass 还没有正式接回 `Nahida_Body_Smooth.mesh` 与 outline color 分档逻辑
7. Unity 里还有 `MaterialUpdater.cs` 在运行时写 `_FaceDirection`,当前引擎里这条驱动链不存在
8. 更关键的是,当前引擎导入出的 Nahida mesh 只有 `Position/Normal/Tangent/Bitangent/UV0`,缺 `Color/UV1`
9. Unity 的 `GenshinForwardPass.hlsl` 明确依赖 `input.color.r``backUV`
10. 这会直接破坏 body/hair 分支的 `aoFactor = lightMap.g * input.color.r` 与双面材质背面采样,因此“脸基本对、身体和头发大面积偏色”是符合代码现状的结果
所以它现在看起来不像原工程,是正常结果。
## 3. 本轮正式范围
### Phase 1Model实例化基础链路
目标:
-`Model` 正式实例化为场景 `GameObject` 层级
任务:
-`MeshFilterComponent` 增加 mesh sub-asset `AssetRef` 绑定入口
-`MeshRendererComponent` 增加 material sub-asset `AssetRef` 绑定入口
- 保证这些 sub-asset 引用能被场景序列化稳定保存
- 新增 `Model -> Scene hierarchy` 实例化工具
- 恢复节点本地 TRS
- 恢复 mesh binding 与 material slot binding
验收:
- 导入一个 `OBJ/FBX Model` 后,可以在场景里创建出层级对象
- 场景存盘后 sub-asset 引用不丢
- 不需要把 FBX 拆成手工摆放的多个静态对象
### Phase 2编辑器侧 Model使用工作流
目标:
- 让编辑器把 `Model` 当成 Unity 式模型资产使用
任务:
- 增加“从模型资产创建场景对象”的命令入口
- 后续接到 Project/Hierarchy/Viewport 的拖拽放置链路
- 让 Nahida 预览场景切到 `Model` 驱动的实例化路径
验收:
- 编辑器里不再主要依赖“手工往 `MeshFilter` 塞 FBX 路径”
- Nahida 预览对象结构来自 `Model` 实例化,而不是手工拆件
### Phase 3Genshin shader 语义对齐
目标:
- 让 Nahida 使用更接近 Unity 旧工程的卡通渲染语义
任务:
- 先补齐静态 mesh 顶点语义链路:`Color``UV1/backUV`
- 让前向渲染输入布局与 `XCCharacterToon.shader` 能真正接到 `COLOR``TEXCOORD1`
- 恢复 body/hair 分支里的 `lightMap.g * input.color.r`
- 恢复双面材质对 `backUV` 的切换采样
- 按 Unity 原 shader 拆分 forward / outline 两条主路径
-`_IS_FACE / _SPECULAR / _RIM / _NORMAL_MAP / _DOUBLE_SIDED` 语义
- 补 face shadow 正式逻辑
- 补 outline pass 与 `Nahida_Body_Smooth.mesh`
- 视情况补一个轻量 `_FaceDirection` 运行时驱动
验收:
- 角色明暗分层、面部阴影、描边、头发高光接近 Unity 参考
## 4. 执行顺序
这一轮按下面顺序推进:
1. 先补 `Model` 实例化与 sub-asset 引用闭环
2. 再把 Nahida 预览场景切到 `Model` 路径
3. 再补 FBX 静态 mesh 的 `Color/UV1` 导入、artifact 保存、渲染输入布局与 shader 接线
4. 再做 shader 语义对齐
5. 最后做画面调参与残余语义收口
不能反过来做。因为当前 shader 再怎么调,只要场景组织和资源绑定语义不对,结果都不稳定。
## 5. 当前这一刀
这一轮立刻执行的第一刀是:
- 补齐 FBX 静态 mesh 的 `vertex color``UV1/backUV` 导入
-`BuiltinForwardPipeline``XCCharacterToon.shader` 真正吃到 `COLOR/TEXCOORD1`
- 补 Nahida 集成诊断与回归验证,先把 body/hair 的基础色输入纠正
- 在这个基础上继续向 Unity 式 `Model -> hierarchy` 与完整 shader 语义收口推进
这是 Nahida 当前从“基础贴图都不稳定”切回“先保证静态基础颜色正确”的前置条件,也是后续继续 Unity 式正式方案的必要补丁。
## 6. 2026-04-11 新发现补充
在实际跑 `tests/Rendering/integration/nahida_preview_scene` 之后,问题又进一步收窄了:
- Nahida 的 `Body_Mesh0` 运行时已经有 `uv1` 数值,且当前实现会在缺少第二套 UV 时回填 `uv1 = uv0`
- 但导入器只在 `mesh.HasTextureCoords(1)` 为真时才打 `VertexAttribute::UV1` 标记
- 结果就是:数据层面有 fallback `uv1`,语义层面却仍被当成“没有 UV1”
- 这会导致后续依赖 `backUV` 的 shader / 诊断逻辑继续走错分支
因此当前这一刀的执行重点再精确一步,变成:
1. 修复 `AssimpModelImporter` / `MeshLoader` 中“fallback uv1 已写入但 `UV1` flag 未置位”的不一致
2. 提升 `ModelImporter` 版本,强制 Nahida 现有 `.xcmodel` artifact 重导
3. 补针对 Nahida FBX 的导入回归测试,确保 `UV1` fallback 与 `Color` 语义不会再次退化
4. 再继续跑 Nahida 集成图验证 body/hair 基础色是否进一步收敛

View File

@@ -325,6 +325,7 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/AssetGUID.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/AssetRef.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/ArtifactFormats.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/ArtifactContainer.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/AssetDatabase.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/AssetImportService.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/ProjectAssetIndex.h
@@ -336,6 +337,7 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/AsyncLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Core/Asset/ResourceDependencyGraph.h
${CMAKE_CURRENT_SOURCE_DIR}/src/Core/Asset/AssetGUID.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Core/Asset/ArtifactContainer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Core/Asset/AssetDatabase.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Core/Asset/AssetImportService.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Core/Asset/ProjectAssetIndex.cpp
@@ -543,10 +545,12 @@ add_library(XCEngine STATIC
# Scene
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/Scene.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/ModelSceneInstantiation.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/SceneRuntime.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/RuntimeLoop.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Scene/SceneManager.h
${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/Scene.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/ModelSceneInstantiation.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/SceneRuntime.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/RuntimeLoop.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Scene/SceneManager.cpp

View File

@@ -22,6 +22,7 @@ public:
const Resources::AssetRef& GetMeshAssetRef() const { return m_meshRef; }
void SetMeshPath(const std::string& meshPath);
void SetMeshAssetRef(const Resources::AssetRef& meshRef);
void SetMesh(const Resources::ResourceHandle<Resources::Mesh>& mesh);
void SetMesh(Resources::Mesh* mesh);
void ClearMesh();

View File

@@ -25,6 +25,7 @@ public:
const std::vector<Resources::AssetRef>& GetMaterialAssetRefs() const { return m_materialRefs; }
void SetMaterialPath(size_t index, const std::string& materialPath);
void SetMaterialAssetRef(size_t index, const Resources::AssetRef& materialRef);
void SetMaterial(size_t index, const Resources::ResourceHandle<Resources::Material>& material);
void SetMaterial(size_t index, Resources::Material* material);
void SetMaterials(const std::vector<Resources::ResourceHandle<Resources::Material>>& materials);

View File

@@ -0,0 +1,105 @@
#pragma once
#include <XCEngine/Core/Asset/AssetGUID.h>
#include <XCEngine/Core/Asset/ResourceTypes.h>
#include <XCEngine/Core/Containers/Array.h>
#include <XCEngine/Core/Containers/String.h>
#include <XCEngine/Core/Types.h>
namespace XCEngine {
namespace Resources {
constexpr Core::uint32 kArtifactContainerSchemaVersion = 1;
enum class ArtifactContainerCompression : Core::uint32 {
None = 0
};
struct ArtifactContainerEntry {
Containers::String name;
ResourceType resourceType = ResourceType::Unknown;
LocalID localID = kInvalidLocalID;
Core::uint32 flags = 0;
ArtifactContainerCompression compression = ArtifactContainerCompression::None;
Containers::Array<Core::uint8> payload;
};
struct ArtifactContainerEntryView {
Containers::String name;
ResourceType resourceType = ResourceType::Unknown;
LocalID localID = kInvalidLocalID;
Core::uint32 flags = 0;
ArtifactContainerCompression compression = ArtifactContainerCompression::None;
Core::uint64 payloadOffset = 0;
Core::uint64 payloadSize = 0;
};
class ArtifactContainerWriter {
public:
void Clear();
void AddEntry(const ArtifactContainerEntry& entry);
void AddEntry(ArtifactContainerEntry&& entry);
const Containers::Array<ArtifactContainerEntry>& GetEntries() const { return m_entries; }
bool WriteToFile(const Containers::String& path,
Containers::String* outErrorMessage = nullptr) const;
private:
Containers::Array<ArtifactContainerEntry> m_entries;
};
class ArtifactContainerReader {
public:
bool Open(const Containers::String& path,
Containers::String* outErrorMessage = nullptr);
void Close();
bool IsOpen() const { return !m_path.Empty(); }
const Containers::String& GetPath() const { return m_path; }
const Containers::Array<ArtifactContainerEntryView>& GetEntries() const { return m_entries; }
Core::uint32 GetEntryCount() const { return static_cast<Core::uint32>(m_entries.Size()); }
const ArtifactContainerEntryView* FindEntryByName(const Containers::String& name) const;
const ArtifactContainerEntryView* FindEntry(ResourceType resourceType,
LocalID localID) const;
bool ReadEntryPayload(const ArtifactContainerEntryView& entry,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage = nullptr) const;
bool ReadEntryPayload(const Containers::String& name,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage = nullptr) const;
private:
Containers::String m_path;
Containers::Array<ArtifactContainerEntryView> m_entries;
Core::uint64 m_payloadStart = 0;
Core::uint64 m_payloadSize = 0;
};
bool WriteArtifactContainer(const Containers::String& path,
const Containers::Array<ArtifactContainerEntry>& entries,
Containers::String* outErrorMessage = nullptr);
bool IsArtifactContainerFile(const Containers::String& path);
Containers::String BuildArtifactContainerEntryPath(const Containers::String& containerPath,
const Containers::String& entryName);
bool TryParseArtifactContainerEntryPath(const Containers::String& path,
Containers::String& outContainerPath,
Containers::String& outEntryName);
bool ReadArtifactContainerEntryPayload(const Containers::String& containerPath,
const Containers::String& entryName,
ResourceType expectedType,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage = nullptr);
bool ReadArtifactContainerPayloadByPath(const Containers::String& path,
ResourceType expectedType,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage = nullptr);
bool ReadArtifactContainerMainEntryPayload(const Containers::String& path,
ResourceType expectedType,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage = nullptr);
} // namespace Resources
} // namespace XCEngine

View File

@@ -175,6 +175,7 @@ private:
Core::uint64 materialVersion = 0;
RHI::RHIResourceView* baseColorTextureView = nullptr;
RHI::RHIResourceView* shadowMapTextureView = nullptr;
std::vector<RHI::RHIResourceView*> materialTextureViews;
};
struct ResolvedShaderPass {
@@ -305,6 +306,9 @@ private:
const Resources::Texture* ResolveTexture(const Resources::Material* material) const;
RHI::RHIResourceView* ResolveTextureView(const Resources::Texture* texture);
RHI::RHIResourceView* ResolveTextureView(const VisibleRenderItem& visibleItem);
RHI::RHIResourceView* ResolveMaterialTextureView(
const Resources::Material* material,
const BuiltinPassResourceBindingDesc& binding);
static LightingConstants BuildLightingConstants(const RenderLightingData& lightingData);
static AdditionalLightConstants BuildAdditionalLightConstants(const RenderAdditionalLightData& lightData);
bool HasSkybox(const RenderSceneData& sceneData) const;

View File

@@ -5,6 +5,7 @@
#include <XCEngine/Core/Math/Bounds.h>
#include <XCEngine/Core/Math/Vector2.h>
#include <XCEngine/Core/Math/Vector3.h>
#include <XCEngine/Core/Math/Vector4.h>
#include <XCEngine/Core/Types.h>
namespace XCEngine {
@@ -38,6 +39,8 @@ struct StaticMeshVertex {
Math::Vector3 tangent = Math::Vector3::Zero();
Math::Vector3 bitangent = Math::Vector3::Zero();
Math::Vector2 uv0 = Math::Vector2::Zero();
Math::Vector2 uv1 = Math::Vector2::Zero();
Math::Vector4 color = Math::Vector4::One();
};
struct MeshSection {

View File

@@ -0,0 +1,37 @@
#pragma once
#include <XCEngine/Core/Asset/AssetRef.h>
#include <XCEngine/Core/Containers/String.h>
#include <XCEngine/Resources/Model/Model.h>
#include <vector>
namespace XCEngine {
namespace Components {
class GameObject;
class Scene;
}
struct ModelSceneInstantiationResult {
Components::GameObject* rootObject = nullptr;
std::vector<Components::GameObject*> nodeObjects;
std::vector<Components::GameObject*> meshObjects;
};
bool InstantiateModelHierarchy(
Components::Scene& scene,
const Resources::Model& model,
const Resources::AssetRef& modelAssetRef,
Components::GameObject* parent = nullptr,
ModelSceneInstantiationResult* outResult = nullptr,
Containers::String* outErrorMessage = nullptr);
bool InstantiateModelHierarchy(
Components::Scene& scene,
const Containers::String& modelPath,
Components::GameObject* parent = nullptr,
ModelSceneInstantiationResult* outResult = nullptr,
Containers::String* outErrorMessage = nullptr);
} // namespace XCEngine

View File

@@ -106,6 +106,31 @@ void MeshFilterComponent::SetMeshPath(const std::string& meshPath) {
}
}
void MeshFilterComponent::SetMeshAssetRef(const Resources::AssetRef& meshRef) {
m_pendingMeshLoad.reset();
m_asyncMeshLoadRequested = false;
m_mesh.Reset();
m_meshPath.clear();
m_meshRef = meshRef;
if (!m_meshRef.IsValid()) {
return;
}
Containers::String resolvedPath;
if (Resources::ResourceManager::Get().TryResolveAssetPath(m_meshRef, resolvedPath)) {
m_meshPath = ToStdString(resolvedPath);
}
if (Resources::ResourceManager::Get().IsDeferredSceneLoadEnabled()) {
return;
}
m_mesh = Resources::ResourceManager::Get().Load<Resources::Mesh>(m_meshRef);
if (m_mesh.Get() != nullptr && m_meshPath.empty()) {
m_meshPath = ToStdString(m_mesh->GetPath());
}
}
void MeshFilterComponent::SetMesh(const Resources::ResourceHandle<Resources::Mesh>& mesh) {
m_pendingMeshLoad.reset();
m_asyncMeshLoadRequested = false;
@@ -271,7 +296,8 @@ void MeshFilterComponent::ResolvePendingMesh() {
}
m_meshPath = ToStdString(m_mesh->GetPath());
if (!Resources::ResourceManager::Get().TryGetAssetRef(m_meshPath.c_str(), Resources::ResourceType::Mesh, m_meshRef)) {
if (!m_meshRef.IsValid() &&
!Resources::ResourceManager::Get().TryGetAssetRef(m_meshPath.c_str(), Resources::ResourceType::Mesh, m_meshRef)) {
m_meshRef.Reset();
}

View File

@@ -193,6 +193,32 @@ void MeshRendererComponent::SetMaterialPath(size_t index, const std::string& mat
}
}
void MeshRendererComponent::SetMaterialAssetRef(size_t index, const Resources::AssetRef& materialRef) {
EnsureMaterialSlot(index);
m_pendingMaterialLoads[index].reset();
m_asyncMaterialLoadRequested[index] = false;
m_materials[index].Reset();
m_materialPaths[index].clear();
m_materialRefs[index] = materialRef;
if (!m_materialRefs[index].IsValid()) {
return;
}
Containers::String resolvedPath;
if (Resources::ResourceManager::Get().TryResolveAssetPath(m_materialRefs[index], resolvedPath)) {
m_materialPaths[index] = ToStdString(resolvedPath);
}
if (Resources::ResourceManager::Get().IsDeferredSceneLoadEnabled()) {
return;
}
m_materials[index] = Resources::ResourceManager::Get().Load<Resources::Material>(m_materialRefs[index]);
if (m_materials[index].Get() != nullptr && m_materialPaths[index].empty()) {
m_materialPaths[index] = MaterialPathFromHandle(m_materials[index]);
}
}
void MeshRendererComponent::SetMaterial(size_t index, const Resources::ResourceHandle<Resources::Material>& material) {
EnsureMaterialSlot(index);
m_pendingMaterialLoads[index].reset();
@@ -445,7 +471,8 @@ void MeshRendererComponent::ResolvePendingMaterials() {
}
m_materialPaths[index] = MaterialPathFromHandle(m_materials[index]);
if (!Resources::ResourceManager::Get().TryGetAssetRef(m_materialPaths[index].c_str(),
if (!m_materialRefs[index].IsValid() &&
!Resources::ResourceManager::Get().TryGetAssetRef(m_materialPaths[index].c_str(),
Resources::ResourceType::Material,
m_materialRefs[index])) {
m_materialRefs[index].Reset();

View File

@@ -0,0 +1,571 @@
#include <XCEngine/Core/Asset/ArtifactContainer.h>
#include <array>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <limits>
#include <vector>
namespace XCEngine {
namespace Resources {
namespace fs = std::filesystem;
namespace {
constexpr const char* kArtifactContainerEntryPathToken = "@entry=";
struct ArtifactContainerFileHeader {
char magic[8] = { 'X', 'C', 'A', 'R', 'T', '0', '1', '\0' };
Core::uint32 schemaVersion = kArtifactContainerSchemaVersion;
Core::uint32 entryCount = 0;
Core::uint64 directorySize = 0;
Core::uint64 payloadSize = 0;
Core::uint64 contentHashHigh = 0;
Core::uint64 contentHashLow = 0;
};
struct ArtifactContainerEntryHeader {
Core::uint32 resourceType = 0;
Core::uint32 compression = 0;
Core::uint32 flags = 0;
Core::uint32 nameLength = 0;
Core::uint64 localID = 0;
Core::uint64 payloadOffset = 0;
Core::uint64 payloadSize = 0;
};
class IncrementalArtifactHasher {
public:
void Append(const void* data, size_t size) {
if (data == nullptr || size == 0) {
return;
}
m_hasBytes = true;
const auto* bytes = static_cast<const Core::uint8*>(data);
for (size_t index = 0; index < size; ++index) {
m_high ^= static_cast<Core::uint64>(bytes[index]);
m_high *= 1099511628211ULL;
m_low ^= static_cast<Core::uint64>(bytes[index]);
m_low *= 1099511628211ULL;
}
}
AssetGUID Finish() const {
if (!m_hasBytes) {
return AssetGUID();
}
return AssetGUID(m_high, m_low);
}
private:
bool m_hasBytes = false;
Core::uint64 m_high = 14695981039346656037ULL;
Core::uint64 m_low = 1099511628211ULL ^ 0x9e3779b97f4a7c15ULL;
};
Containers::String MakeError(const char* message) {
return Containers::String(message == nullptr ? "" : message);
}
bool TryWriteBytes(std::ofstream& output, const void* data, size_t size) {
output.write(static_cast<const char*>(data), static_cast<std::streamsize>(size));
return static_cast<bool>(output);
}
void AppendBytes(const void* data, size_t size, std::vector<Core::uint8>& outBytes) {
if (data == nullptr || size == 0) {
return;
}
const auto* begin = static_cast<const Core::uint8*>(data);
outBytes.insert(outBytes.end(), begin, begin + size);
}
bool IsExpectedMagic(const ArtifactContainerFileHeader& header) {
return std::memcmp(header.magic, "XCART01", 7) == 0 &&
header.schemaVersion == kArtifactContainerSchemaVersion;
}
} // namespace
void ArtifactContainerWriter::Clear() {
m_entries.Clear();
}
void ArtifactContainerWriter::AddEntry(const ArtifactContainerEntry& entry) {
m_entries.PushBack(entry);
}
void ArtifactContainerWriter::AddEntry(ArtifactContainerEntry&& entry) {
m_entries.PushBack(std::move(entry));
}
bool ArtifactContainerWriter::WriteToFile(const Containers::String& path,
Containers::String* outErrorMessage) const {
return WriteArtifactContainer(path, m_entries, outErrorMessage);
}
bool WriteArtifactContainer(const Containers::String& path,
const Containers::Array<ArtifactContainerEntry>& entries,
Containers::String* outErrorMessage) {
std::vector<Core::uint8> directoryBytes;
Core::uint64 payloadBytes = 0;
for (const ArtifactContainerEntry& entry : entries) {
if (entry.name.Length() > std::numeric_limits<Core::uint32>::max()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry name is too long.");
}
return false;
}
const Core::uint64 entryPayloadSize = static_cast<Core::uint64>(entry.payload.Size());
if (payloadBytes > std::numeric_limits<Core::uint64>::max() - entryPayloadSize) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer payload size overflow.");
}
return false;
}
ArtifactContainerEntryHeader entryHeader = {};
entryHeader.resourceType = static_cast<Core::uint32>(entry.resourceType);
entryHeader.compression = static_cast<Core::uint32>(entry.compression);
entryHeader.flags = entry.flags;
entryHeader.nameLength = static_cast<Core::uint32>(entry.name.Length());
entryHeader.localID = entry.localID;
entryHeader.payloadOffset = payloadBytes;
entryHeader.payloadSize = entryPayloadSize;
AppendBytes(&entryHeader, sizeof(entryHeader), directoryBytes);
AppendBytes(entry.name.CStr(), entry.name.Length(), directoryBytes);
payloadBytes += entryPayloadSize;
}
IncrementalArtifactHasher hasher;
if (!directoryBytes.empty()) {
hasher.Append(directoryBytes.data(), directoryBytes.size());
}
for (const ArtifactContainerEntry& entry : entries) {
if (!entry.payload.Empty()) {
hasher.Append(entry.payload.Data(), entry.payload.Size());
}
}
ArtifactContainerFileHeader fileHeader = {};
fileHeader.entryCount = static_cast<Core::uint32>(entries.Size());
fileHeader.directorySize = static_cast<Core::uint64>(directoryBytes.size());
fileHeader.payloadSize = payloadBytes;
const AssetGUID contentHash = hasher.Finish();
fileHeader.contentHashHigh = contentHash.high;
fileHeader.contentHashLow = contentHash.low;
std::error_code ec;
const fs::path targetPath(path.CStr());
if (!targetPath.parent_path().empty()) {
fs::create_directories(targetPath.parent_path(), ec);
if (ec) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to create ArtifactContainer parent directory.");
}
return false;
}
}
std::ofstream output(targetPath, std::ios::binary | std::ios::trunc);
if (!output.is_open()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to open ArtifactContainer output file.");
}
return false;
}
if (!TryWriteBytes(output, &fileHeader, sizeof(fileHeader)) ||
(!directoryBytes.empty() &&
!TryWriteBytes(output, directoryBytes.data(), directoryBytes.size()))) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to write ArtifactContainer header or directory.");
}
return false;
}
for (const ArtifactContainerEntry& entry : entries) {
if (!entry.payload.Empty() &&
!TryWriteBytes(output, entry.payload.Data(), entry.payload.Size())) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to write ArtifactContainer payload.");
}
return false;
}
}
return true;
}
bool IsArtifactContainerFile(const Containers::String& path) {
std::ifstream input(path.CStr(), std::ios::binary);
if (!input.is_open()) {
return false;
}
ArtifactContainerFileHeader fileHeader = {};
input.read(reinterpret_cast<char*>(&fileHeader), sizeof(fileHeader));
return input && IsExpectedMagic(fileHeader);
}
Containers::String BuildArtifactContainerEntryPath(const Containers::String& containerPath,
const Containers::String& entryName) {
if (containerPath.Empty() || entryName.Empty()) {
return Containers::String();
}
Containers::String result = containerPath;
result += kArtifactContainerEntryPathToken;
result += entryName;
return result;
}
bool TryParseArtifactContainerEntryPath(const Containers::String& path,
Containers::String& outContainerPath,
Containers::String& outEntryName) {
outContainerPath.Clear();
outEntryName.Clear();
const std::string text(path.CStr());
const std::string token(kArtifactContainerEntryPathToken);
const size_t tokenPos = text.rfind(token);
if (tokenPos == std::string::npos) {
return false;
}
const size_t entryNamePos = tokenPos + token.length();
if (tokenPos == 0 || entryNamePos >= text.length()) {
return false;
}
outContainerPath = Containers::String(text.substr(0, tokenPos).c_str());
outEntryName = Containers::String(text.substr(entryNamePos).c_str());
return !outContainerPath.Empty() && !outEntryName.Empty();
}
bool ArtifactContainerReader::Open(const Containers::String& path,
Containers::String* outErrorMessage) {
Close();
std::ifstream input(path.CStr(), std::ios::binary);
if (!input.is_open()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to open ArtifactContainer file.");
}
return false;
}
ArtifactContainerFileHeader fileHeader = {};
input.read(reinterpret_cast<char*>(&fileHeader), sizeof(fileHeader));
if (!input || !IsExpectedMagic(fileHeader)) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer header is invalid.");
}
return false;
}
input.seekg(0, std::ios::end);
const std::streamoff fileSize = input.tellg();
const Core::uint64 expectedFileSize =
static_cast<Core::uint64>(sizeof(fileHeader)) +
fileHeader.directorySize +
fileHeader.payloadSize;
if (fileSize < static_cast<std::streamoff>(sizeof(fileHeader)) ||
expectedFileSize != static_cast<Core::uint64>(fileSize)) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer file size does not match header.");
}
return false;
}
input.seekg(static_cast<std::streamoff>(sizeof(fileHeader)), std::ios::beg);
std::vector<Core::uint8> directoryBytes(static_cast<size_t>(fileHeader.directorySize));
if (!directoryBytes.empty()) {
input.read(reinterpret_cast<char*>(directoryBytes.data()),
static_cast<std::streamsize>(directoryBytes.size()));
if (!input) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to read ArtifactContainer directory.");
}
return false;
}
}
Containers::Array<ArtifactContainerEntryView> parsedEntries;
parsedEntries.Reserve(fileHeader.entryCount);
size_t cursor = 0;
for (Core::uint32 entryIndex = 0; entryIndex < fileHeader.entryCount; ++entryIndex) {
if (cursor + sizeof(ArtifactContainerEntryHeader) > directoryBytes.size()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer directory is truncated.");
}
return false;
}
ArtifactContainerEntryHeader entryHeader = {};
std::memcpy(&entryHeader, directoryBytes.data() + cursor, sizeof(entryHeader));
cursor += sizeof(entryHeader);
if (cursor + entryHeader.nameLength > directoryBytes.size()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry name is truncated.");
}
return false;
}
if (entryHeader.payloadOffset > fileHeader.payloadSize ||
entryHeader.payloadSize > fileHeader.payloadSize - entryHeader.payloadOffset) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry payload range is invalid.");
}
return false;
}
ArtifactContainerEntryView& view = parsedEntries.EmplaceBack();
view.name = Containers::String(
reinterpret_cast<const char*>(directoryBytes.data() + cursor),
entryHeader.nameLength);
view.resourceType = static_cast<ResourceType>(entryHeader.resourceType);
view.localID = entryHeader.localID;
view.flags = entryHeader.flags;
view.compression = static_cast<ArtifactContainerCompression>(entryHeader.compression);
view.payloadOffset = entryHeader.payloadOffset;
view.payloadSize = entryHeader.payloadSize;
cursor += entryHeader.nameLength;
}
if (cursor != directoryBytes.size()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer directory has trailing garbage.");
}
return false;
}
IncrementalArtifactHasher hasher;
if (!directoryBytes.empty()) {
hasher.Append(directoryBytes.data(), directoryBytes.size());
}
std::array<Core::uint8, 4096> buffer = {};
Core::uint64 remainingPayloadBytes = fileHeader.payloadSize;
while (remainingPayloadBytes > 0) {
const size_t chunkSize = static_cast<size_t>(std::min<Core::uint64>(
remainingPayloadBytes,
static_cast<Core::uint64>(buffer.size())));
input.read(reinterpret_cast<char*>(buffer.data()), static_cast<std::streamsize>(chunkSize));
if (!input) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to hash ArtifactContainer payload.");
}
return false;
}
hasher.Append(buffer.data(), chunkSize);
remainingPayloadBytes -= static_cast<Core::uint64>(chunkSize);
}
const AssetGUID expectedHash(fileHeader.contentHashHigh, fileHeader.contentHashLow);
const AssetGUID actualHash = hasher.Finish();
if (expectedHash != actualHash) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer content hash mismatch.");
}
return false;
}
m_path = path;
m_entries = std::move(parsedEntries);
m_payloadStart =
static_cast<Core::uint64>(sizeof(ArtifactContainerFileHeader)) +
fileHeader.directorySize;
m_payloadSize = fileHeader.payloadSize;
return true;
}
void ArtifactContainerReader::Close() {
m_path.Clear();
m_entries.Clear();
m_payloadStart = 0;
m_payloadSize = 0;
}
const ArtifactContainerEntryView* ArtifactContainerReader::FindEntryByName(
const Containers::String& name) const {
for (const ArtifactContainerEntryView& entry : m_entries) {
if (entry.name == name) {
return &entry;
}
}
return nullptr;
}
const ArtifactContainerEntryView* ArtifactContainerReader::FindEntry(
ResourceType resourceType,
LocalID localID) const {
for (const ArtifactContainerEntryView& entry : m_entries) {
if (entry.resourceType == resourceType && entry.localID == localID) {
return &entry;
}
}
return nullptr;
}
bool ArtifactContainerReader::ReadEntryPayload(const ArtifactContainerEntryView& entry,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage) const {
outPayload.Clear();
if (!IsOpen()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer is not open.");
}
return false;
}
if (entry.payloadOffset > m_payloadSize ||
entry.payloadSize > m_payloadSize - entry.payloadOffset) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry payload range is invalid.");
}
return false;
}
if (entry.payloadSize == 0) {
return true;
}
if (entry.payloadSize > static_cast<Core::uint64>(std::numeric_limits<size_t>::max())) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry payload is too large.");
}
return false;
}
std::ifstream input(m_path.CStr(), std::ios::binary);
if (!input.is_open()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to reopen ArtifactContainer file.");
}
return false;
}
input.seekg(static_cast<std::streamoff>(m_payloadStart + entry.payloadOffset), std::ios::beg);
if (!input) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to seek ArtifactContainer payload.");
}
return false;
}
outPayload.ResizeUninitialized(static_cast<size_t>(entry.payloadSize));
input.read(reinterpret_cast<char*>(outPayload.Data()),
static_cast<std::streamsize>(entry.payloadSize));
if (!input) {
outPayload.Clear();
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("Failed to read ArtifactContainer payload.");
}
return false;
}
return true;
}
bool ArtifactContainerReader::ReadEntryPayload(const Containers::String& name,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage) const {
const ArtifactContainerEntryView* entry = FindEntryByName(name);
if (entry == nullptr) {
outPayload.Clear();
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry was not found.");
}
return false;
}
return ReadEntryPayload(*entry, outPayload, outErrorMessage);
}
bool ReadArtifactContainerEntryPayload(const Containers::String& containerPath,
const Containers::String& entryName,
ResourceType expectedType,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage) {
outPayload.Clear();
ArtifactContainerReader reader;
if (!reader.Open(containerPath, outErrorMessage)) {
return false;
}
const ArtifactContainerEntryView* entry = reader.FindEntryByName(entryName);
if (entry == nullptr) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry was not found.");
}
return false;
}
if (expectedType != ResourceType::Unknown && entry->resourceType != expectedType) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer entry resource type did not match.");
}
return false;
}
return reader.ReadEntryPayload(*entry, outPayload, outErrorMessage);
}
bool ReadArtifactContainerPayloadByPath(const Containers::String& path,
ResourceType expectedType,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage) {
Containers::String containerPath;
Containers::String entryName;
if (TryParseArtifactContainerEntryPath(path, containerPath, entryName)) {
return ReadArtifactContainerEntryPayload(
containerPath,
entryName,
expectedType,
outPayload,
outErrorMessage);
}
return ReadArtifactContainerMainEntryPayload(path, expectedType, outPayload, outErrorMessage);
}
bool ReadArtifactContainerMainEntryPayload(const Containers::String& path,
ResourceType expectedType,
Containers::Array<Core::uint8>& outPayload,
Containers::String* outErrorMessage) {
outPayload.Clear();
ArtifactContainerReader reader;
if (!reader.Open(path, outErrorMessage)) {
return false;
}
const ArtifactContainerEntryView* entry = reader.FindEntry(expectedType, kMainAssetLocalID);
if (entry == nullptr) {
entry = reader.FindEntryByName("main");
}
if (entry == nullptr) {
if (outErrorMessage != nullptr) {
*outErrorMessage = MakeError("ArtifactContainer main entry was not found.");
}
return false;
}
return reader.ReadEntryPayload(*entry, outPayload, outErrorMessage);
}
} // namespace Resources
} // namespace XCEngine

View File

@@ -75,6 +75,9 @@ bool SerializeMaterialArtifactPayload(
const AssetDatabase* assetDatabase);
bool SerializeShaderArtifactPayload(const Shader& shader,
Containers::Array<Core::uint8>& outPayload);
bool SerializeMeshArtifactPayload(const Mesh& mesh,
const std::vector<Containers::String>& materialArtifactPaths,
Containers::Array<Core::uint8>& outPayload);
bool WriteSingleEntryArtifactContainerFile(const fs::path& artifactPath,
ResourceType resourceType,
const Containers::Array<Core::uint8>& payload);
@@ -1225,11 +1228,28 @@ bool WriteShaderArtifactFile(const fs::path& artifactPath, const Shader& shader)
bool WriteMeshArtifactFile(const fs::path& artifactPath,
const Mesh& mesh,
const std::vector<Containers::String>& materialArtifactPaths) {
Containers::Array<Core::uint8> payload;
if (!SerializeMeshArtifactPayload(mesh, materialArtifactPaths, payload)) {
return false;
}
std::ofstream output(artifactPath, std::ios::binary | std::ios::trunc);
if (!output.is_open()) {
return false;
}
if (!payload.Empty()) {
output.write(reinterpret_cast<const char*>(payload.Data()), static_cast<std::streamsize>(payload.Size()));
}
return static_cast<bool>(output);
}
bool SerializeMeshArtifactPayload(const Mesh& mesh,
const std::vector<Containers::String>& materialArtifactPaths,
Containers::Array<Core::uint8>& outPayload) {
std::ostringstream output(std::ios::binary | std::ios::out);
MeshArtifactHeader header;
header.vertexCount = mesh.GetVertexCount();
header.vertexStride = mesh.GetVertexStride();
@@ -1259,7 +1279,13 @@ bool WriteMeshArtifactFile(const fs::path& artifactPath,
WriteString(output, NormalizeArtifactPathString(materialArtifactPath));
}
return static_cast<bool>(output);
if (!output) {
outPayload.Clear();
return false;
}
outPayload = ToByteArray(output.str());
return true;
}
void DestroyImportedMesh(Mesh* mesh) {
@@ -1438,6 +1464,20 @@ bool AssetDatabase::TryResolveAssetPath(const AssetRef& assetRef, Containers::St
}
auto resolveFromArtifactRecord = [&](const ArtifactRecord& artifactRecord) -> bool {
const Containers::String absoluteMainArtifactPath =
NormalizePathString(fs::path(m_projectRoot.CStr()) / artifactRecord.mainArtifactPath.CStr());
ArtifactContainerReader reader;
Containers::String containerError;
if (reader.Open(absoluteMainArtifactPath, &containerError)) {
const ArtifactContainerEntryView* entry =
reader.FindEntry(assetRef.resourceType, assetRef.localID);
if (entry != nullptr) {
outPath = BuildArtifactContainerEntryPath(absoluteMainArtifactPath, entry->name);
return true;
}
}
const fs::path manifestPath =
fs::path(m_projectRoot.CStr()) /
artifactRecord.artifactDirectory.CStr() /
@@ -2170,7 +2210,7 @@ Core::uint32 AssetDatabase::GetCurrentImporterVersion(const Containers::String&
}
if (importerName == "ModelImporter") {
return 10;
return 12;
}
if (importerName == "GaussianSplatImporter") {
@@ -2557,21 +2597,24 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord,
std::vector<ArtifactDependencyRecord> 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.xcmodel");
const Containers::String legacyArtifactDir = BuildArtifactDirectory(artifactKey);
const Containers::String mainArtifactPath = BuildArtifactFilePath(artifactKey, ".xcmodel");
const Containers::String artifactDir =
NormalizePathString(fs::path(mainArtifactPath.CStr()).parent_path());
std::error_code ec;
fs::remove_all(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec);
fs::remove_all(fs::path(m_projectRoot.CStr()) / legacyArtifactDir.CStr(), ec);
ec.clear();
fs::create_directories(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec);
fs::create_directories(
(fs::path(m_projectRoot.CStr()) / mainArtifactPath.CStr()).parent_path(),
ec);
if (ec) {
importedModel.Reset();
return false;
}
ArtifactContainerWriter writer;
bool writeOk = true;
std::vector<ModelSubAssetManifestEntry> subAssetManifestEntries;
std::unordered_map<const Texture*, Containers::String> textureArtifactPaths;
std::unordered_map<const Texture*, AssetRef> textureAssetRefs;
for (size_t textureIndex = 0; writeOk && textureIndex < importedModel.textures.size(); ++textureIndex) {
@@ -2580,16 +2623,23 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord,
continue;
}
const Containers::String textureArtifactPath =
NormalizePathString(fs::path(artifactDir.CStr()) / ("texture_" + std::to_string(textureIndex) + ".xctex"));
writeOk = WriteTextureArtifactFile(
fs::path(m_projectRoot.CStr()) / textureArtifactPath.CStr(),
*texture);
const Containers::String entryName =
Containers::String(("texture_" + std::to_string(textureIndex) + ".xctex").c_str());
Containers::Array<Core::uint8> payload;
writeOk = SerializeTextureArtifactPayload(*texture, payload);
if (!writeOk) {
break;
}
textureArtifactPaths.emplace(texture, textureArtifactPath);
ArtifactContainerEntry entry;
entry.name = entryName;
entry.resourceType = ResourceType::Texture;
entry.payload = std::move(payload);
writer.AddEntry(std::move(entry));
textureArtifactPaths.emplace(
texture,
BuildArtifactContainerEntryPath(mainArtifactPath, entryName));
if (!texture->GetPath().Empty()) {
AssetRef textureAssetRef;
@@ -2606,11 +2656,12 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord,
continue;
}
const Containers::String materialArtifactPath =
NormalizePathString(fs::path(artifactDir.CStr()) / ("material_" + std::to_string(materialIndex) + ".xcmat"));
writeOk = WriteMaterialArtifactFile(
fs::path(m_projectRoot.CStr()) / materialArtifactPath.CStr(),
const Containers::String entryName =
Containers::String(("material_" + std::to_string(materialIndex) + ".xcmat").c_str());
Containers::Array<Core::uint8> payload;
writeOk = SerializeMaterialArtifactPayload(
*materialEntry.material,
payload,
textureArtifactPaths,
textureAssetRefs,
this);
@@ -2618,9 +2669,16 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord,
break;
}
materialArtifactPathsByLocalID.emplace(materialEntry.localID, materialArtifactPath);
subAssetManifestEntries.push_back(
ModelSubAssetManifestEntry{ materialEntry.localID, ResourceType::Material, materialArtifactPath });
ArtifactContainerEntry entry;
entry.name = entryName;
entry.resourceType = ResourceType::Material;
entry.localID = materialEntry.localID;
entry.payload = std::move(payload);
writer.AddEntry(std::move(entry));
materialArtifactPathsByLocalID.emplace(
materialEntry.localID,
BuildArtifactContainerEntryPath(mainArtifactPath, entryName));
}
for (size_t meshIndex = 0; writeOk && meshIndex < importedModel.meshes.size(); ++meshIndex) {
@@ -2639,30 +2697,37 @@ bool AssetDatabase::ImportModelAsset(const SourceAssetRecord& sourceRecord,
: 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 });
const Containers::String entryName =
Containers::String(("mesh_" + std::to_string(meshIndex) + ".xcmesh").c_str());
Containers::Array<Core::uint8> payload;
writeOk = SerializeMeshArtifactPayload(*meshEntry.mesh, meshMaterialArtifactPaths, payload);
if (!writeOk) {
break;
}
ArtifactContainerEntry entry;
entry.name = entryName;
entry.resourceType = ResourceType::Mesh;
entry.localID = meshEntry.localID;
entry.payload = std::move(payload);
writer.AddEntry(std::move(entry));
}
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);
Containers::Array<Core::uint8> modelPayload;
writeOk = SerializeModelArtifactPayload(*importedModel.model, modelPayload, &modelWriteErrorMessage);
if (writeOk) {
ArtifactContainerEntry mainEntry;
mainEntry.name = "main";
mainEntry.resourceType = ResourceType::Model;
mainEntry.localID = kMainAssetLocalID;
mainEntry.payload = std::move(modelPayload);
writer.AddEntry(std::move(mainEntry));
writeOk = writer.WriteToFile(
NormalizePathString(fs::path(m_projectRoot.CStr()) / mainArtifactPath.CStr()),
&modelWriteErrorMessage);
}
}
importedModel.Reset();
@@ -2940,14 +3005,19 @@ bool AssetDatabase::ImportUIDocumentAsset(const SourceAssetRecord& sourceRecord,
}
const Containers::String artifactKey = BuildArtifactKey(sourceRecord, dependencies);
const Containers::String artifactDir = BuildArtifactDirectory(artifactKey);
const Containers::String mainArtifactPath =
NormalizePathString(fs::path(artifactDir.CStr()) / artifactFileName);
const Containers::String legacyArtifactDir = BuildArtifactDirectory(artifactKey);
const Containers::String extension =
Containers::String(fs::path(artifactFileName == nullptr ? "" : artifactFileName).extension().string().c_str());
const Containers::String mainArtifactPath = BuildArtifactFilePath(artifactKey, extension.CStr());
const Containers::String artifactDir =
NormalizePathString(fs::path(mainArtifactPath.CStr()).parent_path());
std::error_code ec;
fs::remove_all(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec);
fs::remove_all(fs::path(m_projectRoot.CStr()) / legacyArtifactDir.CStr(), ec);
ec.clear();
fs::create_directories(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec);
fs::create_directories(
(fs::path(m_projectRoot.CStr()) / mainArtifactPath.CStr()).parent_path(),
ec);
if (ec) {
SetLastErrorMessage(
Containers::String("Failed to create UI artifact directory: ") + artifactDir);

View File

@@ -106,6 +106,38 @@ RHI::InputLayoutDesc BuiltinForwardPipeline::BuildInputLayout() {
texcoord.alignedByteOffset = static_cast<uint32_t>(offsetof(Resources::StaticMeshVertex, uv0));
inputLayout.elements.push_back(texcoord);
RHI::InputElementDesc backTexcoord = {};
backTexcoord.semanticName = "TEXCOORD";
backTexcoord.semanticIndex = 1;
backTexcoord.format = static_cast<uint32_t>(RHI::Format::R32G32_Float);
backTexcoord.inputSlot = 0;
backTexcoord.alignedByteOffset = static_cast<uint32_t>(offsetof(Resources::StaticMeshVertex, uv1));
inputLayout.elements.push_back(backTexcoord);
RHI::InputElementDesc tangent = {};
tangent.semanticName = "TEXCOORD";
tangent.semanticIndex = 2;
tangent.format = static_cast<uint32_t>(RHI::Format::R32G32B32_Float);
tangent.inputSlot = 0;
tangent.alignedByteOffset = static_cast<uint32_t>(offsetof(Resources::StaticMeshVertex, tangent));
inputLayout.elements.push_back(tangent);
RHI::InputElementDesc bitangent = {};
bitangent.semanticName = "TEXCOORD";
bitangent.semanticIndex = 3;
bitangent.format = static_cast<uint32_t>(RHI::Format::R32G32B32_Float);
bitangent.inputSlot = 0;
bitangent.alignedByteOffset = static_cast<uint32_t>(offsetof(Resources::StaticMeshVertex, bitangent));
inputLayout.elements.push_back(bitangent);
RHI::InputElementDesc color = {};
color.semanticName = "COLOR";
color.semanticIndex = 0;
color.format = static_cast<uint32_t>(RHI::Format::R32G32B32A32_Float);
color.inputSlot = 0;
color.alignedByteOffset = static_cast<uint32_t>(offsetof(Resources::StaticMeshVertex, color));
inputLayout.elements.push_back(color);
return inputLayout;
}
@@ -132,17 +164,26 @@ bool BuiltinForwardPipeline::Render(
const RenderSurface& surface,
const RenderSceneData& sceneData) {
if (!Initialize(context)) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: Initialize returned false");
return false;
}
if (m_volumetricPass != nullptr &&
!sceneData.visibleVolumes.empty() &&
!m_volumetricPass->PrepareVolumeResources(context, sceneData)) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: PrepareVolumeResources returned false");
return false;
}
if (m_gaussianSplatPass != nullptr &&
!sceneData.visibleGaussianSplats.empty() &&
!m_gaussianSplatPass->PrepareGaussianSplatResources(context, sceneData)) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: PrepareGaussianSplatResources returned false");
return false;
}
@@ -156,6 +197,9 @@ bool BuiltinForwardPipeline::Render(
};
if (!BeginForwardScenePass(passContext)) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: BeginForwardScenePass returned false");
return false;
}
@@ -167,15 +211,35 @@ bool BuiltinForwardPipeline::Render(
bool renderResult = ExecuteForwardOpaquePass(passContext);
if (renderResult) {
renderResult = ExecuteForwardSkyboxPass(passContext);
if (!renderResult) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: ExecuteForwardSkyboxPass returned false");
}
}
if (renderResult && m_gaussianSplatPass != nullptr) {
renderResult = m_gaussianSplatPass->Execute(passContext);
if (!renderResult) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: BuiltinGaussianSplatPass::Execute returned false");
}
}
if (renderResult && m_volumetricPass != nullptr) {
renderResult = m_volumetricPass->Execute(passContext);
if (!renderResult) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: BuiltinVolumetricPass::Execute returned false");
}
}
if (renderResult) {
renderResult = ExecuteForwardTransparentPass(passContext);
if (!renderResult) {
Debug::Logger::Get().Error(
Debug::LogCategory::Rendering,
"BuiltinForwardPipeline::Render failed: ExecuteForwardTransparentPass returned false");
}
}
if (sampledDirectionalShadow) {

View File

@@ -438,6 +438,37 @@ BuiltinForwardPipeline::CachedDescriptorSet* BuiltinForwardPipeline::GetOrCreate
}
}
if (setLayout.usesMaterialTextures) {
if (material == nullptr) {
return nullptr;
}
if (cachedDescriptorSet.materialTextureViews.size() != setLayout.materialTextureBindings.size()) {
cachedDescriptorSet.materialTextureViews.assign(
setLayout.materialTextureBindings.size(),
nullptr);
}
for (size_t bindingIndex = 0; bindingIndex < setLayout.materialTextureBindings.size(); ++bindingIndex) {
const BuiltinPassResourceBindingDesc& textureBinding =
setLayout.materialTextureBindings[bindingIndex];
RHI::RHIResourceView* resolvedTextureView =
ResolveMaterialTextureView(material, textureBinding);
if (resolvedTextureView == nullptr) {
return nullptr;
}
if (cachedDescriptorSet.materialTextureViews[bindingIndex] != resolvedTextureView) {
cachedDescriptorSet.descriptorSet.set->Update(
textureBinding.location.binding,
resolvedTextureView);
cachedDescriptorSet.materialTextureViews[bindingIndex] = resolvedTextureView;
}
}
} else if (!cachedDescriptorSet.materialTextureViews.empty()) {
cachedDescriptorSet.materialTextureViews.clear();
}
if (setLayout.usesLighting) {
if (!passLayout.lighting.IsValid() || passLayout.lighting.set != setIndex) {
return nullptr;
@@ -556,6 +587,33 @@ RHI::RHIResourceView* BuiltinForwardPipeline::ResolveTextureView(
return textureView != nullptr ? textureView : m_fallbackTexture2DView;
}
RHI::RHIResourceView* BuiltinForwardPipeline::ResolveMaterialTextureView(
const Resources::Material* material,
const BuiltinPassResourceBindingDesc& binding) {
const bool expectsCubemap = binding.resourceType == Resources::ShaderResourceType::TextureCube;
RHI::RHIResourceView* fallbackView = expectsCubemap ? m_fallbackTextureCubeView : m_fallbackTexture2DView;
if (material == nullptr) {
return fallbackView;
}
const Resources::ResourceHandle<Resources::Texture> textureHandle = material->GetTexture(binding.name);
const Resources::Texture* texture = textureHandle.Get();
if (texture == nullptr) {
return fallbackView;
}
const Resources::TextureType textureType = texture->GetTextureType();
const bool isCubemap =
textureType == Resources::TextureType::TextureCube ||
textureType == Resources::TextureType::TextureCubeArray;
if (expectsCubemap != isCubemap) {
return fallbackView;
}
RHI::RHIResourceView* textureView = ResolveTextureView(texture);
return textureView != nullptr ? textureView : fallbackView;
}
BuiltinForwardPipeline::LightingConstants BuiltinForwardPipeline::BuildLightingConstants(
const RenderLightingData& lightingData) {
LightingConstants lightingConstants = {};
@@ -664,7 +722,7 @@ bool BuiltinForwardPipeline::DrawVisibleItem(
? sceneData.lighting.mainDirectionalShadow.shadowParams
: Math::Vector4::Zero(),
sceneData.lighting.HasMainDirectionalShadow()
? Math::Vector4(1.0f, 0.0f, 0.0f, 0.0f)
? sceneData.lighting.mainDirectionalShadow.shadowOptions
: Math::Vector4::Zero()
};
@@ -730,7 +788,8 @@ bool BuiltinForwardPipeline::DrawVisibleItem(
const Resources::Material* materialKey =
(setLayout.usesMaterial ||
setLayout.usesBaseColorTexture ||
setLayout.usesMaterialBuffers)
setLayout.usesMaterialBuffers ||
setLayout.usesMaterialTextures)
? material
: nullptr;

View File

@@ -1,4 +1,5 @@
#include <XCEngine/Resources/Mesh/MeshLoader.h>
#include <XCEngine/Core/Asset/ArtifactContainer.h>
#include <XCEngine/Core/Asset/ArtifactFormats.h>
#include <XCEngine/Resources/BuiltinResources.h>
#include <XCEngine/Resources/Material/MaterialLoader.h>
@@ -229,12 +230,27 @@ Containers::String ResolveArtifactDependencyPath(const Containers::String& depen
}
std::filesystem::path dependencyFsPath(dependencyPath.CStr());
Containers::String containerPathText;
Containers::String entryName;
const bool isContainerEntryPath =
TryParseArtifactContainerEntryPath(dependencyPath, containerPathText, entryName);
if (isContainerEntryPath) {
dependencyFsPath = std::filesystem::path(containerPathText.CStr());
}
auto rebuildResolvedPath = [&entryName, isContainerEntryPath](const std::filesystem::path& resolvedPath) {
const Containers::String normalizedPath = NormalizePathString(resolvedPath);
return isContainerEntryPath
? BuildArtifactContainerEntryPath(normalizedPath, entryName)
: normalizedPath;
};
if (dependencyFsPath.is_absolute() && std::filesystem::exists(dependencyFsPath)) {
return NormalizePathString(dependencyFsPath);
return rebuildResolvedPath(dependencyFsPath);
}
if (std::filesystem::exists(dependencyFsPath)) {
return NormalizePathString(dependencyFsPath);
return rebuildResolvedPath(dependencyFsPath);
}
const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot();
@@ -242,7 +258,7 @@ Containers::String ResolveArtifactDependencyPath(const Containers::String& depen
const std::filesystem::path projectRelativeCandidate =
std::filesystem::path(resourceRoot.CStr()) / dependencyFsPath;
if (std::filesystem::exists(projectRelativeCandidate)) {
return NormalizePathString(projectRelativeCandidate);
return rebuildResolvedPath(projectRelativeCandidate);
}
}
@@ -254,7 +270,7 @@ Containers::String ResolveArtifactDependencyPath(const Containers::String& depen
const std::filesystem::path ownerRelativeCandidate =
ownerArtifactFsPath.parent_path() / dependencyFsPath;
if (std::filesystem::exists(ownerRelativeCandidate)) {
return NormalizePathString(ownerRelativeCandidate);
return rebuildResolvedPath(ownerRelativeCandidate);
}
std::filesystem::path current = ownerArtifactFsPath.parent_path();
@@ -264,7 +280,7 @@ Containers::String ResolveArtifactDependencyPath(const Containers::String& depen
if (!projectRoot.empty()) {
const std::filesystem::path projectRelativeCandidate = projectRoot / dependencyFsPath;
if (std::filesystem::exists(projectRelativeCandidate)) {
return NormalizePathString(projectRelativeCandidate);
return rebuildResolvedPath(projectRelativeCandidate);
}
}
break;
@@ -675,42 +691,66 @@ ImportedMeshData ImportSingleMesh(const aiMesh& mesh,
result.vertices.reserve(mesh.mNumVertices);
result.indices.reserve(mesh.mNumFaces * 3);
const bool hasNormals = mesh.HasNormals();
const bool hasTangentsAndBitangents = mesh.HasTangentsAndBitangents();
const bool hasUv0 = mesh.HasTextureCoords(0);
const bool hasUv1 = mesh.HasTextureCoords(1);
const bool hasVertexColors = mesh.HasVertexColors(0);
const bool useFallbackUv1 = hasUv0 && !hasUv1;
VertexAttribute attributes = VertexAttribute::Position;
if (mesh.HasNormals()) {
if (hasNormals) {
attributes = attributes | VertexAttribute::Normal;
}
if (mesh.HasTangentsAndBitangents()) {
if (hasTangentsAndBitangents) {
attributes = attributes | VertexAttribute::Tangent | VertexAttribute::Bitangent;
}
if (mesh.HasTextureCoords(0)) {
if (hasUv0) {
attributes = attributes | VertexAttribute::UV0;
}
if (hasUv1 || useFallbackUv1) {
attributes = attributes | VertexAttribute::UV1;
}
if (hasVertexColors) {
attributes = attributes | VertexAttribute::Color;
}
const Math::Matrix4 normalTransform = worldTransform.Inverse().Transpose();
const float appliedScale = globalScale;
for (Core::uint32 vertexIndex = 0; vertexIndex < mesh.mNumVertices; ++vertexIndex) {
StaticMeshVertex vertex;
StaticMeshVertex vertex = {};
const aiVector3D& position = mesh.mVertices[vertexIndex];
const Math::Vector3 transformedPosition = worldTransform.MultiplyPoint(Math::Vector3(position.x, position.y, position.z));
vertex.position = transformedPosition * appliedScale + offset;
if (mesh.HasNormals()) {
if (hasNormals) {
const aiVector3D& normal = mesh.mNormals[vertexIndex];
vertex.normal = normalTransform.MultiplyVector(Math::Vector3(normal.x, normal.y, normal.z)).Normalized();
}
if (mesh.HasTangentsAndBitangents()) {
if (hasTangentsAndBitangents) {
const aiVector3D& tangent = mesh.mTangents[vertexIndex];
const aiVector3D& bitangent = mesh.mBitangents[vertexIndex];
vertex.tangent = normalTransform.MultiplyVector(Math::Vector3(tangent.x, tangent.y, tangent.z)).Normalized();
vertex.bitangent = normalTransform.MultiplyVector(Math::Vector3(bitangent.x, bitangent.y, bitangent.z)).Normalized();
}
if (mesh.HasTextureCoords(0)) {
if (hasUv0) {
const aiVector3D& uv = mesh.mTextureCoords[0][vertexIndex];
vertex.uv0 = Math::Vector2(uv.x, uv.y);
vertex.uv1 = vertex.uv0;
}
if (hasUv1) {
const aiVector3D& uv = mesh.mTextureCoords[1][vertexIndex];
vertex.uv1 = Math::Vector2(uv.x, uv.y);
}
if (hasVertexColors) {
const aiColor4D& color = mesh.mColors[0][vertexIndex];
vertex.color = Math::Vector4(color.r, color.g, color.b, color.a);
}
result.vertices.push_back(vertex);
@@ -831,14 +871,72 @@ void ApplyMaterialProperty(Material& material, const MaterialProperty& property)
}
LoadResult LoadMeshArtifact(const Containers::String& path) {
std::ifstream input(path.CStr(), std::ios::binary);
if (!input.is_open()) {
Containers::Array<Core::uint8> data;
auto tryRead = [&data](const std::filesystem::path& filePath, bool& opened) {
opened = false;
data.Clear();
Containers::String containerError;
if (ReadArtifactContainerPayloadByPath(
Containers::String(filePath.generic_string().c_str()),
ResourceType::Mesh,
data,
&containerError)) {
opened = true;
return;
}
std::ifstream input(filePath, std::ios::binary | std::ios::ate);
if (!input.is_open()) {
return;
}
opened = true;
const std::streamsize size = input.tellg();
if (size < 0) {
data.Clear();
return;
}
input.seekg(0, std::ios::beg);
data.Resize(static_cast<size_t>(size));
if (size > 0 &&
!input.read(reinterpret_cast<char*>(data.Data()), size)) {
data.Clear();
}
};
bool opened = false;
std::filesystem::path resolvedPath(path.CStr());
tryRead(resolvedPath, opened);
if (!opened && !resolvedPath.is_absolute()) {
const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot();
if (!resourceRoot.Empty()) {
resolvedPath = std::filesystem::path(resourceRoot.CStr()) / resolvedPath;
tryRead(resolvedPath, opened);
}
}
if (!opened || data.Size() < sizeof(MeshArtifactHeader)) {
return LoadResult(Containers::String("Failed to read mesh artifact: ") + path);
}
size_t offset = 0;
auto readBytes = [&data, &offset](void* destination, size_t byteCount) -> bool {
if (offset + byteCount > data.Size()) {
return false;
}
if (byteCount > 0) {
std::memcpy(destination, data.Data() + offset, byteCount);
offset += byteCount;
}
return true;
};
MeshArtifactHeader header;
input.read(reinterpret_cast<char*>(&header), sizeof(header));
if (!input) {
if (!readBytes(&header, sizeof(header))) {
return LoadResult(Containers::String("Failed to parse mesh artifact header: ") + path);
}
@@ -859,28 +957,29 @@ LoadResult LoadMeshArtifact(const Containers::String& path) {
Containers::Array<MeshSection> sections;
sections.Resize(header.sectionCount);
for (Core::uint32 index = 0; index < header.sectionCount; ++index) {
input.read(reinterpret_cast<char*>(&sections[index]), sizeof(MeshSection));
if (!input) {
if (!readBytes(&sections[index], sizeof(MeshSection))) {
return LoadResult(Containers::String("Failed to read mesh sections: ") + path);
}
}
Containers::Array<Core::uint8> vertexData;
vertexData.Resize(static_cast<size_t>(header.vertexDataSize));
if (header.vertexDataSize > 0) {
input.read(reinterpret_cast<char*>(vertexData.Data()), static_cast<std::streamsize>(header.vertexDataSize));
if (!input) {
return LoadResult(Containers::String("Failed to read mesh vertex data: ") + path);
}
if (header.vertexDataSize > static_cast<Core::uint64>(data.Size() - offset)) {
return LoadResult(Containers::String("Failed to read mesh vertex data: ") + path);
}
if (header.vertexDataSize > 0 &&
!readBytes(vertexData.Data(), static_cast<size_t>(header.vertexDataSize))) {
return LoadResult(Containers::String("Failed to read mesh vertex data: ") + path);
}
Containers::Array<Core::uint8> indexData;
indexData.Resize(static_cast<size_t>(header.indexDataSize));
if (header.indexDataSize > 0) {
input.read(reinterpret_cast<char*>(indexData.Data()), static_cast<std::streamsize>(header.indexDataSize));
if (!input) {
return LoadResult(Containers::String("Failed to read mesh index data: ") + path);
}
if (header.indexDataSize > static_cast<Core::uint64>(data.Size() - offset)) {
return LoadResult(Containers::String("Failed to read mesh index data: ") + path);
}
if (header.indexDataSize > 0 &&
!readBytes(indexData.Data(), static_cast<size_t>(header.indexDataSize))) {
return LoadResult(Containers::String("Failed to read mesh index data: ") + path);
}
mesh->SetVertexData(vertexData.Data(),
@@ -901,11 +1000,31 @@ LoadResult LoadMeshArtifact(const Containers::String& path) {
bounds.SetMinMax(header.boundsMin, header.boundsMax);
mesh->SetBounds(bounds);
auto readBinaryString = [&data, &offset, &readBytes](Containers::String& outValue) -> bool {
Core::uint32 length = 0;
if (!readBytes(&length, sizeof(length))) {
return false;
}
if (length == 0) {
outValue.Clear();
return true;
}
if (offset + length > data.Size()) {
return false;
}
outValue = Containers::String(reinterpret_cast<const char*>(data.Data() + offset), length);
offset += length;
return true;
};
MaterialLoader materialLoader;
for (Core::uint32 materialIndex = 0; materialIndex < header.materialCount; ++materialIndex) {
const Containers::String materialArtifactPath = ReadBinaryString(input);
if (!input) {
Containers::String materialArtifactPath;
if (!readBinaryString(materialArtifactPath)) {
return LoadResult(Containers::String("Failed to read mesh material artifact path: ") + path);
}

View File

@@ -640,37 +640,61 @@ ImportedMeshData ImportSingleMesh(const aiMesh& mesh) {
result.vertices.reserve(mesh.mNumVertices);
result.indices.reserve(mesh.mNumFaces * 3);
const bool hasNormals = mesh.HasNormals();
const bool hasTangentsAndBitangents = mesh.HasTangentsAndBitangents();
const bool hasUv0 = mesh.HasTextureCoords(0);
const bool hasUv1 = mesh.HasTextureCoords(1);
const bool hasVertexColors = mesh.HasVertexColors(0);
const bool useFallbackUv1 = hasUv0 && !hasUv1;
VertexAttribute attributes = VertexAttribute::Position;
if (mesh.HasNormals()) {
if (hasNormals) {
attributes = attributes | VertexAttribute::Normal;
}
if (mesh.HasTangentsAndBitangents()) {
if (hasTangentsAndBitangents) {
attributes = attributes | VertexAttribute::Tangent | VertexAttribute::Bitangent;
}
if (mesh.HasTextureCoords(0)) {
if (hasUv0) {
attributes = attributes | VertexAttribute::UV0;
}
if (hasUv1 || useFallbackUv1) {
attributes = attributes | VertexAttribute::UV1;
}
if (hasVertexColors) {
attributes = attributes | VertexAttribute::Color;
}
for (Core::uint32 vertexIndex = 0; vertexIndex < mesh.mNumVertices; ++vertexIndex) {
StaticMeshVertex vertex;
StaticMeshVertex vertex = {};
const aiVector3D& position = mesh.mVertices[vertexIndex];
vertex.position = Math::Vector3(position.x, position.y, position.z);
if (mesh.HasNormals()) {
if (hasNormals) {
const aiVector3D& normal = mesh.mNormals[vertexIndex];
vertex.normal = Math::Vector3(normal.x, normal.y, normal.z).Normalized();
}
if (mesh.HasTangentsAndBitangents()) {
if (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)) {
if (hasUv0) {
const aiVector3D& uv = mesh.mTextureCoords[0][vertexIndex];
vertex.uv0 = Math::Vector2(uv.x, uv.y);
vertex.uv1 = vertex.uv0;
}
if (hasUv1) {
const aiVector3D& uv = mesh.mTextureCoords[1][vertexIndex];
vertex.uv1 = Math::Vector2(uv.x, uv.y);
}
if (hasVertexColors) {
const aiColor4D& color = mesh.mColors[0][vertexIndex];
vertex.color = Math::Vector4(color.r, color.g, color.b, color.a);
}
result.vertices.push_back(vertex);

View File

@@ -0,0 +1,228 @@
#include <XCEngine/Scene/ModelSceneInstantiation.h>
#include <XCEngine/Components/GameObject.h>
#include <XCEngine/Components/MeshFilterComponent.h>
#include <XCEngine/Components/MeshRendererComponent.h>
#include <XCEngine/Core/Asset/ResourceManager.h>
#include <XCEngine/Scene/Scene.h>
namespace XCEngine {
namespace {
using namespace Components;
using namespace Resources;
Containers::String MakeNodeName(const ModelNode& node, Core::uint32 nodeIndex) {
if (!node.name.Empty()) {
return node.name;
}
return Containers::String(("ModelNode_" + std::to_string(nodeIndex)).c_str());
}
Containers::String MakeMeshObjectName(const Containers::String& nodeName, Core::uint32 meshBindingOffset) {
return Containers::String(
(std::string(nodeName.CStr()) + "_Mesh" + std::to_string(meshBindingOffset)).c_str());
}
AssetRef MakeSubAssetRef(const AssetRef& modelAssetRef, LocalID localID, ResourceType resourceType) {
AssetRef ref;
ref.assetGuid = modelAssetRef.assetGuid;
ref.localID = localID;
ref.resourceType = resourceType;
return ref;
}
void SetErrorMessage(Containers::String* outErrorMessage, const Containers::String& message) {
if (outErrorMessage != nullptr) {
*outErrorMessage = message;
}
}
bool AttachMeshBinding(
GameObject& targetObject,
const Model& model,
const AssetRef& modelAssetRef,
const ModelMeshBinding& meshBinding,
Containers::String* outErrorMessage,
std::vector<GameObject*>* outMeshObjects) {
if (!modelAssetRef.IsValid()) {
SetErrorMessage(outErrorMessage, "Model asset ref is required when instantiating mesh bindings.");
return false;
}
auto* meshFilter = targetObject.GetComponent<MeshFilterComponent>();
if (meshFilter == nullptr) {
meshFilter = targetObject.AddComponent<MeshFilterComponent>();
}
auto* meshRenderer = targetObject.GetComponent<MeshRendererComponent>();
if (meshRenderer == nullptr) {
meshRenderer = targetObject.AddComponent<MeshRendererComponent>();
}
meshFilter->SetMeshAssetRef(MakeSubAssetRef(modelAssetRef, meshBinding.meshLocalID, ResourceType::Mesh));
const auto& materialBindings = model.GetMaterialBindings();
const Core::uint32 materialBindingEnd = meshBinding.materialBindingStart + meshBinding.materialBindingCount;
if (materialBindingEnd > materialBindings.Size()) {
SetErrorMessage(outErrorMessage, "Model mesh binding references material bindings outside the model range.");
return false;
}
for (Core::uint32 materialBindingIndex = meshBinding.materialBindingStart;
materialBindingIndex < materialBindingEnd;
++materialBindingIndex) {
const ModelMaterialBinding& materialBinding = materialBindings[materialBindingIndex];
meshRenderer->SetMaterialAssetRef(
static_cast<size_t>(materialBinding.slotIndex),
MakeSubAssetRef(modelAssetRef, materialBinding.materialLocalID, ResourceType::Material));
}
if (outMeshObjects != nullptr) {
outMeshObjects->push_back(&targetObject);
}
return true;
}
} // namespace
bool InstantiateModelHierarchy(
Components::Scene& scene,
const Resources::Model& model,
const Resources::AssetRef& modelAssetRef,
Components::GameObject* parent,
ModelSceneInstantiationResult* outResult,
Containers::String* outErrorMessage) {
if (!model.IsValid()) {
SetErrorMessage(outErrorMessage, "Model is invalid.");
return false;
}
if (!model.HasRootNode()) {
SetErrorMessage(outErrorMessage, "Model does not have a root node.");
return false;
}
const auto& nodes = model.GetNodes();
const auto& meshBindings = model.GetMeshBindings();
if (model.GetRootNodeIndex() >= nodes.Size()) {
SetErrorMessage(outErrorMessage, "Model root node index is outside the node range.");
return false;
}
ModelSceneInstantiationResult localResult;
localResult.nodeObjects.resize(nodes.Size(), nullptr);
for (Core::uint32 nodeIndex = 0; nodeIndex < nodes.Size(); ++nodeIndex) {
const ModelNode& node = nodes[nodeIndex];
localResult.nodeObjects[nodeIndex] = scene.CreateGameObject(MakeNodeName(node, nodeIndex).CStr(), nullptr);
if (localResult.nodeObjects[nodeIndex] == nullptr) {
SetErrorMessage(outErrorMessage, "Failed to create a model node game object.");
return false;
}
auto* transform = localResult.nodeObjects[nodeIndex]->GetTransform();
transform->SetLocalPosition(node.localPosition);
transform->SetLocalRotation(node.localRotation);
transform->SetLocalScale(node.localScale);
}
for (Core::uint32 nodeIndex = 0; nodeIndex < nodes.Size(); ++nodeIndex) {
const ModelNode& node = nodes[nodeIndex];
GameObject* nodeObject = localResult.nodeObjects[nodeIndex];
if (nodeObject == nullptr) {
continue;
}
if (node.parentIndex >= 0) {
const Core::uint32 parentIndex = static_cast<Core::uint32>(node.parentIndex);
if (parentIndex >= localResult.nodeObjects.size() || localResult.nodeObjects[parentIndex] == nullptr) {
SetErrorMessage(outErrorMessage, "Model node references an invalid parent index.");
return false;
}
nodeObject->SetParent(localResult.nodeObjects[parentIndex], false);
} else if (parent != nullptr) {
nodeObject->SetParent(parent, false);
}
}
for (Core::uint32 nodeIndex = 0; nodeIndex < nodes.Size(); ++nodeIndex) {
const ModelNode& node = nodes[nodeIndex];
GameObject* nodeObject = localResult.nodeObjects[nodeIndex];
if (nodeObject == nullptr || node.meshBindingCount == 0u) {
continue;
}
const Core::uint32 meshBindingEnd = node.meshBindingStart + node.meshBindingCount;
if (meshBindingEnd > meshBindings.Size()) {
SetErrorMessage(outErrorMessage, "Model node references mesh bindings outside the model range.");
return false;
}
if (node.meshBindingCount == 1u) {
if (!AttachMeshBinding(
*nodeObject,
model,
modelAssetRef,
meshBindings[node.meshBindingStart],
outErrorMessage,
&localResult.meshObjects)) {
return false;
}
continue;
}
const Containers::String nodeName = MakeNodeName(node, nodeIndex);
for (Core::uint32 meshBindingIndex = node.meshBindingStart; meshBindingIndex < meshBindingEnd; ++meshBindingIndex) {
GameObject* meshObject = scene.CreateGameObject(
MakeMeshObjectName(nodeName, meshBindingIndex - node.meshBindingStart).CStr(),
nodeObject);
if (meshObject == nullptr) {
SetErrorMessage(outErrorMessage, "Failed to create an auxiliary mesh binding game object.");
return false;
}
if (!AttachMeshBinding(
*meshObject,
model,
modelAssetRef,
meshBindings[meshBindingIndex],
outErrorMessage,
&localResult.meshObjects)) {
return false;
}
}
}
localResult.rootObject = localResult.nodeObjects[model.GetRootNodeIndex()];
if (outResult != nullptr) {
*outResult = std::move(localResult);
}
return true;
}
bool InstantiateModelHierarchy(
Components::Scene& scene,
const Containers::String& modelPath,
Components::GameObject* parent,
ModelSceneInstantiationResult* outResult,
Containers::String* outErrorMessage) {
ResourceManager& resourceManager = ResourceManager::Get();
const ResourceHandle<Resources::Model> modelHandle = resourceManager.Load<Resources::Model>(modelPath);
if (!modelHandle.IsValid()) {
SetErrorMessage(outErrorMessage, Containers::String("Failed to load model asset: ") + modelPath);
return false;
}
AssetRef modelAssetRef;
if (!resourceManager.TryGetAssetRef(modelPath, ResourceType::Model, modelAssetRef)) {
SetErrorMessage(outErrorMessage, Containers::String("Failed to resolve model asset ref: ") + modelPath);
return false;
}
return InstantiateModelHierarchy(scene, *modelHandle, modelAssetRef, parent, outResult, outErrorMessage);
}
} // namespace XCEngine

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: b728e58114669fc3dd9e6521bd22933a
folderAsset: true
importer: FolderImporter
importerVersion: 7

View File

@@ -0,0 +1,195 @@
# Nahida Dependency Map
This document captures the Unity sample semantics that must survive the port.
Source roots:
- Unity reference project: `mvs/NahidaRender`
- Engine project staging area: `project/Assets/Characters/Nahida`
## Texture groups
Body and Dress1:
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Diffuse.png`
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Lightmap.png`
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Normalmap.png`
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Shadow_Ramp.png`
Hair and Dress2:
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Diffuse.png`
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Lightmap.png`
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Normalmap.png`
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Shadow_Ramp.png`
Face and shared:
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Face_Diffuse.png`
- `Textures/Shared/Avatar_Loli_Tex_FaceLightmap.png`
- `Textures/Shared/Avatar_Tex_Face_Shadow.png`
- `Textures/Shared/Avatar_Tex_MetalMap.png`
Reference-only extra:
- `Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Specular_Ramp.png`
- Present in the sample resources.
- Not directly referenced by the seven Unity Nahida materials that were copied into `docs/reference/NahidaUnity/Materials`.
## Material graph
Shared base material:
- `docs/reference/NahidaUnity/Materials/Nahida_Base.mat`
- Uses `docs/reference/NahidaUnity/Shaders/Genshin.shader`
- Binds shared `_MetalMap = Textures/Shared/Avatar_Tex_MetalMap.png`
- Carries shared shadow, rim, outline, and specular defaults
Body branch:
- `docs/reference/NahidaUnity/Materials/Nahida_Body.mat`
- `_BaseMap = Body_Diffuse`
- `_LightMap = Body_Lightmap`
- `_NormalMap = Body_Normalmap`
- `_ShadowRamp = Body_Shadow_Ramp`
- Enables emission, normal map, rim, specular, and smooth normal
- `docs/reference/NahidaUnity/Materials/Nahida_Dress1.mat`
- Reuses the same body texture group
- Double-sided
- Uses smooth normal
Hair branch:
- `docs/reference/NahidaUnity/Materials/Nahida_Hair.mat`
- `_BaseMap = Hair_Diffuse`
- `_LightMap = Hair_Lightmap`
- `_NormalMap = Hair_Normalmap`
- `_ShadowRamp = Hair_Shadow_Ramp`
- Enables emission, normal map, rim, specular, and smooth normal
- `docs/reference/NahidaUnity/Materials/Nahida_Dress2.mat`
- Reuses the same hair texture group
- Double-sided
Face branch:
- `docs/reference/NahidaUnity/Materials/Nahida_Face.mat`
- `_BaseMap = Face_Diffuse`
- `_FaceLightMap = Avatar_Loli_Tex_FaceLightmap`
- `_FaceShadow = Avatar_Tex_Face_Shadow`
- `_ShadowRamp = Body_Shadow_Ramp`
- Marks `_IsFace = 1`
- Depends on runtime `_FaceDirection`
- `docs/reference/NahidaUnity/Materials/Nahida_Brow.mat`
- Reuses the same face texture group
- Uses a tint override through `_BaseColor`
- Sets outline width to `0`
## Shader semantics
Core shader files:
- `docs/reference/NahidaUnity/Shaders/Genshin.shader`
- `docs/reference/NahidaUnity/Shaders/GenshinInput.hlsl`
- `docs/reference/NahidaUnity/Shaders/GenshinForwardPass.hlsl`
- `docs/reference/NahidaUnity/Shaders/GenshinOutlinePass.hlsl`
Important shader features to preserve:
- `_LightMap` driven shadow partitioning
- `_FaceLightMap + _FaceShadow + _FaceDirection` face shading path
- `_MetalMap` driven specular/metal response
- Rim light
- Separate outline pass
- `_UseSmoothNormal` switch for outline data path
## Scene semantics from Unity sample
Reference scene:
- `docs/reference/NahidaUnity/Scenes/SampleScene.unity`
Observed setup:
- Instantiates `Avatar_Loli_Catalyst_Nahida.fbx` as a prefab instance
- Renames the root object to `NahidaUnityModel`
- Uses `SkinnedMeshRenderer`, not a static `MeshFilter + MeshRenderer` setup
- Overrides one renderer mesh with `Meshes/Nahida_Body_Smooth.mesh`
- Assigns body renderer material slots in this order:
- slot 0: `Nahida_Hair.mat`
- slot 1: `Nahida_Body.mat`
- slot 2: `Nahida_Dress1.mat`
- slot 3: `Nahida_Dress2.mat`
- Additional renderers use:
- `Nahida_Face.mat`
- `Nahida_Brow.mat`
- One face-related renderer writes `BlendShapeWeights[0] = 100`
## Current static assembly baseline in XCEngine
Preview scene:
- `Assets/Scenes/NahidaPreview.xc`
Current static assembly uses the existing imported FBX sub-meshes from:
- `Assets/Models/nahida/Avatar_Loli_Catalyst_Nahida.fbx`
Scene-side material path mapping in `Assets/Scenes/NahidaPreview.xc`:
- `Nahida_Hair`
- mesh localID: `5268898388415806497`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Hair.mat`
- `Nahida_Body`
- mesh localID: `5268897288904178286`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Body.mat`
- `Nahida_Dress1`
- mesh localID: `5268896189392550075`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Dress1.mat`
- `Nahida_Dress2`
- mesh localID: `5268895089880921864`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Dress2.mat`
- `Nahida_Brow`
- mesh localID: `15841426242793151682`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Brow.mat`
- `Nahida_EffectMesh`
- mesh localID: `692846506840157104`
- temporary fallback material: `builtin://materials/default-primitive`
- `Nahida_EyeStar`
- mesh localID: `8234240765526303311`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Face.mat`
- `Nahida_Face`
- mesh localID: `1306782875462705981`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Face.mat`
- `Nahida_FaceEye`
- mesh localID: `977130118610429631`
- engine material: `Assets/Characters/Nahida/Materials/Nahida_Face.mat`
Current engine-native shader and materials:
- Shader: `Assets/Shaders/XCCharacterToon.shader`
- Materials:
- `Assets/Characters/Nahida/Materials/Nahida_Hair.mat`
- `Assets/Characters/Nahida/Materials/Nahida_Body.mat`
- `Assets/Characters/Nahida/Materials/Nahida_Dress1.mat`
- `Assets/Characters/Nahida/Materials/Nahida_Dress2.mat`
- `Assets/Characters/Nahida/Materials/Nahida_Face.mat`
- `Assets/Characters/Nahida/Materials/Nahida_Brow.mat`
Current model material remap sidecars:
- `Assets/Models/nahida/Avatar_Loli_Catalyst_Nahida.fbx.materialmap`
- `Assets/Characters/Nahida/Model/Avatar_Loli_Catalyst_Nahida.fbx.materialmap`
Format:
- `meshLocalID=Assets/.../Material.mat`
- Multiple material slots can be provided with `|`
Current gaps:
- No dedicated outline pass yet.
- Face shading logic now matches the Unity-style forward path more closely, but `_FaceDirection` is still not driven at runtime.
## Runtime driver semantics
Runtime driver:
- `docs/reference/NahidaUnity/Scripts/MaterialUpdater.cs`
Behavior:
- Reads the head bone transform every frame
- Rotates the configured local head direction
- Writes `_FaceDirection` into every material on the configured face renderers
This means face shading is not static material data. The engine port needs a runtime material-parameter driver.
## Post-process reference
Reference files:
- `docs/reference/NahidaUnity/Shaders/URPGenshinPostProcess.shader`
- `docs/reference/NahidaUnity/Scripts/Rendering/`
- `docs/reference/NahidaUnity/URPSettings/`
These are kept only as semantic reference for a later phase. They should not be treated as engine-native runtime assets.

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 28da23fea182596ac7927a1f42a87cd8
folderAsset: false
importer: DefaultImporter
importerVersion: 7

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 725c8d70965b4172e982c891fbc0f6e6
folderAsset: true
importer: FolderImporter
importerVersion: 7

View File

@@ -0,0 +1,19 @@
{
"shader": "Assets/Shaders/XCCharacterToon.shader",
"renderQueue": "geometry",
"keywords": ["_EMISSION", "_NORMAL_MAP", "_RIM", "_SPECULAR"],
"properties": {
"_UseEmission": 1.0,
"_UseNormalMap": 1.0,
"_UseRim": 1.0,
"_UseSmoothNormal": 1.0,
"_UseSpecular": 1.0
},
"textures": {
"_BaseMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Diffuse.png",
"_LightMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Lightmap.png",
"_NormalMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Normalmap.png",
"_ShadowRamp": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Shadow_Ramp.png",
"_MetalMap": "Assets/Characters/Nahida/Textures/Shared/Avatar_Tex_MetalMap.png"
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 4e3f0d5ffce7442ab339368d71155d11
folderAsset: false
importer: MaterialImporter
importerVersion: 8

View File

@@ -0,0 +1,19 @@
{
"shader": "Assets/Shaders/XCCharacterToon.shader",
"renderQueue": "geometry",
"keywords": ["_IS_FACE", "_RIM"],
"properties": {
"_BaseColor": [0.9764706, 0.80103135, 0.76164705, 1.0],
"_FaceDirection": [-0.0051237254, -0.11509954, -0.99334073, 0.0],
"_IsFace": 1.0,
"_OutlineWidth": 0.0,
"_UseCustomMaterialType": 1.0,
"_UseRim": 1.0
},
"textures": {
"_BaseMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Face_Diffuse.png",
"_FaceLightMap": "Assets/Characters/Nahida/Textures/Shared/Avatar_Loli_Tex_FaceLightmap.png",
"_FaceShadow": "Assets/Characters/Nahida/Textures/Shared/Avatar_Tex_Face_Shadow.png",
"_ShadowRamp": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Shadow_Ramp.png"
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 4f1b23288e264487b6658db635991d0d
folderAsset: false
importer: MaterialImporter
importerVersion: 8

View File

@@ -0,0 +1,24 @@
{
"shader": "Assets/Shaders/XCCharacterToon.shader",
"renderQueue": "geometry",
"keywords": ["_EMISSION", "_NORMAL_MAP", "_RIM", "_SPECULAR"],
"renderState": {
"cull": "none"
},
"properties": {
"_DoubleSided": 1.0,
"_OutlineZOffset": 0.5,
"_UseEmission": 1.0,
"_UseNormalMap": 1.0,
"_UseRim": 1.0,
"_UseSmoothNormal": 1.0,
"_UseSpecular": 1.0
},
"textures": {
"_BaseMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Diffuse.png",
"_LightMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Lightmap.png",
"_NormalMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Normalmap.png",
"_ShadowRamp": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Shadow_Ramp.png",
"_MetalMap": "Assets/Characters/Nahida/Textures/Shared/Avatar_Tex_MetalMap.png"
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: cb6d6cf5229d47ecb22f5467b66d831f
folderAsset: false
importer: MaterialImporter
importerVersion: 8

View File

@@ -0,0 +1,23 @@
{
"shader": "Assets/Shaders/XCCharacterToon.shader",
"renderQueue": "geometry",
"keywords": ["_EMISSION", "_NORMAL_MAP", "_RIM", "_SPECULAR"],
"renderState": {
"cull": "none"
},
"properties": {
"_DoubleSided": 1.0,
"_OutlineZOffset": 0.5,
"_UseEmission": 1.0,
"_UseNormalMap": 1.0,
"_UseRim": 1.0,
"_UseSpecular": 1.0
},
"textures": {
"_BaseMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Diffuse.png",
"_LightMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Lightmap.png",
"_NormalMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Normalmap.png",
"_ShadowRamp": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Shadow_Ramp.png",
"_MetalMap": "Assets/Characters/Nahida/Textures/Shared/Avatar_Tex_MetalMap.png"
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: b05741f6efd64d40b18f33990be8fc8d
folderAsset: false
importer: MaterialImporter
importerVersion: 8

View File

@@ -0,0 +1,19 @@
{
"shader": "Assets/Shaders/XCCharacterToon.shader",
"renderQueue": "geometry",
"keywords": ["_IS_FACE", "_RIM"],
"properties": {
"_FaceDirection": [-0.0051237254, -0.11509954, -0.99334073, 0.0],
"_FaceBlushStrength": 0.3,
"_IsFace": 1.0,
"_OutlineZOffset": 0.5,
"_UseCustomMaterialType": 1.0,
"_UseRim": 1.0
},
"textures": {
"_BaseMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Face_Diffuse.png",
"_FaceLightMap": "Assets/Characters/Nahida/Textures/Shared/Avatar_Loli_Tex_FaceLightmap.png",
"_FaceShadow": "Assets/Characters/Nahida/Textures/Shared/Avatar_Tex_Face_Shadow.png",
"_ShadowRamp": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Body_Shadow_Ramp.png"
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: f8df278bf29d44ecb2f0ddcefe8f8c89
folderAsset: false
importer: MaterialImporter
importerVersion: 8

View File

@@ -0,0 +1,20 @@
{
"shader": "Assets/Shaders/XCCharacterToon.shader",
"renderQueue": "geometry",
"keywords": ["_EMISSION", "_NORMAL_MAP", "_RIM", "_SPECULAR"],
"properties": {
"_UseEmission": 1.0,
"_UseNormalMap": 1.0,
"_UseRim": 1.0,
"_UseSmoothNormal": 1.0,
"_UseSpecular": 1.0,
"_OutlineColor": [0.2784314, 0.18039216, 0.14901961, 1.0]
},
"textures": {
"_BaseMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Diffuse.png",
"_LightMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Lightmap.png",
"_NormalMap": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Normalmap.png",
"_ShadowRamp": "Assets/Characters/Nahida/Textures/Nahida/Avatar_Loli_Catalyst_Nahida_Tex_Hair_Shadow_Ramp.png",
"_MetalMap": "Assets/Characters/Nahida/Textures/Shared/Avatar_Tex_MetalMap.png"
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 9e4b6736db6d4fc1ab0ea6933043f422
folderAsset: false
importer: MaterialImporter
importerVersion: 8

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 344e873aa42dd08f92d5f330479a4a17
folderAsset: true
importer: FolderImporter
importerVersion: 7

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 1b61124cf698a5ef085f249b9fa733cd
folderAsset: false
importer: DefaultImporter
importerVersion: 7

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 545136f78545ba2ab47f94b9f88f11cd
folderAsset: true
importer: FolderImporter
importerVersion: 7

View File

@@ -0,0 +1,10 @@
# meshLocalID=materialPath[|materialPath...]
5268898388415806497=Assets/Characters/Nahida/Materials/Nahida_Hair.mat
5268897288904178286=Assets/Characters/Nahida/Materials/Nahida_Body.mat
5268896189392550075=Assets/Characters/Nahida/Materials/Nahida_Dress1.mat
5268895089880921864=Assets/Characters/Nahida/Materials/Nahida_Dress2.mat
15841426242793151682=Assets/Characters/Nahida/Materials/Nahida_Brow.mat
692846506840157104=builtin://materials/default-primitive
8234240765526303311=Assets/Characters/Nahida/Materials/Nahida_Face.mat
1306782875462705981=Assets/Characters/Nahida/Materials/Nahida_Face.mat
977130118610429631=Assets/Characters/Nahida/Materials/Nahida_Face.mat

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: c59d5039bc5145a5dfdbdf8a2b301d2d
folderAsset: false
importer: DefaultImporter
importerVersion: 7

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 9c225844db0e8be81d1887d23a25fb6d
folderAsset: false
importer: ModelImporter
importerVersion: 12

View File

@@ -0,0 +1,49 @@
# Nahida Staging Assets
This folder is the non-breaking staging area for migrating the Unity Nahida sample into the engine-native project structure.
Current safety rule:
- Keep `project/Assets/Models/nahida` in place for now.
- `project/Assets/Scenes/Main.xc` still references the old imported FBX asset GUID from `Assets/Models/nahida/Avatar_Loli_Catalyst_Nahida.fbx`.
- Do not rename or delete the old Nahida folder until a `Model`-driven scene path is ready.
Directory layout:
- `Model/`
- Canonical FBX source that should become the future `Model` entry asset.
- `Textures/Nahida/`
- Character-local body, hair, and face textures copied from the Unity sample.
- `Textures/Shared/`
- Shared textures used by the shader and face shading path.
- `Meshes/`
- Extra mesh data needed by the outline path.
- `docs/reference/NahidaUnity/`
- Unity-side semantic reference only.
- Stored outside `project/Assets` on purpose so the asset pipeline does not try to import Unity `.mat` and `.shader` files as engine-native assets.
What has been staged in this phase:
- Full Nahida FBX source.
- Full body, hair, face, and shared texture set.
- `Nahida_Body_Smooth.mesh` outline dependency.
- Unity reference materials, shader files, runtime scripts, sample scene, and URP settings under `docs/reference/NahidaUnity/`.
What has not been done yet:
- No skinned runtime path, animator path, or blend-shape port.
- No dedicated outline pass port yet.
- Face shading now follows the Unity-style `_FaceLightMap + _FaceShadow + _FaceDirection` path in the forward shader, but it still does not have Unity's runtime `_FaceDirection` driver.
- The preview scene still uses imported FBX sub-meshes, but the main character materials now point at engine-native `.mat` assets.
- The main character materials now use Unity-aligned property names, keywords, and normal-map bindings, but they still render through a single forward pass in the current engine pipeline.
Current FBX material remap bridge:
- `Model` instantiation now looks for a sidecar file named `<model>.materialmap`.
- Each line maps an imported mesh localID to one or more material paths:
- `meshLocalID=Assets/.../Material.mat`
- Multiple slots can be provided with `|`
- Nahida remap files are staged at:
- `Assets/Models/nahida/Avatar_Loli_Catalyst_Nahida.fbx.materialmap`
- `Assets/Characters/Nahida/Model/Avatar_Loli_Catalyst_Nahida.fbx.materialmap`
Recommended next implementation order:
1. Visually validate `Assets/Scenes/NahidaPreview.xc` and tune framing/light balance against the Unity reference scene.
2. Add a proper outline solution without introducing skinning work.
3. Add a lightweight runtime `_FaceDirection` material driver for static preview scenes.
4. Decide whether the static preview should keep the full instantiated FBX node tree or collapse helper/bone-only nodes for readability.

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 113b61db9e093c2f322baac3044d87c4
folderAsset: false
importer: DefaultImporter
importerVersion: 7

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 9c357f39264001a509e920d6fe32a964
folderAsset: true
importer: FolderImporter
importerVersion: 7

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: ad60afb17f902c3354694f6ed843f338
folderAsset: true
importer: FolderImporter
importerVersion: 7

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: c7b42a277c1cf135d5e9cb6ee524c503
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 58ccdf0abb398c5c9788bf8b3cf504d2
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 3cd3aafb26fc8002b87fd2530da1d15b
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 33e3526cdaec1749e8ceb79a4ca75a18
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: e51d3a747171881244c074abee912603
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: ab5235785541c991a236d0003f000989
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 9734cd1fe5cd61f0b3b2a04993d36dd3
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 29eb407776ceb38fa6463e2d6938d808
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 7122f1b290e26ea8fe7c68a63f5003c2
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: c76b66d7f54717749fb4ac684872b94a
folderAsset: false
importer: TextureImporter
importerVersion: 9

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: bd5012ae058d3240669b1e2278f780f9
folderAsset: true
importer: FolderImporter
importerVersion: 7

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: d97403a58e720b96269ee683519ee167
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: e632e900c73bd6cdc4c51951e1ab02b6
folderAsset: false
importer: TextureImporter
importerVersion: 9

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: b1928595729c1c6863c89d17b054288b
folderAsset: false
importer: TextureImporter
importerVersion: 9

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 0ef5176ec0a14e2d9f9d1201ca5a2d5a
folderAsset: false
importer: DefaultImporter
importerVersion: 7

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 1f85e3636b3f31e874d42632b48eee0a
folderAsset: true
importer: FolderImporter
importerVersion: 7

View File

@@ -0,0 +1,583 @@
Shader "XC Character Toon"
{
Properties
{
_BaseMap ("Base Map", 2D) = "white" [Semantic(BaseColorTexture)]
_BaseColor ("Base Color", Color) = (1,1,1,1) [Semantic(BaseColor)]
_Cutoff ("Alpha Cutoff", Range) = 0.5 [Semantic(AlphaCutoff)]
_IsDay ("Is Day", Float) = 1
_DoubleSided ("Double Sided", Float) = 0
_LightMap ("Light Map", 2D) = "white"
_LightDirectionMultiplier ("Light Direction Multiplier", Vector) = (1,0.5,1,0)
_ShadowOffset ("Shadow Offset", Float) = 0.1
_ShadowSmoothness ("Shadow Smoothness", Float) = 0.4
_ShadowColor ("Shadow Color", Color) = (1.1,1.1,1.1,1)
_ShadowRamp ("Shadow Ramp", 2D) = "white"
_UseCustomMaterialType ("Use Custom Material Type", Float) = 0
_CustomMaterialType ("Custom Material Type", Float) = 1
_UseEmission ("Use Emission", Float) = 0
_EmissionIntensity ("Emission Intensity", Float) = 0.2
_UseNormalMap ("Use Normal Map", Float) = 0
_NormalMap ("Normal Map", 2D) = "bump"
_IsFace ("Is Face", Float) = 0
_FaceDirection ("Face Direction", Vector) = (0,0,1,0)
_FaceShadowOffset ("Face Shadow Offset", Float) = 0
_FaceBlushColor ("Face Blush Color", Color) = (1,0.72156864,0.69803923,1)
_FaceBlushStrength ("Face Blush Strength", Float) = 0
_FaceLightMap ("Face Light Map", 2D) = "white"
_FaceShadow ("Face Shadow", 2D) = "white"
_UseSpecular ("Use Specular", Float) = 0
_SpecularSmoothness ("Specular Smoothness", Float) = 5
_NonmetallicIntensity ("Nonmetallic Intensity", Float) = 0.3
_MetallicIntensity ("Metallic Intensity", Float) = 8
_MetalMap ("Metal Map", 2D) = "white"
_UseRim ("Use Rim", Float) = 0
_RimOffset ("Rim Offset", Float) = 5
_RimThreshold ("Rim Threshold", Float) = 0.5
_RimIntensity ("Rim Intensity", Float) = 0.5
_UseSmoothNormal ("Use Smooth Normal", Float) = 0
_OutlineWidth ("Outline Width", Float) = 1.6
_OutlineWidthParams ("Outline Width Params", Vector) = (0,6,0.1,0.6)
_OutlineZOffset ("Outline Z Offset", Float) = 0.1
_ScreenOffset ("Screen Offset", Vector) = (0,0,0,0)
_OutlineColor ("Outline Color", Color) = (0.5176471,0.35686275,0.34117648,1)
_OutlineColor2 ("Outline Color 2", Color) = (0.3529412,0.3529412,0.3529412,1)
_OutlineColor3 ("Outline Color 3", Color) = (0.47058824,0.47058824,0.5647059,1)
_OutlineColor4 ("Outline Color 4", Color) = (0.5176471,0.35686275,0.34117648,1)
_OutlineColor5 ("Outline Color 5", Color) = (0.35,0.35,0.35,1)
}
HLSLINCLUDE
cbuffer PerObjectConstants
{
float4x4 gProjectionMatrix;
float4x4 gViewMatrix;
float4x4 gModelMatrix;
float4x4 gNormalMatrix;
};
static const int XC_MAX_ADDITIONAL_LIGHTS = 8;
struct AdditionalLightData
{
float4 colorAndIntensity;
float4 positionAndRange;
float4 directionAndType;
float4 spotAnglesAndFlags;
};
cbuffer LightingConstants
{
float4 gMainLightDirectionAndIntensity;
float4 gMainLightColorAndFlags;
float4 gLightingParams;
AdditionalLightData gAdditionalLights[XC_MAX_ADDITIONAL_LIGHTS];
};
cbuffer MaterialConstants
{
float4 _BaseColor;
float4 _Cutoff;
float4 _IsDay;
float4 _DoubleSided;
float4 _LightDirectionMultiplier;
float4 _ShadowOffset;
float4 _ShadowSmoothness;
float4 _ShadowColor;
float4 _UseCustomMaterialType;
float4 _CustomMaterialType;
float4 _UseEmission;
float4 _EmissionIntensity;
float4 _UseNormalMap;
float4 _IsFace;
float4 _FaceDirection;
float4 _FaceShadowOffset;
float4 _FaceBlushColor;
float4 _FaceBlushStrength;
float4 _UseSpecular;
float4 _SpecularSmoothness;
float4 _NonmetallicIntensity;
float4 _MetallicIntensity;
float4 _UseRim;
float4 _RimOffset;
float4 _RimThreshold;
float4 _RimIntensity;
float4 _UseSmoothNormal;
float4 _OutlineWidth;
float4 _OutlineWidthParams;
float4 _OutlineZOffset;
float4 _ScreenOffset;
float4 _OutlineColor;
float4 _OutlineColor2;
float4 _OutlineColor3;
float4 _OutlineColor4;
float4 _OutlineColor5;
};
cbuffer ShadowReceiverConstants
{
float4x4 gWorldToShadowMatrix;
float4 gShadowBiasAndTexelSize;
float4 gShadowOptions;
};
Texture2D BaseColorTexture;
Texture2D _LightMap;
Texture2D _ShadowRamp;
Texture2D _NormalMap;
Texture2D _FaceLightMap;
Texture2D _FaceShadow;
Texture2D _MetalMap;
SamplerState LinearClampSampler;
Texture2D ShadowMapTexture;
SamplerState ShadowMapSampler;
struct VSInput
{
float3 position : POSITION;
float3 normal : NORMAL;
float4 color : COLOR;
float2 texcoord : TEXCOORD0;
float2 backTexcoord : TEXCOORD1;
float3 tangent : TEXCOORD2;
float3 bitangent : TEXCOORD3;
};
struct PSInput
{
float4 position : SV_POSITION;
float2 texcoord : TEXCOORD0;
float2 backTexcoord : TEXCOORD1;
float3 positionWS : TEXCOORD2;
float3 positionVS : TEXCOORD3;
float3 normalWS : TEXCOORD4;
float3 tangentWS : TEXCOORD5;
float3 bitangentWS : TEXCOORD6;
float4 color : COLOR;
};
float3 NormalizeSafe(float3 value, float3 fallbackValue)
{
const float lengthSq = dot(value, value);
if (lengthSq <= 1e-6f) {
return fallbackValue;
}
return value * rsqrt(lengthSq);
}
bool UseEmissionFeature()
{
#ifdef _EMISSION
return true;
#else
return _UseEmission.x > 0.5f;
#endif
}
bool UseNormalMapFeature()
{
#ifdef _NORMAL_MAP
return true;
#else
return _UseNormalMap.x > 0.5f;
#endif
}
bool UseFaceFeature()
{
#ifdef _IS_FACE
return true;
#else
return _IsFace.x > 0.5f;
#endif
}
bool UseSpecularFeature()
{
#ifdef _SPECULAR
return true;
#else
return _UseSpecular.x > 0.5f;
#endif
}
bool UseRimFeature()
{
#ifdef _RIM
return true;
#else
return _UseRim.x > 0.5f;
#endif
}
bool UseDoubleSidedFeature()
{
#ifdef _DOUBLE_SIDED
return true;
#else
return _DoubleSided.x > 0.5f;
#endif
}
float2 ResolveMaterialTexcoord(PSInput input, bool isFrontFace)
{
if (!UseDoubleSidedFeature()) {
return input.texcoord;
}
return lerp(input.texcoord, input.backTexcoord, isFrontFace ? 0.0f : 1.0f);
}
PSInput MainVS(VSInput input)
{
PSInput output;
const float4 positionWS = mul(gModelMatrix, float4(input.position, 1.0f));
const float4 positionVS = mul(gViewMatrix, positionWS);
output.position = mul(gProjectionMatrix, positionVS);
output.texcoord = input.texcoord;
output.backTexcoord = input.backTexcoord;
output.positionWS = positionWS.xyz;
output.positionVS = positionVS.xyz;
output.normalWS = normalize(mul((float3x3)gNormalMatrix, input.normal));
output.tangentWS = normalize(mul((float3x3)gNormalMatrix, input.tangent));
output.bitangentWS = normalize(mul((float3x3)gNormalMatrix, input.bitangent));
output.color = input.color;
return output;
}
float ComputeShadowAttenuation(float3 positionWS, float3 normalWS, float3 lightDirectionWS)
{
#ifndef XC_MAIN_LIGHT_SHADOWS
return 1.0f;
#else
if (gShadowOptions.x < 0.5f) {
return 1.0f;
}
const float3 resolvedNormalWS = NormalizeSafe(normalWS, float3(0.0f, 1.0f, 0.0f));
const float3 resolvedLightDirectionWS =
NormalizeSafe(lightDirectionWS, float3(0.0f, -1.0f, 0.0f));
const float nDotL = saturate(dot(resolvedNormalWS, resolvedLightDirectionWS));
const float normalBiasWorld = gShadowOptions.y * gShadowOptions.z * (1.0f - nDotL);
const float3 shadowPositionWS = positionWS + resolvedNormalWS * normalBiasWorld;
const float4 shadowClip = mul(gWorldToShadowMatrix, float4(shadowPositionWS, 1.0f));
if (shadowClip.w <= 0.0f) {
return 1.0f;
}
const float3 shadowNdc = shadowClip.xyz / shadowClip.w;
#if UNITY_UV_STARTS_AT_TOP
const float shadowUvY = shadowNdc.y * -0.5f + 0.5f;
#else
const float shadowUvY = shadowNdc.y * 0.5f + 0.5f;
#endif
const float2 shadowUv = float2(shadowNdc.x * 0.5f + 0.5f, shadowUvY);
#if UNITY_NEAR_CLIP_VALUE < 0
if (shadowUv.x < 0.0f || shadowUv.x > 1.0f ||
shadowUv.y < 0.0f || shadowUv.y > 1.0f ||
shadowNdc.z < -1.0f || shadowNdc.z > 1.0f) {
return 1.0f;
}
const float receiverDepth = shadowNdc.z * 0.5f + 0.5f - gShadowBiasAndTexelSize.x;
#else
if (shadowUv.x < 0.0f || shadowUv.x > 1.0f ||
shadowUv.y < 0.0f || shadowUv.y > 1.0f ||
shadowNdc.z < 0.0f || shadowNdc.z > 1.0f) {
return 1.0f;
}
const float receiverDepth = shadowNdc.z - gShadowBiasAndTexelSize.x;
#endif
const float2 shadowTexelSize = gShadowBiasAndTexelSize.yz;
float visibility = 0.0f;
[unroll]
for (int offsetY = -1; offsetY <= 1; ++offsetY) {
[unroll]
for (int offsetX = -1; offsetX <= 1; ++offsetX) {
const float2 sampleUv =
shadowUv + float2((float)offsetX, (float)offsetY) * shadowTexelSize;
if (sampleUv.x < 0.0f || sampleUv.x > 1.0f ||
sampleUv.y < 0.0f || sampleUv.y > 1.0f) {
visibility += 1.0f;
continue;
}
const float shadowDepth = ShadowMapTexture.Sample(ShadowMapSampler, sampleUv).r;
visibility += receiverDepth <= shadowDepth ? 1.0f : 0.0f;
}
}
visibility *= (1.0f / 9.0f);
const float shadowStrength = saturate(gShadowBiasAndTexelSize.w);
return lerp(1.0f - shadowStrength, 1.0f, visibility);
#endif
}
float ComputeRangeAttenuation(float distanceSq, float range)
{
if (range <= 0.0f) {
return 0.0f;
}
const float clampedRange = max(range, 0.0001f);
const float rangeSq = clampedRange * clampedRange;
if (distanceSq >= rangeSq) {
return 0.0f;
}
const float distance = sqrt(max(distanceSq, 0.0f));
const float normalized = saturate(1.0f - distance / clampedRange);
return normalized * normalized;
}
float ComputeSpotAttenuation(AdditionalLightData light, float3 directionToLightWS)
{
const float cosOuter = light.spotAnglesAndFlags.x;
const float cosInner = light.spotAnglesAndFlags.y;
const float3 spotAxisToLightWS = NormalizeSafe(light.directionAndType.xyz, float3(0.0f, -1.0f, 0.0f));
const float cosTheta = dot(spotAxisToLightWS, directionToLightWS);
return saturate((cosTheta - cosOuter) / max(cosInner - cosOuter, 1e-4f));
}
float3 EvaluateAdditionalLight(AdditionalLightData light, float3 normalWS, float3 positionWS)
{
const float lightType = light.directionAndType.w;
const float3 lightColor = light.colorAndIntensity.rgb;
const float lightIntensity = light.colorAndIntensity.w;
float3 directionToLightWS = float3(0.0f, 0.0f, 0.0f);
float attenuation = 1.0f;
if (lightType < 0.5f) {
directionToLightWS = NormalizeSafe(light.directionAndType.xyz, float3(0.0f, -1.0f, 0.0f));
} else {
const float3 lightVectorWS = light.positionAndRange.xyz - positionWS;
const float distanceSq = dot(lightVectorWS, lightVectorWS);
if (distanceSq <= 1e-6f) {
return float3(0.0f, 0.0f, 0.0f);
}
directionToLightWS = lightVectorWS * rsqrt(distanceSq);
attenuation = ComputeRangeAttenuation(distanceSq, light.positionAndRange.w);
if (attenuation <= 0.0f) {
return float3(0.0f, 0.0f, 0.0f);
}
if (lightType > 1.5f) {
attenuation *= ComputeSpotAttenuation(light, directionToLightWS);
if (attenuation <= 0.0f) {
return float3(0.0f, 0.0f, 0.0f);
}
}
}
const float diffuse = saturate(dot(normalWS, directionToLightWS));
if (diffuse <= 0.0f) {
return float3(0.0f, 0.0f, 0.0f);
}
return lightColor * (diffuse * lightIntensity * attenuation);
}
float3 ComputeSurfaceNormalWS(PSInput input, float2 texcoord)
{
float3 normalWS = NormalizeSafe(input.normalWS, float3(0.0f, 1.0f, 0.0f));
if (!UseNormalMapFeature()) {
return normalWS;
}
const float3 tangentWS = NormalizeSafe(input.tangentWS, float3(1.0f, 0.0f, 0.0f));
const float3 bitangentWS = NormalizeSafe(input.bitangentWS, float3(0.0f, 0.0f, 1.0f));
const float3 normalTS = _NormalMap.Sample(LinearClampSampler, texcoord).xyz * 2.0f - 1.0f;
const float3 mappedNormalWS =
tangentWS * normalTS.x +
bitangentWS * normalTS.y +
normalWS * normalTS.z;
return NormalizeSafe(mappedNormalWS, normalWS);
}
float3 GetAdjustedMainLightDirection()
{
const float3 scaledDirection =
gMainLightDirectionAndIntensity.xyz * _LightDirectionMultiplier.xyz;
return NormalizeSafe(
scaledDirection,
NormalizeSafe(gMainLightDirectionAndIntensity.xyz, float3(0.0f, -1.0f, 0.0f)));
}
float ResolveMaterialSelector(float lightMapAlpha)
{
const float useCustomMaterialType = _UseCustomMaterialType.x > 0.5f ? 1.0f : 0.0f;
return lerp(lightMapAlpha, _CustomMaterialType.x, useCustomMaterialType);
}
float GetGenericShadow(float3 normalWS, float3 lightDirectionWS, float aoFactor)
{
const float ndotl = dot(normalWS, lightDirectionWS);
const float halfLambert = ndotl * 0.5f + 0.5f;
const float shadow = saturate(2.0f * halfLambert * aoFactor);
return lerp(shadow, 1.0f, step(0.9f, aoFactor));
}
float GetFaceShadow(PSInput input, float2 texcoord, float3 lightDirectionWS)
{
const float3 faceDirection = NormalizeSafe(
float3(_FaceDirection.x, 0.0f, _FaceDirection.z),
float3(0.0f, 0.0f, 1.0f));
const float3 flatLightDirection = NormalizeSafe(
float3(lightDirectionWS.x, 0.0f, lightDirectionWS.z),
float3(0.0f, 0.0f, 1.0f));
const float faceDotLight = dot(faceDirection, flatLightDirection);
const float faceCrossLight = cross(faceDirection, flatLightDirection).y;
float2 shadowUv = texcoord;
shadowUv.x = lerp(shadowUv.x, 1.0f - shadowUv.x, step(0.0f, faceCrossLight));
const float faceShadowMap = _FaceLightMap.Sample(LinearClampSampler, shadowUv).r;
const float faceShadow = step(-0.5f * faceDotLight + 0.5f + _FaceShadowOffset.x, faceShadowMap);
const float faceMask = _FaceShadow.Sample(LinearClampSampler, texcoord).a;
return lerp(faceShadow, 1.0f, faceMask);
}
float3 GetShadowColor(float shadow, float materialSelector)
{
float rampIndex = 4.0f;
rampIndex = lerp(rampIndex, 1.0f, step(0.2f, materialSelector));
rampIndex = lerp(rampIndex, 2.0f, step(0.4f, materialSelector));
rampIndex = lerp(rampIndex, 0.0f, step(0.6f, materialSelector));
rampIndex = lerp(rampIndex, 3.0f, step(0.8f, materialSelector));
const float rangeMin = 0.5f + _ShadowOffset.x - _ShadowSmoothness.x;
const float rangeMax = 0.5f + _ShadowOffset.x;
const float2 rampUv = float2(
smoothstep(rangeMin, rangeMax, shadow),
rampIndex / 10.0f + 0.5f * saturate(_IsDay.x) + 0.05f);
const float3 shadowRamp = _ShadowRamp.Sample(LinearClampSampler, rampUv).rgb;
float3 shadowColor =
shadowRamp * lerp(_ShadowColor.rgb, float3(1.0f, 1.0f, 1.0f), smoothstep(0.9f, 1.0f, rampUv.x));
shadowColor = lerp(shadowColor, float3(1.0f, 1.0f, 1.0f), step(rangeMax, shadow));
return shadowColor;
}
float3 GetSpecular(
PSInput input,
float3 normalWS,
float3 lightDirectionWS,
float3 albedo,
float3 lightMap)
{
const float3 viewDirectionVS = NormalizeSafe(-input.positionVS, float3(0.0f, 0.0f, 1.0f));
const float3 lightDirectionVS = NormalizeSafe(
mul((float3x3)gViewMatrix, lightDirectionWS),
float3(0.0f, 0.0f, 1.0f));
const float3 normalVS = NormalizeSafe(mul((float3x3)gViewMatrix, normalWS), float3(0.0f, 0.0f, 1.0f));
const float3 halfDirectionVS = NormalizeSafe(lightDirectionVS + viewDirectionVS, lightDirectionVS);
const float nDotH = dot(normalVS, halfDirectionVS);
const float blinnPhong = pow(saturate(nDotH), max(_SpecularSmoothness.x, 1.0f));
const float2 matcapUv = normalVS.xy * 0.5f + 0.5f;
const float3 metalMap = _MetalMap.Sample(LinearClampSampler, matcapUv).rgb;
const float3 nonMetallic =
step(1.1f, lightMap.b + blinnPhong) * lightMap.r * _NonmetallicIntensity.x;
const float3 metallic =
blinnPhong * lightMap.b * albedo * metalMap * _MetallicIntensity.x;
return lerp(nonMetallic, metallic, step(0.9f, lightMap.r));
}
float GetRim(PSInput input, float3 normalWS)
{
const float3 viewDirectionVS = NormalizeSafe(-input.positionVS, float3(0.0f, 0.0f, 1.0f));
const float3 normalVS = NormalizeSafe(mul((float3x3)gViewMatrix, normalWS), float3(0.0f, 0.0f, 1.0f));
const float nDotV = dot(normalVS, viewDirectionVS);
const float fresnel = pow(saturate(1.0f - nDotV), max(_RimOffset.x, 0.001f));
return smoothstep(0.0f, max(_RimThreshold.x, 0.001f), fresnel) * _RimIntensity.x;
}
float4 MainPS(PSInput input, bool isFrontFace : SV_IsFrontFace) : SV_TARGET
{
const float2 texcoord = ResolveMaterialTexcoord(input, isFrontFace);
float4 baseMap = BaseColorTexture.Sample(LinearClampSampler, texcoord);
float3 albedo = baseMap.rgb * _BaseColor.rgb;
const float alphaMask = baseMap.a;
if (UseFaceFeature()) {
albedo = lerp(albedo, _FaceBlushColor.rgb, _FaceBlushStrength.x * alphaMask);
}
const float3 normalWS = ComputeSurfaceNormalWS(input, texcoord);
float3 color = albedo;
if (gMainLightColorAndFlags.a >= 0.5f) {
const float3 lightDirectionWS = GetAdjustedMainLightDirection();
const float4 lightMap = _LightMap.Sample(LinearClampSampler, texcoord);
const float materialSelector = ResolveMaterialSelector(lightMap.a);
float shadow = UseFaceFeature()
? GetFaceShadow(input, texcoord, lightDirectionWS)
: GetGenericShadow(normalWS, lightDirectionWS, lightMap.g * input.color.x);
shadow *= ComputeShadowAttenuation(input.positionWS, normalWS, lightDirectionWS);
const float3 shadowColor = GetShadowColor(shadow, materialSelector);
float3 specular = float3(0.0f, 0.0f, 0.0f);
if (UseSpecularFeature()) {
specular = GetSpecular(input, normalWS, lightDirectionWS, albedo, lightMap.rgb);
}
float3 emission = float3(0.0f, 0.0f, 0.0f);
if (UseEmissionFeature()) {
emission = albedo * _EmissionIntensity.x * alphaMask;
}
float3 rim = float3(0.0f, 0.0f, 0.0f);
if (UseRimFeature()) {
rim = albedo * GetRim(input, normalWS);
}
color = albedo * shadowColor + specular + emission + rim;
}
const int additionalLightCount = min((int)gLightingParams.x, XC_MAX_ADDITIONAL_LIGHTS);
[unroll]
for (int lightIndex = 0; lightIndex < XC_MAX_ADDITIONAL_LIGHTS; ++lightIndex) {
if (lightIndex >= additionalLightCount) {
break;
}
color +=
albedo *
EvaluateAdditionalLight(gAdditionalLights[lightIndex], normalWS, input.positionWS) *
0.15f;
}
return float4(saturate(color), 1.0f);
}
ENDHLSL
SubShader
{
Cull Back
ZWrite On
ZTest LEqual
Pass
{
Name "ForwardLit"
Tags { "LightMode" = "ForwardLit" }
HLSLPROGRAM
#pragma target 4.5
#pragma vertex MainVS
#pragma fragment MainPS
#pragma multi_compile _ XC_MAIN_LIGHT_SHADOWS
#pragma shader_feature_local _ XC_ALPHA_TEST
#pragma shader_feature_local _ _EMISSION
#pragma shader_feature_local _ _NORMAL_MAP
#pragma shader_feature_local _ _IS_FACE
#pragma shader_feature_local _ _SPECULAR
#pragma shader_feature_local _ _RIM
ENDHLSL
}
UsePass "Builtin Depth Only/DepthOnly"
UsePass "Builtin Shadow Caster/ShadowCaster"
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: 5a51f782e3c149bd8bf65c8b0e25d3a1
folderAsset: false
importer: ShaderImporter
importerVersion: 8

View File

@@ -147,6 +147,48 @@ TEST(MeshFilterComponent_Test, SetMeshPathPreservesPathWithoutLoadedResource) {
EXPECT_EQ(component.GetMesh(), nullptr);
}
TEST(MeshFilterComponent_Test, SetMeshAssetRefPreservesProjectSubAssetReference) {
namespace fs = std::filesystem;
ResourceManager& manager = ResourceManager::Get();
manager.Initialize();
const fs::path projectRoot = fs::temp_directory_path() / "xc_mesh_filter_asset_ref_test";
const fs::path assetsDir = projectRoot / "Assets";
const fs::path meshPath = assetsDir / "runtime.mesh";
fs::remove_all(projectRoot);
fs::create_directories(assetsDir);
{
std::ofstream meshFile(meshPath);
ASSERT_TRUE(meshFile.is_open());
meshFile << "placeholder";
}
manager.SetResourceRoot(projectRoot.string().c_str());
AssetRef meshRef;
ASSERT_TRUE(manager.TryGetAssetRef("Assets/runtime.mesh", ResourceType::Mesh, meshRef));
MeshFilterComponent component;
component.SetMeshAssetRef(meshRef);
EXPECT_TRUE(component.GetMeshAssetRef().IsValid());
EXPECT_EQ(component.GetMeshAssetRef().assetGuid, meshRef.assetGuid);
EXPECT_EQ(component.GetMeshAssetRef().localID, meshRef.localID);
EXPECT_EQ(component.GetMeshAssetRef().resourceType, meshRef.resourceType);
EXPECT_EQ(component.GetMeshPath(), "Assets/runtime.mesh");
std::stringstream stream;
component.Serialize(stream);
EXPECT_NE(stream.str().find("meshRef="), std::string::npos);
EXPECT_EQ(stream.str().find("meshRef=;"), std::string::npos);
manager.SetResourceRoot("");
manager.Shutdown();
fs::remove_all(projectRoot);
}
TEST(MeshFilterComponent_Test, DeferredSceneDeserializeLoadsMeshAsyncByPath) {
ResourceManager& manager = ResourceManager::Get();
manager.Initialize();
@@ -276,6 +318,56 @@ TEST(MeshRendererComponent_Test, SetMaterialPathPreservesPathWithoutLoadedResour
EXPECT_EQ(component.GetMaterial(1), nullptr);
}
TEST(MeshRendererComponent_Test, SetMaterialAssetRefPreservesProjectSubAssetReference) {
namespace fs = std::filesystem;
ResourceManager& manager = ResourceManager::Get();
manager.Initialize();
const fs::path projectRoot = fs::temp_directory_path() / "xc_mesh_renderer_sub_asset_ref_test";
const fs::path assetsDir = projectRoot / "Assets";
const fs::path materialPath = assetsDir / "runtime.material";
fs::remove_all(projectRoot);
fs::create_directories(assetsDir);
{
std::ofstream materialFile(materialPath);
ASSERT_TRUE(materialFile.is_open());
materialFile << "{\n";
materialFile << " \"renderQueue\": \"geometry\",\n";
materialFile << " \"renderState\": {\n";
materialFile << " \"cull\": \"back\"\n";
materialFile << " }\n";
materialFile << "}";
}
manager.SetResourceRoot(projectRoot.string().c_str());
AssetRef materialRef;
ASSERT_TRUE(manager.TryGetAssetRef("Assets/runtime.material", ResourceType::Material, materialRef));
MeshRendererComponent component;
component.SetMaterialAssetRef(0, materialRef);
ASSERT_EQ(component.GetMaterialCount(), 1u);
EXPECT_TRUE(component.GetMaterialAssetRefs()[0].IsValid());
EXPECT_EQ(component.GetMaterialAssetRefs()[0].assetGuid, materialRef.assetGuid);
EXPECT_EQ(component.GetMaterialAssetRefs()[0].localID, materialRef.localID);
EXPECT_EQ(component.GetMaterialAssetRefs()[0].resourceType, materialRef.resourceType);
EXPECT_EQ(component.GetMaterialPath(0), "Assets/runtime.material");
ASSERT_NE(component.GetMaterial(0), nullptr);
std::stringstream stream;
component.Serialize(stream);
EXPECT_NE(stream.str().find("materialRefs="), std::string::npos);
EXPECT_EQ(stream.str().find("materialRefs=;"), std::string::npos);
manager.SetResourceRoot("");
manager.Shutdown();
fs::remove_all(projectRoot);
}
TEST(MeshRendererComponent_Test, DeserializeIgnoresPlainMaterialPathsWithoutAssetRefs) {
MeshRendererComponent target;

View File

@@ -26,6 +26,7 @@ add_custom_target(rendering_integration_tests
rendering_integration_offscreen_scene
rendering_integration_skybox_scene
rendering_integration_post_process_scene
rendering_integration_nahida_preview_scene
)
add_custom_target(rendering_all_tests

View File

@@ -25,3 +25,4 @@ add_subdirectory(volume_scene)
add_subdirectory(volume_occlusion_scene)
add_subdirectory(volume_transform_scene)
add_subdirectory(gaussian_splat_scene)
add_subdirectory(nahida_preview_scene)

View File

@@ -0,0 +1,71 @@
cmake_minimum_required(VERSION 3.15)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
project(rendering_integration_nahida_preview_scene)
set(ENGINE_ROOT_DIR ${CMAKE_SOURCE_DIR}/engine)
set(PACKAGE_DIR ${CMAKE_SOURCE_DIR}/mvs/OpenGL/package)
get_filename_component(PROJECT_ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../.. ABSOLUTE)
find_package(Vulkan QUIET)
add_executable(rendering_integration_nahida_preview_scene
main.cpp
${CMAKE_SOURCE_DIR}/tests/RHI/integration/fixtures/RHIIntegrationFixture.cpp
${PACKAGE_DIR}/src/glad.c
)
target_include_directories(rendering_integration_nahida_preview_scene PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/tests/RHI/integration/fixtures
${ENGINE_ROOT_DIR}/include
${PACKAGE_DIR}/include
${PROJECT_ROOT_DIR}/engine/src
)
target_link_libraries(rendering_integration_nahida_preview_scene PRIVATE
d3d12
dxgi
d3dcompiler
winmm
opengl32
XCEngine
GTest::gtest
)
if(TARGET Vulkan::Vulkan)
target_link_libraries(rendering_integration_nahida_preview_scene PRIVATE Vulkan::Vulkan)
target_compile_definitions(rendering_integration_nahida_preview_scene PRIVATE XCENGINE_SUPPORT_VULKAN)
endif()
target_compile_definitions(rendering_integration_nahida_preview_scene PRIVATE
UNICODE
_UNICODE
XCENGINE_SUPPORT_OPENGL
XCENGINE_SUPPORT_D3D12
)
add_custom_command(TARGET rendering_integration_nahida_preview_scene POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_SOURCE_DIR}/tests/RHI/integration/compare_ppm.py
$<TARGET_FILE_DIR:rendering_integration_nahida_preview_scene>/
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${ENGINE_ROOT_DIR}/third_party/renderdoc/renderdoc.dll
$<TARGET_FILE_DIR:rendering_integration_nahida_preview_scene>/
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${ENGINE_ROOT_DIR}/third_party/assimp/bin/assimp-vc143-mt.dll
$<TARGET_FILE_DIR:rendering_integration_nahida_preview_scene>/
)
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/GT.ppm)
add_custom_command(TARGET rendering_integration_nahida_preview_scene POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/GT.ppm
$<TARGET_FILE_DIR:rendering_integration_nahida_preview_scene>/GT.ppm
)
endif()
include(GoogleTest)
gtest_discover_tests(rendering_integration_nahida_preview_scene)

View File

@@ -185,7 +185,7 @@ void DumpMeshDiagnostics(const char* label, const Mesh* mesh) {
const StaticMeshVertex& firstVertex = vertices[0];
std::printf(
"[NahidaDiag] %s firstVertex pos=(%.4f, %.4f, %.4f) normal=(%.4f, %.4f, %.4f) tangent=(%.4f, %.4f, %.4f) bitangent=(%.4f, %.4f, %.4f) uv=(%.4f, %.4f)\n",
"[NahidaDiag] %s firstVertex pos=(%.4f, %.4f, %.4f) normal=(%.4f, %.4f, %.4f) tangent=(%.4f, %.4f, %.4f) bitangent=(%.4f, %.4f, %.4f) uv0=(%.4f, %.4f) uv1=(%.4f, %.4f) color=(%.4f, %.4f, %.4f, %.4f)\n",
label,
firstVertex.position.x,
firstVertex.position.y,
@@ -200,7 +200,13 @@ void DumpMeshDiagnostics(const char* label, const Mesh* mesh) {
firstVertex.bitangent.y,
firstVertex.bitangent.z,
firstVertex.uv0.x,
firstVertex.uv0.y);
firstVertex.uv0.y,
firstVertex.uv1.x,
firstVertex.uv1.y,
firstVertex.color.x,
firstVertex.color.y,
firstVertex.color.z,
firstVertex.color.w);
std::printf(
"[NahidaDiag] %s uvRange min=(%.4f, %.4f) max=(%.4f, %.4f)\n",
label,
@@ -356,6 +362,16 @@ void NahidaPreviewSceneTest::SetUp() {
ASSERT_NE(m_scene->Find("Face"), nullptr);
PreloadSceneResources();
auto* bodyMeshObject = m_scene->Find("Body_Mesh0");
ASSERT_NE(bodyMeshObject, nullptr);
auto* bodyMeshFilter = bodyMeshObject->GetComponent<MeshFilterComponent>();
ASSERT_NE(bodyMeshFilter, nullptr);
Mesh* bodyMesh = bodyMeshFilter->GetMesh();
ASSERT_NE(bodyMesh, nullptr);
EXPECT_TRUE(HasVertexAttribute(bodyMesh->GetVertexAttributes(), VertexAttribute::Color));
EXPECT_TRUE(HasVertexAttribute(bodyMesh->GetVertexAttributes(), VertexAttribute::UV1));
ApplyIsolationFilter();
ApplyDiagnosticOverrides();
DumpTargetDiagnostics();

View File

@@ -135,7 +135,7 @@ private:
TEST(BuiltinForwardPipeline_Test, UsesFloat3PositionInputLayoutForStaticMeshVertices) {
const InputLayoutDesc inputLayout = BuiltinForwardPipeline::BuildInputLayout();
ASSERT_EQ(inputLayout.elements.size(), 5u);
ASSERT_EQ(inputLayout.elements.size(), 7u);
const InputElementDesc& position = inputLayout.elements[0];
EXPECT_EQ(position.semanticName, "POSITION");
@@ -158,19 +158,33 @@ TEST(BuiltinForwardPipeline_Test, UsesFloat3PositionInputLayoutForStaticMeshVert
EXPECT_EQ(texcoord.inputSlot, 0u);
EXPECT_EQ(texcoord.alignedByteOffset, static_cast<uint32_t>(offsetof(StaticMeshVertex, uv0)));
const InputElementDesc& tangent = inputLayout.elements[3];
const InputElementDesc& backTexcoord = inputLayout.elements[3];
EXPECT_EQ(backTexcoord.semanticName, "TEXCOORD");
EXPECT_EQ(backTexcoord.semanticIndex, 1u);
EXPECT_EQ(backTexcoord.format, static_cast<uint32_t>(Format::R32G32_Float));
EXPECT_EQ(backTexcoord.inputSlot, 0u);
EXPECT_EQ(backTexcoord.alignedByteOffset, static_cast<uint32_t>(offsetof(StaticMeshVertex, uv1)));
const InputElementDesc& tangent = inputLayout.elements[4];
EXPECT_EQ(tangent.semanticName, "TEXCOORD");
EXPECT_EQ(tangent.semanticIndex, 1u);
EXPECT_EQ(tangent.semanticIndex, 2u);
EXPECT_EQ(tangent.format, static_cast<uint32_t>(Format::R32G32B32_Float));
EXPECT_EQ(tangent.inputSlot, 0u);
EXPECT_EQ(tangent.alignedByteOffset, static_cast<uint32_t>(offsetof(StaticMeshVertex, tangent)));
const InputElementDesc& bitangent = inputLayout.elements[4];
const InputElementDesc& bitangent = inputLayout.elements[5];
EXPECT_EQ(bitangent.semanticName, "TEXCOORD");
EXPECT_EQ(bitangent.semanticIndex, 2u);
EXPECT_EQ(bitangent.semanticIndex, 3u);
EXPECT_EQ(bitangent.format, static_cast<uint32_t>(Format::R32G32B32_Float));
EXPECT_EQ(bitangent.inputSlot, 0u);
EXPECT_EQ(bitangent.alignedByteOffset, static_cast<uint32_t>(offsetof(StaticMeshVertex, bitangent)));
const InputElementDesc& color = inputLayout.elements[6];
EXPECT_EQ(color.semanticName, "COLOR");
EXPECT_EQ(color.semanticIndex, 0u);
EXPECT_EQ(color.format, static_cast<uint32_t>(Format::R32G32B32A32_Float));
EXPECT_EQ(color.inputSlot, 0u);
EXPECT_EQ(color.alignedByteOffset, static_cast<uint32_t>(offsetof(StaticMeshVertex, color)));
}
TEST(RenderSurfacePipelineUtils_Test, ResolvesContiguousSurfaceAttachmentFormatsIntoPipelineDesc) {

View File

@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include <XCEngine/Core/Asset/AssetDatabase.h>
#include <XCEngine/Core/Asset/ArtifactContainer.h>
#include <XCEngine/Core/Asset/ResourceManager.h>
#include <XCEngine/Resources/BuiltinResources.h>
#include <XCEngine/Resources/Material/MaterialLoader.h>
@@ -304,13 +305,19 @@ TEST(MeshLoader, ProjectBackpackSampleArtifactRetainsSectionMaterialTextures) {
AssetDatabase::ResolvedAsset 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");
EXPECT_EQ(fs::path(resolvedAsset.artifactMainPath.CStr()).extension().string(), ".xcmodel");
const fs::path meshArtifactPath = fs::path(resolvedAsset.artifactDirectory.CStr()) / "mesh_0.xcmesh";
ASSERT_TRUE(fs::exists(meshArtifactPath));
const String meshArtifactPath =
BuildArtifactContainerEntryPath(resolvedAsset.artifactMainPath, "mesh_0.xcmesh");
XCEngine::Containers::Array<XCEngine::Core::uint8> payload;
ASSERT_TRUE(ReadArtifactContainerEntryPayload(
resolvedAsset.artifactMainPath,
"mesh_0.xcmesh",
ResourceType::Mesh,
payload));
MeshLoader loader;
const LoadResult result = loader.Load(meshArtifactPath.string().c_str());
const LoadResult result = loader.Load(meshArtifactPath);
ASSERT_TRUE(result);
ASSERT_NE(result.resource, nullptr);
@@ -331,6 +338,60 @@ TEST(MeshLoader, ProjectBackpackSampleArtifactRetainsSectionMaterialTextures) {
manager.Shutdown();
}
TEST(MeshLoader, ProjectNahidaSampleMarksFallbackUv1WhenSecondaryChannelIsAbsent) {
namespace fs = std::filesystem;
const fs::path repositoryRoot = GetRepositoryRoot();
const fs::path projectRoot = repositoryRoot / "project";
const fs::path nahidaModelPath =
projectRoot / "Assets" / "Characters" / "Nahida" / "Model" / "Avatar_Loli_Catalyst_Nahida.fbx";
const fs::path assimpDllPath = repositoryRoot / "engine" / "third_party" / "assimp" / "bin" / "assimp-vc143-mt.dll";
if (!fs::exists(nahidaModelPath)) {
GTEST_SKIP() << "Nahida sample model is not available in the local project fixture.";
}
ASSERT_TRUE(fs::exists(assimpDllPath));
#ifdef _WIN32
struct DllGuard {
HMODULE module = nullptr;
~DllGuard() {
if (module != nullptr) {
FreeLibrary(module);
}
}
} dllGuard;
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());
MeshLoader loader;
const LoadResult result = loader.Load(nahidaModelPath.string().c_str());
ASSERT_TRUE(result);
ASSERT_NE(result.resource, nullptr);
auto* mesh = static_cast<Mesh*>(result.resource);
ASSERT_NE(mesh, nullptr);
EXPECT_TRUE(HasVertexAttribute(mesh->GetVertexAttributes(), VertexAttribute::UV0));
EXPECT_TRUE(HasVertexAttribute(mesh->GetVertexAttributes(), VertexAttribute::UV1));
EXPECT_TRUE(HasVertexAttribute(mesh->GetVertexAttributes(), VertexAttribute::Color));
const auto* vertices = static_cast<const StaticMeshVertex*>(mesh->GetVertexData());
ASSERT_NE(vertices, nullptr);
ASSERT_GT(mesh->GetVertexCount(), 0u);
EXPECT_FLOAT_EQ(vertices[0].uv0.x, vertices[0].uv1.x);
EXPECT_FLOAT_EQ(vertices[0].uv0.y, vertices[0].uv1.y);
delete mesh;
manager.SetResourceRoot("");
manager.Shutdown();
}
TEST(MeshLoader, AssetDatabaseCreatesModelArtifactAndReusesItWithoutReimport) {
namespace fs = std::filesystem;
using namespace std::chrono_literals;
@@ -378,22 +439,35 @@ TEST(MeshLoader, AssetDatabaseCreatesModelArtifactAndReusesItWithoutReimport) {
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_EQ(fs::path(firstResolve.artifactMainPath.CStr()).extension().string(), ".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(projectRoot / "Library" / "assets.db"));
EXPECT_TRUE(fs::exists(projectRoot / "Library" / "artifacts.db"));
EXPECT_TRUE(fs::exists(firstResolve.artifactMainPath.CStr()));
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")));
const String meshArtifactPath =
BuildArtifactContainerEntryPath(firstResolve.artifactMainPath, "mesh_0.xcmesh");
const String materialArtifactPath = BuildArtifactContainerEntryPath(
firstResolve.artifactMainPath,
String(("material_" + std::to_string(sourceMaterialIndex) + ".xcmat").c_str()));
XCEngine::Containers::Array<XCEngine::Core::uint8> artifactPayload;
EXPECT_TRUE(ReadArtifactContainerEntryPayload(
firstResolve.artifactMainPath,
"mesh_0.xcmesh",
ResourceType::Mesh,
artifactPayload));
EXPECT_TRUE(ReadArtifactContainerEntryPayload(
firstResolve.artifactMainPath,
String(("material_" + std::to_string(sourceMaterialIndex) + ".xcmat").c_str()),
ResourceType::Material,
artifactPayload));
EXPECT_TRUE(ReadArtifactContainerEntryPayload(
firstResolve.artifactMainPath,
"texture_0.xctex",
ResourceType::Texture,
artifactPayload));
MaterialLoader materialLoader;
LoadResult materialArtifactResult =
materialLoader.Load(
(fs::path(firstResolve.artifactDirectory.CStr()) /
("material_" + std::to_string(sourceMaterialIndex) + ".xcmat")).string().c_str());
LoadResult materialArtifactResult = materialLoader.Load(materialArtifactPath);
ASSERT_TRUE(materialArtifactResult);
ASSERT_NE(materialArtifactResult.resource, nullptr);
auto* artifactMaterial = static_cast<Material*>(materialArtifactResult.resource);
@@ -414,8 +488,7 @@ TEST(MeshLoader, AssetDatabaseCreatesModelArtifactAndReusesItWithoutReimport) {
delete artifactMaterial;
MeshLoader meshLoader;
LoadResult meshArtifactResult =
meshLoader.Load((fs::path(firstResolve.artifactDirectory.CStr()) / "mesh_0.xcmesh").string().c_str());
LoadResult meshArtifactResult = meshLoader.Load(meshArtifactPath);
ASSERT_TRUE(meshArtifactResult);
ASSERT_NE(meshArtifactResult.resource, nullptr);
auto* artifactMesh = static_cast<Mesh*>(meshArtifactResult.resource);
@@ -566,7 +639,13 @@ TEST(MeshLoader, ResourceManagerLoadsImportedMeshSubAssetByAssetRefFromProjectAs
ASSERT_TRUE(meshAssetRef.IsValid());
ASSERT_TRUE(manager.TryResolveAssetPath(meshAssetRef, resolvedMeshPath));
EXPECT_EQ(fs::path(resolvedMeshPath.CStr()).filename().string(), "mesh_0.xcmesh");
String resolvedContainerPath;
String resolvedEntryName;
ASSERT_TRUE(TryParseArtifactContainerEntryPath(
resolvedMeshPath,
resolvedContainerPath,
resolvedEntryName));
EXPECT_EQ(resolvedEntryName, "mesh_0.xcmesh");
}
manager.UnloadAll();

View File

@@ -6,6 +6,7 @@ set(MODEL_TEST_SOURCES
test_model.cpp
test_model_loader.cpp
test_model_import_pipeline.cpp
test_model_scene_instantiation.cpp
)
add_executable(model_tests ${MODEL_TEST_SOURCES})

View File

@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <XCEngine/Core/Asset/AssetDatabase.h>
#include <XCEngine/Core/Asset/ArtifactContainer.h>
#include <XCEngine/Core/Asset/ResourceManager.h>
#include <XCEngine/Resources/Mesh/Mesh.h>
#include <XCEngine/Resources/Mesh/MeshLoader.h>
@@ -85,11 +86,24 @@ TEST(ModelImportPipeline, AssetDatabaseImportsObjAsModelArtifact) {
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_EQ(fs::path(resolvedAsset.artifactMainPath.CStr()).extension().string(), ".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"));
XCEngine::Containers::Array<XCEngine::Core::uint8> payload;
EXPECT_TRUE(ReadArtifactContainerEntryPayload(
resolvedAsset.artifactMainPath,
"mesh_0.xcmesh",
ResourceType::Mesh,
payload));
EXPECT_TRUE(ReadArtifactContainerEntryPayload(
resolvedAsset.artifactMainPath,
"material_0.xcmat",
ResourceType::Material,
payload));
EXPECT_TRUE(ReadArtifactContainerEntryPayload(
resolvedAsset.artifactMainPath,
"texture_0.xctex",
ResourceType::Texture,
payload));
ModelLoader modelLoader;
const LoadResult modelResult = modelLoader.Load(resolvedAsset.artifactMainPath);
@@ -109,7 +123,8 @@ TEST(ModelImportPipeline, AssetDatabaseImportsObjAsModelArtifact) {
MeshLoader meshLoader;
const LoadResult meshResult =
meshLoader.Load((fs::path(resolvedAsset.artifactDirectory.CStr()) / "mesh_0.xcmesh").string().c_str());
meshLoader.Load(
BuildArtifactContainerEntryPath(resolvedAsset.artifactMainPath, "mesh_0.xcmesh"));
ASSERT_TRUE(meshResult);
ASSERT_NE(meshResult.resource, nullptr);
@@ -182,4 +197,64 @@ TEST(ModelImportPipeline, ResourceManagerLoadsModelFromProjectAsset) {
fs::remove_all(projectRoot);
}
TEST(ModelImportPipeline, ProjectNahidaSampleArtifactPreservesFallbackUv1Semantic) {
namespace fs = std::filesystem;
const fs::path repositoryRoot = GetRepositoryRoot();
const fs::path projectRoot = repositoryRoot / "project";
const fs::path nahidaModelPath =
projectRoot / "Assets" / "Characters" / "Nahida" / "Model" / "Avatar_Loli_Catalyst_Nahida.fbx";
const fs::path assimpDllPath = repositoryRoot / "engine" / "third_party" / "assimp" / "bin" / "assimp-vc143-mt.dll";
if (!fs::exists(nahidaModelPath)) {
GTEST_SKIP() << "Nahida sample model is not available in the local project fixture.";
}
ASSERT_TRUE(fs::exists(assimpDllPath));
#ifdef _WIN32
AssimpDllGuard dllGuard;
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());
AssetDatabase database;
database.Initialize(projectRoot.string().c_str());
AssetDatabase::ResolvedAsset resolvedAsset;
ASSERT_TRUE(database.EnsureArtifact(
"Assets/Characters/Nahida/Model/Avatar_Loli_Catalyst_Nahida.fbx",
ResourceType::Model,
resolvedAsset));
ASSERT_TRUE(resolvedAsset.artifactReady);
EXPECT_EQ(fs::path(resolvedAsset.artifactMainPath.CStr()).extension().string(), ".xcmodel");
MeshLoader meshLoader;
const LoadResult meshResult =
meshLoader.Load(BuildArtifactContainerEntryPath(resolvedAsset.artifactMainPath, "mesh_0.xcmesh"));
ASSERT_TRUE(meshResult);
ASSERT_NE(meshResult.resource, nullptr);
auto* mesh = static_cast<Mesh*>(meshResult.resource);
ASSERT_NE(mesh, nullptr);
EXPECT_TRUE(HasVertexAttribute(mesh->GetVertexAttributes(), VertexAttribute::UV0));
EXPECT_TRUE(HasVertexAttribute(mesh->GetVertexAttributes(), VertexAttribute::UV1));
EXPECT_TRUE(HasVertexAttribute(mesh->GetVertexAttributes(), VertexAttribute::Color));
const auto* vertices = static_cast<const StaticMeshVertex*>(mesh->GetVertexData());
ASSERT_NE(vertices, nullptr);
ASSERT_GT(mesh->GetVertexCount(), 0u);
EXPECT_FLOAT_EQ(vertices[0].uv0.x, vertices[0].uv1.x);
EXPECT_FLOAT_EQ(vertices[0].uv0.y, vertices[0].uv1.y);
delete mesh;
database.Shutdown();
manager.SetResourceRoot("");
manager.Shutdown();
}
} // namespace

View File

@@ -0,0 +1,173 @@
#include <gtest/gtest.h>
#include <XCEngine/Components/MeshFilterComponent.h>
#include <XCEngine/Components/MeshRendererComponent.h>
#include <XCEngine/Core/Asset/AssetRef.h>
#include <XCEngine/Core/Asset/ResourceManager.h>
#include <XCEngine/Resources/Model/Model.h>
#include <XCEngine/Scene/ModelSceneInstantiation.h>
#include <XCEngine/Scene/Scene.h>
#include <filesystem>
#include <fstream>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
using namespace XCEngine;
using namespace XCEngine::Components;
using namespace XCEngine::Resources;
namespace {
std::filesystem::path GetRepositoryRoot() {
return std::filesystem::path(__FILE__).parent_path().parent_path().parent_path().parent_path();
}
std::string GetMeshFixturePath(const char* fileName) {
return (std::filesystem::path(XCENGINE_TEST_FIXTURES_DIR) / "Resources" / "Mesh" / fileName).string();
}
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(ModelSceneInstantiation, BuildsHierarchyFromManualModelGraph) {
Scene scene("Instantiation Scene");
Model model;
IResource::ConstructParams params = {};
params.name = "ManualModel";
params.path = "Assets/manual_model.fbx";
params.guid = ResourceGUID::Generate(params.path);
model.Initialize(params);
ModelNode rootNode;
rootNode.name = "Root";
rootNode.parentIndex = -1;
rootNode.localPosition = Math::Vector3(1.0f, 2.0f, 3.0f);
ModelNode childNode;
childNode.name = "Child";
childNode.parentIndex = 0;
childNode.localPosition = Math::Vector3(4.0f, 5.0f, 6.0f);
childNode.localScale = Math::Vector3(2.0f, 2.0f, 2.0f);
model.AddNode(rootNode);
model.AddNode(childNode);
model.SetRootNodeIndex(0u);
ModelSceneInstantiationResult result;
Containers::String errorMessage;
ASSERT_TRUE(InstantiateModelHierarchy(scene, model, AssetRef(), nullptr, &result, &errorMessage))
<< errorMessage.CStr();
ASSERT_NE(result.rootObject, nullptr);
EXPECT_EQ(result.rootObject->GetName(), "Root");
ASSERT_EQ(result.nodeObjects.size(), 2u);
ASSERT_NE(result.nodeObjects[1], nullptr);
EXPECT_EQ(result.nodeObjects[1]->GetParent(), result.rootObject);
EXPECT_EQ(result.rootObject->GetTransform()->GetLocalPosition(), Math::Vector3(1.0f, 2.0f, 3.0f));
EXPECT_EQ(result.nodeObjects[1]->GetTransform()->GetLocalPosition(), Math::Vector3(4.0f, 5.0f, 6.0f));
EXPECT_EQ(result.nodeObjects[1]->GetTransform()->GetLocalScale(), Math::Vector3(2.0f, 2.0f, 2.0f));
}
TEST(ModelSceneInstantiation, RestoresMeshAndMaterialSubAssetRefsFromImportedModel) {
namespace fs = std::filesystem;
const fs::path projectRoot = fs::temp_directory_path() / "xc_model_scene_instantiation";
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<Model>("Assets/textured_triangle.obj");
ASSERT_TRUE(modelHandle.IsValid());
AssetRef modelAssetRef;
ASSERT_TRUE(manager.TryGetAssetRef("Assets/textured_triangle.obj", ResourceType::Model, modelAssetRef));
Scene scene("Instantiation Scene");
ModelSceneInstantiationResult result;
Containers::String errorMessage;
ASSERT_TRUE(InstantiateModelHierarchy(scene, *modelHandle, modelAssetRef, nullptr, &result, &errorMessage))
<< errorMessage.CStr();
ASSERT_NE(result.rootObject, nullptr);
ASSERT_FALSE(result.meshObjects.empty());
auto* meshObject = result.meshObjects.front();
ASSERT_NE(meshObject, nullptr);
auto* meshFilter = meshObject->GetComponent<MeshFilterComponent>();
auto* meshRenderer = meshObject->GetComponent<MeshRendererComponent>();
ASSERT_NE(meshFilter, nullptr);
ASSERT_NE(meshRenderer, nullptr);
ASSERT_NE(meshFilter->GetMesh(), nullptr);
ASSERT_EQ(meshRenderer->GetMaterialCount(), 1u);
ASSERT_NE(meshRenderer->GetMaterial(0), nullptr);
const AssetRef& meshRef = meshFilter->GetMeshAssetRef();
EXPECT_TRUE(meshRef.IsValid());
EXPECT_EQ(meshRef.assetGuid, modelAssetRef.assetGuid);
EXPECT_EQ(meshRef.localID, modelHandle->GetMeshBindings()[0].meshLocalID);
EXPECT_EQ(meshRef.resourceType, ResourceType::Mesh);
ASSERT_EQ(meshRenderer->GetMaterialAssetRefs().size(), 1u);
const AssetRef& materialRef = meshRenderer->GetMaterialAssetRefs()[0];
EXPECT_TRUE(materialRef.IsValid());
EXPECT_EQ(materialRef.assetGuid, modelAssetRef.assetGuid);
EXPECT_EQ(materialRef.localID, modelHandle->GetMaterialBindings()[0].materialLocalID);
EXPECT_EQ(materialRef.resourceType, ResourceType::Material);
const std::string serializedScene = scene.SerializeToString();
EXPECT_NE(serializedScene.find("meshRef="), std::string::npos);
EXPECT_EQ(serializedScene.find("meshRef=;"), std::string::npos);
EXPECT_NE(serializedScene.find("materialRefs="), std::string::npos);
EXPECT_EQ(serializedScene.find("materialRefs=;"), std::string::npos);
manager.SetResourceRoot("");
manager.Shutdown();
fs::remove_all(projectRoot);
}
} // namespace