From 3df0f89343885b2fab9ac3d871f3174371466a28 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Thu, 22 Oct 2026 00:30:23 +0200 Subject: [PATCH] fix: material serialization --- destrum/CMakeLists.txt | 1 + destrum/include/destrum/FS/AssetFS.h | 2 + .../destrum/Graphics/Caches/ImageCache.h | 14 +- .../destrum/Graphics/Caches/MaterialCache.h | 2 +- .../Serialization/MaterialSerialization.h | 27 +++ destrum/src/FS/AssetFS.cpp | 13 ++ destrum/src/Graphics/Caches/ImageCache.cpp | 5 + destrum/src/Graphics/Caches/MaterialCache.cpp | 7 +- .../Serialization/MaterialSerialization.cpp | 208 ++++++++++++++++++ .../src/Serialization/PrefabSerializer.cpp | 13 ++ destrum/src/Serialization/SceneSerializer.cpp | 15 ++ hotpink.html | 16 ++ tests/serialization_tests.cpp | 55 +++++ 13 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 destrum/include/destrum/Serialization/MaterialSerialization.h create mode 100644 destrum/src/Serialization/MaterialSerialization.cpp create mode 100644 hotpink.html diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index c414c41..b1ee8d4 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -119,6 +119,7 @@ set(SRC_FILES "src/Serialization/SceneSerializer.cpp" "src/Serialization/ObjectSerialization.cpp" "src/Serialization/PrefabSerializer.cpp" + "src/Serialization/MaterialSerialization.cpp" "src/Serialization/PrefabCache.cpp" "src/Serialization/ComponentRegistry.cpp" diff --git a/destrum/include/destrum/FS/AssetFS.h b/destrum/include/destrum/FS/AssetFS.h index 89b7abd..f6ac897 100644 --- a/destrum/include/destrum/FS/AssetFS.h +++ b/destrum/include/destrum/FS/AssetFS.h @@ -32,6 +32,8 @@ public: [[nodiscard]] std::filesystem::path GetCookedPathForFile(std::string_view vpath) const; + [[nodiscard]] std::optional ToVirtualPath(const std::filesystem::path& fullPath) const; + private: static std::vector ReadFile(const std::filesystem::path& fullPath); diff --git a/destrum/include/destrum/Graphics/Caches/ImageCache.h b/destrum/include/destrum/Graphics/Caches/ImageCache.h index d4d8394..f150fef 100644 --- a/destrum/include/destrum/Graphics/Caches/ImageCache.h +++ b/destrum/include/destrum/Graphics/Caches/ImageCache.h @@ -19,6 +19,13 @@ class ImageCache { friend class ResourcesInspector; public: + struct LoadedImageInfo { + std::filesystem::path path; + TextureIntent intent; + VkImageUsageFlags usage; + bool mipMap; + }; + explicit ImageCache(GfxDevice& gfxDevice); ImageID loadImageFromFile( @@ -29,6 +36,7 @@ public: ImageID addImage(GPUImage image); ImageID addImage(ImageID id, GPUImage image); [[nodiscard]] const GPUImage& getImage(ImageID id) const; + [[nodiscard]] const LoadedImageInfo* findLoadedImageInfo(ImageID id) const; void removeImage(ImageID id); [[nodiscard]] ImageID getFreeImageId() const; @@ -57,12 +65,6 @@ private: std::shared_ptr> freeIds = std::make_shared>(); GfxDevice& gfxDevice; - struct LoadedImageInfo { - std::filesystem::path path; - TextureIntent intent; - VkImageUsageFlags usage; - bool mipMap; - }; std::unordered_map loadedImagesInfo; ImageID errorImageId{NULL_IMAGE_ID}; }; diff --git a/destrum/include/destrum/Graphics/Caches/MaterialCache.h b/destrum/include/destrum/Graphics/Caches/MaterialCache.h index 636c575..a43c20e 100644 --- a/destrum/include/destrum/Graphics/Caches/MaterialCache.h +++ b/destrum/include/destrum/Graphics/Caches/MaterialCache.h @@ -27,7 +27,7 @@ public: void cleanup(GfxDevice& gfxDevice); - MaterialID addMaterial(Material material); + MaterialID addMaterial(Material material, std::string key = {}); MaterialID addSimpleColorMaterial( glm::vec3 color, std::string name = {}); diff --git a/destrum/include/destrum/Serialization/MaterialSerialization.h b/destrum/include/destrum/Serialization/MaterialSerialization.h new file mode 100644 index 0000000..72dedbc --- /dev/null +++ b/destrum/include/destrum/Serialization/MaterialSerialization.h @@ -0,0 +1,27 @@ +#ifndef DESTRUM_MATERIALSERIALIZATION_H +#define DESTRUM_MATERIALSERIALIZATION_H + +#include +#include +#include + +#include + +#include + +namespace MaterialSerialization { + using TextureWriter = std::function; + using TextureReader = std::function; + + [[nodiscard]] nlohmann::json ToJson(const Material& material, const TextureWriter& writeTexture); + [[nodiscard]] Material FromJson(const nlohmann::json& data, const TextureReader& readTexture); + + [[nodiscard]] std::optional Validate(const nlohmann::json& materials); + + // Materials that only exist at runtime (no source file) are stored in the scene or prefab + // file under their cache key, so the materialKeys in its objects resolve after a restart + [[nodiscard]] nlohmann::json CollectEmbedded(const nlohmann::json& objects); + void RegisterEmbedded(const nlohmann::json& materials); +} + +#endif //DESTRUM_MATERIALSERIALIZATION_H diff --git a/destrum/src/FS/AssetFS.cpp b/destrum/src/FS/AssetFS.cpp index 8483567..7afd552 100644 --- a/destrum/src/FS/AssetFS.cpp +++ b/destrum/src/FS/AssetFS.cpp @@ -201,6 +201,19 @@ std::filesystem::path AssetFS::GetCookedPathForFile(std::string_view vpath) cons return rawPath; } +std::optional AssetFS::ToVirtualPath(const std::filesystem::path& fullPath) const { + const std::filesystem::path normalized = fullPath.lexically_normal(); + for (const FSMount& mount : mounts) { + const std::filesystem::path relative = normalized.lexically_relative(mount.root); + if (relative.empty() || relative.is_absolute() || relative == "." || + relative == ".." || relative.generic_string().starts_with("../")) { + continue; + } + return mount.scheme + "://" + relative.generic_string(); + } + return std::nullopt; +} + std::vector AssetFS::ReadFile(const std::filesystem::path& fullPath) { std::ifstream file(fullPath, std::ios::binary); if (!file) { diff --git a/destrum/src/Graphics/Caches/ImageCache.cpp b/destrum/src/Graphics/Caches/ImageCache.cpp index 8de5fb7..287d278 100644 --- a/destrum/src/Graphics/Caches/ImageCache.cpp +++ b/destrum/src/Graphics/Caches/ImageCache.cpp @@ -107,6 +107,11 @@ ImageID ImageCache::addImage(ImageID id, GPUImage image) { } } +const ImageCache::LoadedImageInfo* ImageCache::findLoadedImageInfo(ImageID id) const { + const auto it = loadedImagesInfo.find(id); + return it != loadedImagesInfo.end() ? &it->second : nullptr; +} + void ImageCache::removeImage(ImageID id) { if (id >= images.size() || images[id].image == VK_NULL_HANDLE) { return; diff --git a/destrum/src/Graphics/Caches/MaterialCache.cpp b/destrum/src/Graphics/Caches/MaterialCache.cpp index acabe4e..61bd585 100644 --- a/destrum/src/Graphics/Caches/MaterialCache.cpp +++ b/destrum/src/Graphics/Caches/MaterialCache.cpp @@ -50,7 +50,7 @@ void MaterialCache::cleanup(GfxDevice& gfxDevice) device = nullptr; } -MaterialID MaterialCache::addMaterial(Material material) +MaterialID MaterialCache::addMaterial(Material material, std::string key) { MaterialData* data = static_cast(materialDataBuffer.info.pMappedData); @@ -71,10 +71,9 @@ MaterialID MaterialCache::addMaterial(Material material) sizeof(MaterialData)); const auto materialId = static_cast(materials.size()); - std::string key; - if (!material.assetReference.empty()) { + if (key.empty() && !material.assetReference.empty()) { key = material.assetReference.cacheKey(); - } else { + } else if (key.empty()) { key = material.name.empty() ? "material:" + std::to_string(materialId) : material.name; diff --git a/destrum/src/Serialization/MaterialSerialization.cpp b/destrum/src/Serialization/MaterialSerialization.cpp new file mode 100644 index 0000000..3079209 --- /dev/null +++ b/destrum/src/Serialization/MaterialSerialization.cpp @@ -0,0 +1,208 @@ +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { + using TextureSlot = std::pair; + + constexpr std::array TextureSlots{{ + {"diffuse", &Material::diffuseTexture}, + {"normal", &Material::normalTexture}, + {"metallic", &Material::metallicTexture}, + {"roughness", &Material::roughnessTexture}, + {"occlusion", &Material::occlusionTexture}, + {"emissive", &Material::emissiveTexture}, + }}; + + [[nodiscard]] const char* FilteringName(TextureFilteringMode mode) { + switch (mode) { + case TextureFilteringMode::Nearest: return "nearest"; + case TextureFilteringMode::Anisotropic: return "anisotropic"; + case TextureFilteringMode::Linear: break; + } + return "linear"; + } + + [[nodiscard]] TextureFilteringMode ParseFiltering(const std::string& name) { + if (name == "nearest") return TextureFilteringMode::Nearest; + if (name == "anisotropic") return TextureFilteringMode::Anisotropic; + return TextureFilteringMode::Linear; + } + + [[nodiscard]] RenderResources* GetResources() { + if (!GameState::GetInstance().HasRenderer() || !GameState::GetInstance().HasGfxDevice()) { + return nullptr; + } + return GameState::GetInstance().Renderer().getResources(); + } +} + +namespace MaterialSerialization { + json ToJson(const Material& material, const TextureWriter& writeTexture) { + json data = { + {"name", material.name}, + {"baseColor", material.baseColor}, + {"metallic", material.metallicFactor}, + {"roughness", material.roughnessFactor}, + {"emissiveColor", material.emissiveColor}, + {"normalScale", material.normalScale}, + {"occlusionStrength", material.occlusionStrength}, + {"filtering", FilteringName(material.textureFilteringMode)}, + }; + + json textures = json::object(); + for (const auto& [slot, member] : TextureSlots) { + if (material.*member == NULL_IMAGE_ID) { + continue; + } + json texture = writeTexture(material.*member); + if (!texture.is_null()) { + textures[slot] = std::move(texture); + } + } + if (!textures.empty()) { + data["textures"] = std::move(textures); + } + return data; + } + + Material FromJson(const json& data, const TextureReader& readTexture) { + Material material{}; + material.name = data.value("name", std::string{}); + material.baseColor = data.value("baseColor", material.baseColor); + material.metallicFactor = data.value("metallic", material.metallicFactor); + material.roughnessFactor = data.value("roughness", material.roughnessFactor); + material.emissiveColor = data.value("emissiveColor", material.emissiveColor); + material.normalScale = data.value("normalScale", material.normalScale); + material.occlusionStrength = data.value("occlusionStrength", material.occlusionStrength); + material.textureFilteringMode = ParseFiltering(data.value("filtering", std::string{"linear"})); + + if (data.contains("textures")) { + const json& textures = data.at("textures"); + for (const auto& [slot, member] : TextureSlots) { + if (textures.contains(slot)) { + material.*member = readTexture(textures.at(slot)); + } + } + } + return material; + } + + std::optional Validate(const json& materials) { + if (!materials.is_object()) { + return "\"materials\" must be an object"; + } + for (auto it = materials.begin(); it != materials.end(); ++it) { + const std::string context = "materials[\"" + it.key() + "\"]"; + if (!it.value().is_object()) { + return context + " must be an object"; + } + if (!it.value().contains("textures")) { + continue; + } + const json& textures = it.value().at("textures"); + if (!textures.is_object()) { + return context + ".textures must be an object"; + } + for (auto texture = textures.begin(); texture != textures.end(); ++texture) { + if (!texture.value().is_object() || !texture.value().contains("path") || + !texture.value().at("path").is_string()) { + return context + ".textures." + texture.key() + " must have a \"path\" string"; + } + } + } + return std::nullopt; + } + + json CollectEmbedded(const json& objects) { + json materials = json::object(); + const RenderResources* resources = GetResources(); + if (resources == nullptr) { + return materials; + } + const MaterialCache& cache = resources->materials(); + + const auto writeTexture = [&](ImageID id) -> json { + const ImageCache::LoadedImageInfo* info = resources->images().findLoadedImageInfo(id); + if (info == nullptr) { + spdlog::warn("Texture {} was not loaded from a file, it is not saved", id); + return nullptr; + } + const auto virtualPath = AssetFS::GetInstance().ToVirtualPath(info->path); + return { + {"path", virtualPath ? *virtualPath : info->path.generic_string()}, + {"srgb", info->intent == TextureIntent::ColorSrgb}, + {"mipMap", info->mipMap}, + }; + }; + + for (const json& objectJson : objects) { + if (!objectJson.contains("components")) { + continue; + } + for (const json& componentJson : objectJson.at("components")) { + if (!componentJson.contains("data") || !componentJson.at("data").contains("materialKey")) { + continue; + } + const json& keyJson = componentJson.at("data").at("materialKey"); + if (!keyJson.is_string() || materials.contains(keyJson.get())) { + continue; + } + + const std::string key = keyJson.get(); + const auto id = cache.findMaterialByKey(key); + if (!id || *id == cache.getPlaceholderMaterialId() || + !cache.getMaterial(*id).assetReference.empty()) { + continue; + } + materials[key] = ToJson(cache.getMaterial(*id), writeTexture); + } + } + return materials; + } + + void RegisterEmbedded(const json& materials) { + RenderResources* resources = GetResources(); + if (resources == nullptr || !materials.is_object()) { + return; + } + GfxDevice& gfxDevice = GameState::GetInstance().Gfx(); + + const auto readTexture = [&](const json& texture) -> ImageID { + const std::string path = texture.at("path").get(); + const TextureIntent intent = texture.value("srgb", true) + ? TextureIntent::ColorSrgb + : TextureIntent::DataLinear; + try { + const std::filesystem::path fullPath = path.find("://") != std::string::npos + ? AssetFS::GetInstance().GetFullPath(path) + : std::filesystem::path{path}; + return resources->loadImageFromFile( + gfxDevice, fullPath, VK_IMAGE_USAGE_SAMPLED_BIT, texture.value("mipMap", true), intent); + } catch (const std::exception& exception) { + spdlog::warn("Material texture \"{}\" could not be loaded: {}", path, exception.what()); + return resources->getErrorTextureID(); + } + }; + + // A key that is already cached wins, so duplicating objects keeps sharing their material + for (auto it = materials.begin(); it != materials.end(); ++it) { + if (resources->materials().findMaterialByKey(it.key())) { + continue; + } + resources->materials().addMaterial(FromJson(it.value(), readTexture), it.key()); + } + } +} diff --git a/destrum/src/Serialization/PrefabSerializer.cpp b/destrum/src/Serialization/PrefabSerializer.cpp index 74b5d8f..930632f 100644 --- a/destrum/src/Serialization/PrefabSerializer.cpp +++ b/destrum/src/Serialization/PrefabSerializer.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -33,6 +34,9 @@ namespace { } try { + if (prefab.contains("materials")) { + MaterialSerialization::RegisterEmbedded(prefab.at("materials")); + } auto built = ObjectSerialization::BuildObjects(scene, prefab.at("objects"), externalObjects); GameObject* root = built.objects.front(); if (parent != nullptr) { @@ -58,6 +62,9 @@ std::optional PrefabSerializer::Capture(const GameObject& root) { prefab["name"] = root.GetName(); try { prefab["objects"] = ObjectSerialization::SerializeObjects(ObjectSerialization::CollectHierarchy(root)); + if (json materials = MaterialSerialization::CollectEmbedded(prefab["objects"]); !materials.empty()) { + prefab["materials"] = std::move(materials); + } } catch (const std::exception& exception) { spdlog::error("Cannot capture prefab \"{}\": {}", root.GetName(), exception.what()); return std::nullopt; @@ -135,6 +142,12 @@ std::optional PrefabSerializer::Validate(const json& prefab) { return "prefab must contain a non-empty \"objects\" array"; } + if (prefab.contains("materials")) { + if (auto error = MaterialSerialization::Validate(prefab.at("materials"))) { + return error; + } + } + const auto& objects = prefab.at("objects"); if (auto error = ObjectSerialization::ValidateObjects(objects, ObjectSerialization::CurrentObjectVersion)) { return error; diff --git a/destrum/src/Serialization/SceneSerializer.cpp b/destrum/src/Serialization/SceneSerializer.cpp index 5e1c25c..b0315f7 100644 --- a/destrum/src/Serialization/SceneSerializer.cpp +++ b/destrum/src/Serialization/SceneSerializer.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include using json = nlohmann::json; @@ -49,6 +50,12 @@ namespace { return "\"currentCamera\" must be an object id"; } + if (root.contains("materials")) { + if (auto error = MaterialSerialization::Validate(root.at("materials"))) { + return error; + } + } + return ObjectSerialization::ValidateObjects(root.at("objects"), version); } } @@ -94,6 +101,9 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { try { root["objects"] = ObjectSerialization::SerializeObjects(objects); + if (json materials = MaterialSerialization::CollectEmbedded(root["objects"]); !materials.empty()) { + root["materials"] = std::move(materials); + } } catch (const std::exception& exception) { spdlog::error("Cannot save scene \"{}\": {}", scene.GetName(), exception.what()); return false; @@ -183,6 +193,11 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, std::string context = "scene"; try { + if (root.contains("materials")) { + context = "materials"; + MaterialSerialization::RegisterEmbedded(root.at("materials")); + context = "scene"; + } idMap = ObjectSerialization::BuildObjects(stagingScene, root.at("objects")).idMap; // Move objects from pending to objects list without physics registration diff --git a/hotpink.html b/hotpink.html new file mode 100644 index 0000000..7fae78f --- /dev/null +++ b/hotpink.html @@ -0,0 +1,16 @@ + + + + + Title + + + + + + + \ No newline at end of file diff --git a/tests/serialization_tests.cpp b/tests/serialization_tests.cpp index 286ad84..c9417af 100644 --- a/tests/serialization_tests.cpp +++ b/tests/serialization_tests.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -281,6 +282,58 @@ namespace { SceneManager::GetInstance().Destroy(); } + + void testMaterialJsonRoundTrip() + { + Material material = Material::SimpleColor(glm::vec3{0.2f, 0.4f, 0.6f}, "Crate"); + material.metallicFactor = 0.3f; + material.roughnessFactor = 0.55f; + material.emissiveColor = {1.0f, 0.5f, 0.0f}; + material.textureFilteringMode = TextureFilteringMode::Anisotropic; + material.diffuseTexture = 3; + material.emissiveTexture = 7; + material.normalTexture = 9; + + // Texture 9 has no source file, it must be left out instead of saved as a dangling id + const nlohmann::json data = MaterialSerialization::ToJson(material, [](ImageID id) -> nlohmann::json { + if (id == 9) { + return nullptr; + } + return {{"path", "game://tex" + std::to_string(id) + ".png"}, {"srgb", id == 3}}; + }); + check(!MaterialSerialization::Validate(nlohmann::json{{"Crate", data}}).has_value(), + "a serialized material must pass validation"); + check(!data.at("textures").contains("normal"), "textures without a source must not be saved"); + + const Material loaded = MaterialSerialization::FromJson(data, [](const nlohmann::json& texture) { + const std::string path = texture.at("path").get(); + return path == "game://tex3.png" ? ImageID{30} : ImageID{70}; + }); + check(loaded.name == "Crate" && loaded.baseColor == material.baseColor && + close(loaded.metallicFactor, 0.3f) && close(loaded.roughnessFactor, 0.55f) && + loaded.emissiveColor == material.emissiveColor && + loaded.textureFilteringMode == TextureFilteringMode::Anisotropic, + "material factors must round-trip through json"); + check(loaded.diffuseTexture == 30 && loaded.emissiveTexture == 70 && + loaded.normalTexture == NULL_IMAGE_ID && loaded.metallicTexture == NULL_IMAGE_ID, + "material textures must round-trip through their slots"); + } + + void testSceneValidatesEmbeddedMaterials() + { + const auto path = std::filesystem::temp_directory_path() / "destrum_materials_scene.json"; + Scene& scene = SceneManager::GetInstance().CreateScene("materials"); + + std::ofstream{path} << R"({"version":2,"objects":[],"materials":{"Bad":{"textures":{"diffuse":5}}}})"; + check(!SceneSerializer::Load(scene, path), "a material texture without a path must be rejected"); + + std::ofstream{path} << R"({"version":2,"objects":[{"id":1,"name":"box"}],)" + R"("materials":{"Good":{"baseColor":[1,0,0],"textures":{"diffuse":{"path":"game://a.png"}}}}})"; + check(SceneSerializer::Load(scene, path), "a scene with embedded materials must load"); + std::filesystem::remove(path); + + SceneManager::GetInstance().Destroy(); + } } void RunSerializationTests() @@ -295,4 +348,6 @@ void RunSerializationTests() testSceneLoadDropsMissingCamera(); testGlmJson(); testSceneFileIdsAreLocal(); + testMaterialJsonRoundTrip(); + testSceneValidatesEmbeddedMaterials(); }