diff --git a/CMakeLists.txt b/CMakeLists.txt index f9d031e..cbf2adf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,7 @@ if (TARGET _internal_validate_engine_shaders) add_dependencies(ValidateShaders _internal_validate_engine_shaders) endif() +destrum_enable_warnings(destrum_assets) destrum_enable_warnings(destrum) destrum_enable_warnings(lightkeeper) diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index 2e02fc9..1a5e3f1 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -1,5 +1,35 @@ add_subdirectory(third_party) +# CPU side asset types and the cooked model format, shared with TheChef (no Vulkan / SDL dependencies) +add_library(destrum_assets + "src/Assets/ModelFile.cpp" +) + +add_library(destrum::assets ALIAS destrum_assets) + +set_target_properties(destrum_assets PROPERTIES + CXX_STANDARD 20 + CXX_EXTENSIONS OFF +) + +target_include_directories(destrum_assets PUBLIC "${CMAKE_CURRENT_LIST_DIR}/include") + +target_link_libraries(destrum_assets + PUBLIC + glm::glm + assimp + spdlog::spdlog +) + +target_compile_definitions(destrum_assets + PUBLIC + GLM_FORCE_CTOR_INIT + GLM_FORCE_XYZW_ONLY + GLM_FORCE_EXPLICIT_CTOR + GLM_FORCE_DEPTH_ZERO_TO_ONE + GLM_ENABLE_EXPERIMENTAL +) + set(SRC_FILES "src/App.cpp" "src/Event.cpp" @@ -100,6 +130,8 @@ target_include_directories(destrum PRIVATE target_link_libraries(destrum PUBLIC + destrum_assets + volk::volk_headers vk-bootstrap::vk-bootstrap GPUOpen::VulkanMemoryAllocator @@ -137,15 +169,6 @@ else () target_link_libraries(destrum PUBLIC SDL2::SDL2-static) endif () -target_compile_definitions(destrum - PUBLIC - GLM_FORCE_CTOR_INIT - GLM_FORCE_XYZW_ONLY - GLM_FORCE_EXPLICIT_CTOR - GLM_FORCE_DEPTH_ZERO_TO_ONE - GLM_ENABLE_EXPERIMENTAL -) - set(ASSETS_SRC_DIR "${CMAKE_CURRENT_LIST_DIR}/assets_src") set(ASSETS_RUNTIME_DIR "${CMAKE_CURRENT_LIST_DIR}/assets_runtime") #set(OUTPUT_ENGINE_ASSETS_DIR "${CMAKE_BINARY_DIR}/assets/engine") @@ -229,7 +252,7 @@ function(destrum_cook_engine_assets GAME_TARGET GAME_OUTPUT_DIR) install(DIRECTORY "${_engine_runtime}/" DESTINATION "bin/assets/engine") endfunction() -install(TARGETS destrum +install(TARGETS destrum destrum_assets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin) diff --git a/destrum/include/destrum/Assets/ModelFile.h b/destrum/include/destrum/Assets/ModelFile.h new file mode 100644 index 0000000..23b2547 --- /dev/null +++ b/destrum/include/destrum/Assets/ModelFile.h @@ -0,0 +1,23 @@ +#ifndef DESTRUM_MODELFILE_H +#define DESTRUM_MODELFILE_H + +#include +#include +#include +#include +#include + +#include + +namespace ModelFile { + inline constexpr std::uint32_t Version = 1; + inline constexpr std::string_view Extension = ".dmodel"; + + [[nodiscard]] std::vector Write(const ModelDoc::ModelAsset& model); + [[nodiscard]] ModelDoc::ModelAsset Read(std::span bytes); + + void WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path); + [[nodiscard]] ModelDoc::ModelAsset ReadFromFile(const std::filesystem::path& path); +} + +#endif // DESTRUM_MODELFILE_H diff --git a/destrum/include/destrum/Graphics/Resources/CPUMesh.h b/destrum/include/destrum/Graphics/Resources/CPUMesh.h new file mode 100644 index 0000000..6a78a46 --- /dev/null +++ b/destrum/include/destrum/Graphics/Resources/CPUMesh.h @@ -0,0 +1,43 @@ +#ifndef CPUMESH_H +#define CPUMESH_H + +#include +#include +#include +#include + +#include +#include + +#include +#include + +// ─── CPU Mesh ───────────────────────────────────────────────────────────────── +struct CPUMesh { + struct Vertex { + glm::vec3 position; + float uv_x{}; + glm::vec3 normal; + float uv_y{}; + glm::vec4 tangent; + }; + + struct SkinningData { + glm::vec<4, std::uint32_t> jointIds; + glm::vec4 weights; + }; + + std::vector indices; + std::vector vertices; + std::vector skinningData; // empty if no skeleton + + std::string name; + AssetReference assetReference; + + glm::vec3 minPos; + glm::vec3 maxPos; + + std::optional skeleton; // present only for skinned meshes +}; + +#endif // CPUMESH_H diff --git a/destrum/include/destrum/Graphics/Resources/Mesh.h b/destrum/include/destrum/Graphics/Resources/Mesh.h index 96d96aa..d4ddbdc 100644 --- a/destrum/include/destrum/Graphics/Resources/Mesh.h +++ b/destrum/include/destrum/Graphics/Resources/Mesh.h @@ -13,37 +13,8 @@ #include #include +#include #include -#include -#include - -// ─── CPU Mesh ───────────────────────────────────────────────────────────────── -struct CPUMesh { - struct Vertex { - glm::vec3 position; - float uv_x{}; - glm::vec3 normal; - float uv_y{}; - glm::vec4 tangent; - }; - - struct SkinningData { - glm::vec<4, std::uint32_t> jointIds; - glm::vec4 weights; - }; - - std::vector indices; - std::vector vertices; - std::vector skinningData; // empty if no skeleton - - std::string name; - AssetReference assetReference; - - glm::vec3 minPos; - glm::vec3 maxPos; - - std::optional skeleton; // present only for skinned meshes -}; // ─── GPU Mesh ───────────────────────────────────────────────────────────────── diff --git a/destrum/include/destrum/Util/ModelDoc.h b/destrum/include/destrum/Util/ModelDoc.h index 703b172..a3ac2c9 100644 --- a/destrum/include/destrum/Util/ModelDoc.h +++ b/destrum/include/destrum/Util/ModelDoc.h @@ -30,7 +30,9 @@ #include #include -#include +#include + +#include #include #include #include diff --git a/destrum/src/Assets/ModelFile.cpp b/destrum/src/Assets/ModelFile.cpp new file mode 100644 index 0000000..aaa44ad --- /dev/null +++ b/destrum/src/Assets/ModelFile.cpp @@ -0,0 +1,444 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + static_assert(std::endian::native == std::endian::little, "Model files are stored little endian"); + + static_assert(sizeof(CPUMesh::Vertex) == 48); + static_assert(sizeof(CPUMesh::SkinningData) == 32); + static_assert(sizeof(SkeletalAnimation::Keyframe) == 44); + static_assert(sizeof(glm::mat4) == 64); + + constexpr std::array Magic{'D', 'M', 'D', 'L'}; + + template + concept RawCopyable = std::is_trivially_copyable_v; + + class BinaryWriter { + public: + template + void Write(const T& value) { + const auto* bytes = reinterpret_cast(&value); + m_Bytes.insert(m_Bytes.end(), bytes, bytes + sizeof(T)); + } + + template + void WriteArray(const std::vector& values) { + WriteCount(values.size()); + const auto* bytes = reinterpret_cast(values.data()); + m_Bytes.insert(m_Bytes.end(), bytes, bytes + values.size() * sizeof(T)); + } + + void WriteCount(std::size_t count) { + Write(static_cast(count)); + } + + void WriteBool(bool value) { + Write(static_cast(value ? 1 : 0)); + } + + void WriteString(std::string_view value) { + WriteCount(value.size()); + m_Bytes.insert(m_Bytes.end(), value.begin(), value.end()); + } + + void WriteStrings(const std::vector& values) { + WriteCount(values.size()); + for (const std::string& value : values) { + WriteString(value); + } + } + + [[nodiscard]] std::vector TakeBytes() { + return std::move(m_Bytes); + } + + private: + std::vector m_Bytes; + }; + + class BinaryReader { + public: + explicit BinaryReader(std::span bytes) + : m_Bytes(bytes) { + } + + template + [[nodiscard]] T Read() { + T value{}; + std::memcpy(&value, Take(sizeof(T)).data(), sizeof(T)); + return value; + } + + template + [[nodiscard]] std::vector ReadArray() { + const std::size_t count = ReadCount(sizeof(T)); + std::vector values(count); + if (count > 0) { + std::memcpy(values.data(), Take(count * sizeof(T)).data(), count * sizeof(T)); + } + return values; + } + + // Every element takes at least minElementSize bytes, which keeps a corrupt count + // from allocating far more than the file could ever contain + [[nodiscard]] std::size_t ReadCount(std::size_t minElementSize) { + const auto count = Read(); + if (count > Remaining() / minElementSize) { + throw std::runtime_error("Model file is corrupt: element count is out of range"); + } + return static_cast(count); + } + + [[nodiscard]] bool ReadBool() { + return Read() != 0; + } + + [[nodiscard]] std::string ReadString() { + const std::size_t length = ReadCount(1); + const auto bytes = Take(length); + return std::string(bytes.begin(), bytes.end()); + } + + [[nodiscard]] std::vector ReadStrings() { + std::vector values(ReadCount(sizeof(std::uint64_t))); + for (std::string& value : values) { + value = ReadString(); + } + return values; + } + + [[nodiscard]] bool AtEnd() const { + return m_Offset == m_Bytes.size(); + } + + private: + [[nodiscard]] std::size_t Remaining() const { + return m_Bytes.size() - m_Offset; + } + + [[nodiscard]] std::span Take(std::size_t size) { + if (size > Remaining()) { + throw std::runtime_error("Model file is truncated"); + } + const auto bytes = m_Bytes.subspan(m_Offset, size); + m_Offset += size; + return bytes; + } + + std::span m_Bytes; + std::size_t m_Offset{0}; + }; + + void WriteAssetReference(BinaryWriter& writer, const AssetReference& reference) { + writer.WriteString(reference.path); + writer.WriteString(reference.subresource); + } + + AssetReference ReadAssetReference(BinaryReader& reader) { + AssetReference reference; + reference.path = reader.ReadString(); + reference.subresource = reader.ReadString(); + return reference; + } + + void WriteSkeleton(BinaryWriter& writer, const Skeleton& skeleton) { + WriteAssetReference(writer, skeleton.assetReference); + + writer.WriteCount(skeleton.hierarchy.size()); + for (const Skeleton::JointNode& node : skeleton.hierarchy) { + writer.Write(node.id); + writer.WriteArray(node.children); + } + + writer.WriteArray(skeleton.inverseBindMatrices); + + // Joint has padding after its id, so it's written field by field + writer.WriteCount(skeleton.joints.size()); + for (const Joint& joint : skeleton.joints) { + writer.Write(joint.id); + writer.Write(joint.localTranslation); + writer.Write(joint.localRotation); + writer.Write(joint.localScale); + } + + writer.WriteStrings(skeleton.jointNames); + writer.WriteArray(skeleton.parentIndex); + writer.Write(skeleton.rootPreTransform); + } + + Skeleton ReadSkeleton(BinaryReader& reader) { + Skeleton skeleton; + skeleton.assetReference = ReadAssetReference(reader); + + skeleton.hierarchy.resize(reader.ReadCount(sizeof(JointId))); + for (Skeleton::JointNode& node : skeleton.hierarchy) { + node.id = reader.Read(); + node.children = reader.ReadArray(); + } + + skeleton.inverseBindMatrices = reader.ReadArray(); + + skeleton.joints.resize(reader.ReadCount(sizeof(JointId))); + for (Joint& joint : skeleton.joints) { + joint.id = reader.Read(); + joint.localTranslation = reader.Read(); + joint.localRotation = reader.Read(); + joint.localScale = reader.Read(); + } + + skeleton.jointNames = reader.ReadStrings(); + skeleton.parentIndex = reader.ReadArray(); + skeleton.rootPreTransform = reader.Read(); + return skeleton; + } + + void WriteMesh(BinaryWriter& writer, const CPUMesh& mesh) { + writer.WriteArray(mesh.indices); + writer.WriteArray(mesh.vertices); + writer.WriteArray(mesh.skinningData); + writer.WriteString(mesh.name); + WriteAssetReference(writer, mesh.assetReference); + writer.Write(mesh.minPos); + writer.Write(mesh.maxPos); + + writer.WriteBool(mesh.skeleton.has_value()); + if (mesh.skeleton) { + WriteSkeleton(writer, *mesh.skeleton); + } + } + + CPUMesh ReadMesh(BinaryReader& reader) { + CPUMesh mesh; + mesh.indices = reader.ReadArray(); + mesh.vertices = reader.ReadArray(); + mesh.skinningData = reader.ReadArray(); + mesh.name = reader.ReadString(); + mesh.assetReference = ReadAssetReference(reader); + mesh.minPos = reader.Read(); + mesh.maxPos = reader.Read(); + + if (reader.ReadBool()) { + mesh.skeleton = ReadSkeleton(reader); + } + return mesh; + } + + void WritePrimitive(BinaryWriter& writer, const ModelDoc::MeshPrimitive& primitive) { + WriteMesh(writer, primitive.mesh); + writer.Write(primitive.materialIndex); + writer.WriteString(primitive.nodeName); + writer.Write(primitive.sourceMeshIndex); + writer.WriteBool(primitive.skinned); + writer.Write(primitive.nodeWorldTransform); + } + + ModelDoc::MeshPrimitive ReadPrimitive(BinaryReader& reader) { + ModelDoc::MeshPrimitive primitive; + primitive.mesh = ReadMesh(reader); + primitive.materialIndex = reader.Read(); + primitive.nodeName = reader.ReadString(); + primitive.sourceMeshIndex = reader.Read(); + primitive.skinned = reader.ReadBool(); + primitive.nodeWorldTransform = reader.Read(); + return primitive; + } + + void WriteTexture(BinaryWriter& writer, const ModelDoc::TextureInfo& texture) { + writer.Write(static_cast(texture.semantic)); + writer.WriteString(texture.rawPath); + writer.WriteString(texture.path); + writer.Write(static_cast(texture.assimpType)); + writer.Write(texture.slot); + writer.WriteBool(texture.embedded); + } + + ModelDoc::TextureInfo ReadTexture(BinaryReader& reader) { + ModelDoc::TextureInfo texture; + texture.semantic = static_cast(reader.Read()); + texture.rawPath = reader.ReadString(); + texture.path = reader.ReadString(); + texture.assimpType = static_cast(reader.Read()); + texture.slot = reader.Read(); + texture.embedded = reader.ReadBool(); + return texture; + } + + void WriteMaterial(BinaryWriter& writer, const ModelDoc::MaterialInfo& material) { + writer.WriteString(material.name); + WriteAssetReference(writer, material.assetReference); + writer.Write(material.baseColor); + writer.Write(material.emissiveColor); + writer.Write(material.metallicFactor); + writer.Write(material.roughnessFactor); + writer.Write(material.opacity); + + writer.WriteCount(material.textures.size()); + for (const ModelDoc::TextureInfo& texture : material.textures) { + WriteTexture(writer, texture); + } + } + + ModelDoc::MaterialInfo ReadMaterial(BinaryReader& reader) { + ModelDoc::MaterialInfo material; + material.name = reader.ReadString(); + material.assetReference = ReadAssetReference(reader); + material.baseColor = reader.Read(); + material.emissiveColor = reader.Read(); + material.metallicFactor = reader.Read(); + material.roughnessFactor = reader.Read(); + material.opacity = reader.Read(); + + material.textures.resize(reader.ReadCount(1)); + for (ModelDoc::TextureInfo& texture : material.textures) { + texture = ReadTexture(reader); + } + return material; + } + + void WriteAnimation(BinaryWriter& writer, const SkeletalAnimation& animation) { + WriteAssetReference(writer, animation.assetReference); + + writer.WriteCount(animation.tracks.size()); + for (const SkeletalAnimation::Track& track : animation.tracks) { + writer.Write(track.jointIndex); + writer.WriteArray(track.keyframes); + } + + writer.Write(animation.duration); + writer.WriteBool(animation.looped); + writer.WriteString(animation.name); + writer.Write(animation.startFrame); + + writer.WriteCount(animation.events.size()); + for (const auto& [frame, events] : animation.events) { + writer.Write(frame); + writer.WriteStrings(events); + } + } + + SkeletalAnimation ReadAnimation(BinaryReader& reader) { + SkeletalAnimation animation; + animation.assetReference = ReadAssetReference(reader); + + animation.tracks.resize(reader.ReadCount(sizeof(std::uint32_t))); + for (SkeletalAnimation::Track& track : animation.tracks) { + track.jointIndex = reader.Read(); + track.keyframes = reader.ReadArray(); + } + + animation.duration = reader.Read(); + animation.looped = reader.ReadBool(); + animation.name = reader.ReadString(); + animation.startFrame = reader.Read(); + + const std::size_t eventFrameCount = reader.ReadCount(sizeof(int)); + for (std::size_t i = 0; i < eventFrameCount; ++i) { + const int frame = reader.Read(); + animation.events[frame] = reader.ReadStrings(); + } + return animation; + } +} + +std::vector ModelFile::Write(const ModelDoc::ModelAsset& model) { + BinaryWriter writer; + writer.Write(Magic); + writer.Write(Version); + + writer.WriteString(model.sourcePath); + + writer.WriteCount(model.primitives.size()); + for (const ModelDoc::MeshPrimitive& primitive : model.primitives) { + WritePrimitive(writer, primitive); + } + + writer.WriteCount(model.materials.size()); + for (const ModelDoc::MaterialInfo& material : model.materials) { + WriteMaterial(writer, material); + } + + WriteSkeleton(writer, model.skeleton); + + writer.WriteCount(model.animations.size()); + for (const SkeletalAnimation& animation : model.animations) { + WriteAnimation(writer, animation); + } + + return writer.TakeBytes(); +} + +ModelDoc::ModelAsset ModelFile::Read(std::span bytes) { + BinaryReader reader{bytes}; + + if (reader.Read>() != Magic) { + throw std::runtime_error("Not a cooked model file"); + } + + const auto version = reader.Read(); + if (version != Version) { + throw std::runtime_error( + "Cooked model file has version " + std::to_string(version) + + ", expected " + std::to_string(Version) + " (recook the asset)"); + } + + ModelDoc::ModelAsset model; + model.sourcePath = reader.ReadString(); + + model.primitives.resize(reader.ReadCount(1)); + for (ModelDoc::MeshPrimitive& primitive : model.primitives) { + primitive = ReadPrimitive(reader); + } + + model.materials.resize(reader.ReadCount(1)); + for (ModelDoc::MaterialInfo& material : model.materials) { + material = ReadMaterial(reader); + } + + model.skeleton = ReadSkeleton(reader); + + model.animations.resize(reader.ReadCount(1)); + for (SkeletalAnimation& animation : model.animations) { + animation = ReadAnimation(reader); + } + + if (!reader.AtEnd()) { + throw std::runtime_error("Cooked model file has unexpected data at the end"); + } + return model; +} + +void ModelFile::WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path) { + const std::vector bytes = Write(model); + + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) { + throw std::runtime_error("Failed to open model file for writing: " + path.string()); + } + file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!file) { + throw std::runtime_error("Failed to write model file: " + path.string()); + } +} + +ModelDoc::ModelAsset ModelFile::ReadFromFile(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) { + throw std::runtime_error("Failed to open model file: " + path.string()); + } + + std::vector bytes(static_cast(file.tellg())); + file.seekg(0); + file.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!file) { + throw std::runtime_error("Failed to read model file: " + path.string()); + } + return Read(bytes); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 20250ef..ef64f60 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,7 +8,10 @@ set_target_properties(destrum_tests PROPERTIES CXX_EXTENSIONS OFF ) -target_compile_definitions(destrum_tests PRIVATE SDL_MAIN_HANDLED) +target_compile_definitions(destrum_tests PRIVATE + SDL_MAIN_HANDLED + DESTRUM_ENGINE_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/destrum/assets_src" +) target_link_libraries(destrum_tests PRIVATE destrum::destrum) destrum_enable_warnings(destrum_tests) diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index 1941cef..667047f 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -485,6 +486,189 @@ namespace { "LightComponent range must stay positive"); } + ModelDoc::ModelAsset makeTestModelAsset() + { + Skeleton skeleton; + skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"}; + skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}}; + skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}}; + skeleton.joints = { + {.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}}, + {.id = 1, .localScale = glm::vec3{2.0f}}, + }; + skeleton.jointNames = {"root", "spine"}; + buildParentIndex(skeleton); + + CPUMesh mesh; + mesh.indices = {0, 1, 2}; + mesh.vertices = { + {.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}}, + {.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}}, + {.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f}, + }; + mesh.skinningData = { + {.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}}, + {.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, + {.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, + }; + mesh.name = "body"; + mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"}; + mesh.minPos = glm::vec3{0.0f}; + mesh.maxPos = glm::vec3{1.0f}; + mesh.skeleton = skeleton; + + ModelDoc::MeshPrimitive primitive; + primitive.mesh = mesh; + primitive.materialIndex = 0; + primitive.nodeName = "Body"; + primitive.sourceMeshIndex = 3; + primitive.skinned = true; + primitive.nodeWorldTransform = glm::mat4{3.0f}; + + ModelDoc::MaterialInfo material; + material.name = "skin"; + material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"}; + material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f}; + material.emissiveColor = glm::vec3{0.1f}; + material.metallicFactor = 0.2f; + material.roughnessFactor = 0.9f; + material.opacity = 0.5f; + material.textures = {{ + .semantic = ModelDoc::TextureSemantic::BaseColor, + .rawPath = "*0", + .path = "textures/skin.png", + .assimpType = aiTextureType_DIFFUSE, + .slot = 1, + .embedded = true, + }}; + + SkeletalAnimation animation; + animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"}; + animation.tracks = {{ + .jointIndex = 1, + .keyframes = { + {.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity(), .scale = glm::vec3{1.0f}}, + {.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}}, + }, + }}; + animation.duration = 0.5f; + animation.looped = false; + animation.name = "wave"; + animation.startFrame = 4; + animation.events = {{2, {"footstep", "dust"}}}; + + ModelDoc::ModelAsset model; + model.sourcePath = "engine://character.fbx"; + model.primitives = {primitive}; + model.materials = {material}; + model.skeleton = skeleton; + model.animations = {animation}; + return model; + } + + bool modelFileReadThrows(const std::vector& bytes) + { + try { + (void)ModelFile::Read(bytes); + } catch (const std::exception&) { + return true; + } + return false; + } + + void testModelFileRoundTrip() + { + const auto bytes = ModelFile::Write(makeTestModelAsset()); + const auto loaded = ModelFile::Read(bytes); + + check(ModelFile::Write(loaded) == bytes, + "a cooked model must write back to exactly the same bytes"); + + check(loaded.sourcePath == "engine://character.fbx", + "cooked models must keep their source path"); + + check(loaded.primitives.size() == 1, "cooked models must keep their primitives"); + const auto& primitive = loaded.primitives.front(); + check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned, + "cooked primitives must keep their import metadata"); + check(primitive.mesh.indices == std::vector{0, 1, 2}, + "cooked meshes must keep their indices"); + check(primitive.mesh.vertices.size() == 3 && + primitive.mesh.vertices[0].uv_x == 0.25f && + primitive.mesh.vertices[1].tangent.w == -1.0f, + "cooked meshes must keep their vertices"); + check(primitive.mesh.skinningData[0].jointIds.y == 1 && + primitive.mesh.skinningData[0].weights.x == 0.5f, + "cooked meshes must keep their skinning data"); + check(primitive.mesh.skeleton.has_value() && + primitive.mesh.skeleton->jointNames.size() == 2, + "cooked skinned meshes must keep their skeleton"); + + check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f, + "cooked models must keep their materials"); + const auto& texture = loaded.materials.front().textures.front(); + check(texture.semantic == ModelDoc::TextureSemantic::BaseColor && + texture.path == "textures/skin.png" && + texture.assimpType == aiTextureType_DIFFUSE && + texture.embedded, + "cooked materials must keep their textures"); + + check(loaded.skeleton.jointNames == std::vector{"root", "spine"} && + loaded.skeleton.parentIndex == std::vector{-1, 0} && + loaded.skeleton.joints[1].localScale == glm::vec3{2.0f}, + "cooked models must keep their skeleton"); + + check(loaded.animations.size() == 1, "cooked models must keep their animations"); + const auto& animation = loaded.animations.front(); + check(animation.name == "wave" && !animation.looped && animation.startFrame == 4, + "cooked animations must keep their settings"); + check(animation.tracks.front().keyframes[1].translation.y == 2.0f && + animation.tracks.front().keyframes[1].rotation.x == 1.0f, + "cooked animations must keep their keyframes"); + check(animation.events.at(2) == std::vector{"footstep", "dust"}, + "cooked animations must keep their events"); + } + + void testModelFileRejectsBadData() + { + const auto bytes = ModelFile::Write(makeTestModelAsset()); + + auto truncated = bytes; + truncated.resize(bytes.size() / 2); + check(modelFileReadThrows(truncated), "truncated cooked models must be rejected"); + + auto wrongMagic = bytes; + wrongMagic[0] = 'X'; + check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected"); + + auto wrongVersion = bytes; + wrongVersion[4] = static_cast(wrongVersion[4] + 1); + check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected"); + + auto trailingData = bytes; + trailingData.push_back(0); + check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected"); + } + + void testModelFileFromImportedModel() + { + const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx"; + const auto imported = ModelDoc::LoadModel(source.generic_string()); + + const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel"; + ModelFile::WriteToFile(imported, path); + const auto loaded = ModelFile::ReadFromFile(path); + std::filesystem::remove(path); + + check(!imported.primitives.empty(), "the test model must import with at least one primitive"); + check(loaded.primitives.size() == imported.primitives.size() && + loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() && + loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices, + "an imported model must survive being cooked"); + check(ModelFile::Write(loaded) == ModelFile::Write(imported), + "a cooked imported model must match the import exactly"); + } + void testSimpleColorMaterial() { const auto material = Material::SimpleColor( @@ -600,6 +784,9 @@ int main() testSceneJoltSphereCollisions(); testAnimatorAssetReferences(); testLightComponentRoundTrip(); + testModelFileRoundTrip(); + testModelFileRejectsBadData(); + testModelFileFromImportedModel(); testSimpleColorMaterial(); testAssetReferenceCacheKeys(); testTriggerZone();