fix: material serialization

This commit is contained in:
2026-10-22 00:30:23 +02:00
parent 3a04c1884a
commit 3df0f89343
13 changed files with 367 additions and 11 deletions
+1
View File
@@ -119,6 +119,7 @@ set(SRC_FILES
"src/Serialization/SceneSerializer.cpp" "src/Serialization/SceneSerializer.cpp"
"src/Serialization/ObjectSerialization.cpp" "src/Serialization/ObjectSerialization.cpp"
"src/Serialization/PrefabSerializer.cpp" "src/Serialization/PrefabSerializer.cpp"
"src/Serialization/MaterialSerialization.cpp"
"src/Serialization/PrefabCache.cpp" "src/Serialization/PrefabCache.cpp"
"src/Serialization/ComponentRegistry.cpp" "src/Serialization/ComponentRegistry.cpp"
+2
View File
@@ -32,6 +32,8 @@ public:
[[nodiscard]] std::filesystem::path GetCookedPathForFile(std::string_view vpath) const; [[nodiscard]] std::filesystem::path GetCookedPathForFile(std::string_view vpath) const;
[[nodiscard]] std::optional<std::string> ToVirtualPath(const std::filesystem::path& fullPath) const;
private: private:
static std::vector<uint8_t> ReadFile(const std::filesystem::path& fullPath); static std::vector<uint8_t> ReadFile(const std::filesystem::path& fullPath);
@@ -19,6 +19,13 @@ class ImageCache {
friend class ResourcesInspector; friend class ResourcesInspector;
public: public:
struct LoadedImageInfo {
std::filesystem::path path;
TextureIntent intent;
VkImageUsageFlags usage;
bool mipMap;
};
explicit ImageCache(GfxDevice& gfxDevice); explicit ImageCache(GfxDevice& gfxDevice);
ImageID loadImageFromFile( ImageID loadImageFromFile(
@@ -29,6 +36,7 @@ public:
ImageID addImage(GPUImage image); ImageID addImage(GPUImage image);
ImageID addImage(ImageID id, GPUImage image); ImageID addImage(ImageID id, GPUImage image);
[[nodiscard]] const GPUImage& getImage(ImageID id) const; [[nodiscard]] const GPUImage& getImage(ImageID id) const;
[[nodiscard]] const LoadedImageInfo* findLoadedImageInfo(ImageID id) const;
void removeImage(ImageID id); void removeImage(ImageID id);
[[nodiscard]] ImageID getFreeImageId() const; [[nodiscard]] ImageID getFreeImageId() const;
@@ -57,12 +65,6 @@ private:
std::shared_ptr<std::vector<ImageID>> freeIds = std::make_shared<std::vector<ImageID>>(); std::shared_ptr<std::vector<ImageID>> freeIds = std::make_shared<std::vector<ImageID>>();
GfxDevice& gfxDevice; GfxDevice& gfxDevice;
struct LoadedImageInfo {
std::filesystem::path path;
TextureIntent intent;
VkImageUsageFlags usage;
bool mipMap;
};
std::unordered_map<ImageID, LoadedImageInfo> loadedImagesInfo; std::unordered_map<ImageID, LoadedImageInfo> loadedImagesInfo;
ImageID errorImageId{NULL_IMAGE_ID}; ImageID errorImageId{NULL_IMAGE_ID};
}; };
@@ -27,7 +27,7 @@ public:
void cleanup(GfxDevice& gfxDevice); void cleanup(GfxDevice& gfxDevice);
MaterialID addMaterial(Material material); MaterialID addMaterial(Material material, std::string key = {});
MaterialID addSimpleColorMaterial( MaterialID addSimpleColorMaterial(
glm::vec3 color, glm::vec3 color,
std::string name = {}); std::string name = {});
@@ -0,0 +1,27 @@
#ifndef DESTRUM_MATERIALSERIALIZATION_H
#define DESTRUM_MATERIALSERIALIZATION_H
#include <functional>
#include <optional>
#include <string>
#include <nlohmann/json.hpp>
#include <destrum/Graphics/Material.h>
namespace MaterialSerialization {
using TextureWriter = std::function<nlohmann::json(ImageID)>;
using TextureReader = std::function<ImageID(const nlohmann::json&)>;
[[nodiscard]] nlohmann::json ToJson(const Material& material, const TextureWriter& writeTexture);
[[nodiscard]] Material FromJson(const nlohmann::json& data, const TextureReader& readTexture);
[[nodiscard]] std::optional<std::string> 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
+13
View File
@@ -201,6 +201,19 @@ std::filesystem::path AssetFS::GetCookedPathForFile(std::string_view vpath) cons
return rawPath; return rawPath;
} }
std::optional<std::string> 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<uint8_t> AssetFS::ReadFile(const std::filesystem::path& fullPath) { std::vector<uint8_t> AssetFS::ReadFile(const std::filesystem::path& fullPath) {
std::ifstream file(fullPath, std::ios::binary); std::ifstream file(fullPath, std::ios::binary);
if (!file) { if (!file) {
@@ -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) { void ImageCache::removeImage(ImageID id) {
if (id >= images.size() || images[id].image == VK_NULL_HANDLE) { if (id >= images.size() || images[id].image == VK_NULL_HANDLE) {
return; return;
@@ -50,7 +50,7 @@ void MaterialCache::cleanup(GfxDevice& gfxDevice)
device = nullptr; device = nullptr;
} }
MaterialID MaterialCache::addMaterial(Material material) MaterialID MaterialCache::addMaterial(Material material, std::string key)
{ {
MaterialData* data = static_cast<MaterialData*>(materialDataBuffer.info.pMappedData); MaterialData* data = static_cast<MaterialData*>(materialDataBuffer.info.pMappedData);
@@ -71,10 +71,9 @@ MaterialID MaterialCache::addMaterial(Material material)
sizeof(MaterialData)); sizeof(MaterialData));
const auto materialId = static_cast<MaterialID>(materials.size()); const auto materialId = static_cast<MaterialID>(materials.size());
std::string key; if (key.empty() && !material.assetReference.empty()) {
if (!material.assetReference.empty()) {
key = material.assetReference.cacheKey(); key = material.assetReference.cacheKey();
} else { } else if (key.empty()) {
key = material.name.empty() key = material.name.empty()
? "material:" + std::to_string(materialId) ? "material:" + std::to_string(materialId)
: material.name; : material.name;
@@ -0,0 +1,208 @@
#include <destrum/Serialization/MaterialSerialization.h>
#include <array>
#include <filesystem>
#include <utility>
#include <spdlog/spdlog.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Graphics/GfxDevice.h>
#include <destrum/Graphics/Renderer.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Util/GameState.h>
using json = nlohmann::json;
namespace {
using TextureSlot = std::pair<const char*, ImageID Material::*>;
constexpr std::array<TextureSlot, 6> 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<std::string> 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<std::string>())) {
continue;
}
const std::string key = keyJson.get<std::string>();
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<std::string>();
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());
}
}
}
@@ -8,6 +8,7 @@
#include <destrum/ObjectModel/Transform.h> #include <destrum/ObjectModel/Transform.h>
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/Serialization/ComponentRegistry.h> #include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/MaterialSerialization.h>
#include <destrum/Serialization/ObjectSerialization.h> #include <destrum/Serialization/ObjectSerialization.h>
#include <destrum/Serialization/PrefabCache.h> #include <destrum/Serialization/PrefabCache.h>
@@ -33,6 +34,9 @@ namespace {
} }
try { try {
if (prefab.contains("materials")) {
MaterialSerialization::RegisterEmbedded(prefab.at("materials"));
}
auto built = ObjectSerialization::BuildObjects(scene, prefab.at("objects"), externalObjects); auto built = ObjectSerialization::BuildObjects(scene, prefab.at("objects"), externalObjects);
GameObject* root = built.objects.front(); GameObject* root = built.objects.front();
if (parent != nullptr) { if (parent != nullptr) {
@@ -58,6 +62,9 @@ std::optional<json> PrefabSerializer::Capture(const GameObject& root) {
prefab["name"] = root.GetName(); prefab["name"] = root.GetName();
try { try {
prefab["objects"] = ObjectSerialization::SerializeObjects(ObjectSerialization::CollectHierarchy(root)); 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) { } catch (const std::exception& exception) {
spdlog::error("Cannot capture prefab \"{}\": {}", root.GetName(), exception.what()); spdlog::error("Cannot capture prefab \"{}\": {}", root.GetName(), exception.what());
return std::nullopt; return std::nullopt;
@@ -135,6 +142,12 @@ std::optional<std::string> PrefabSerializer::Validate(const json& prefab) {
return "prefab must contain a non-empty \"objects\" array"; 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"); const auto& objects = prefab.at("objects");
if (auto error = ObjectSerialization::ValidateObjects(objects, ObjectSerialization::CurrentObjectVersion)) { if (auto error = ObjectSerialization::ValidateObjects(objects, ObjectSerialization::CurrentObjectVersion)) {
return error; return error;
@@ -13,6 +13,7 @@
#include <destrum/Components/Physics/Rigidbody.h> #include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Serialization/ComponentRegistry.h> #include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/GlmJson.h> #include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/MaterialSerialization.h>
#include <destrum/Serialization/ObjectSerialization.h> #include <destrum/Serialization/ObjectSerialization.h>
using json = nlohmann::json; using json = nlohmann::json;
@@ -49,6 +50,12 @@ namespace {
return "\"currentCamera\" must be an object id"; 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); return ObjectSerialization::ValidateObjects(root.at("objects"), version);
} }
} }
@@ -94,6 +101,9 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) {
try { try {
root["objects"] = ObjectSerialization::SerializeObjects(objects); root["objects"] = ObjectSerialization::SerializeObjects(objects);
if (json materials = MaterialSerialization::CollectEmbedded(root["objects"]); !materials.empty()) {
root["materials"] = std::move(materials);
}
} catch (const std::exception& exception) { } catch (const std::exception& exception) {
spdlog::error("Cannot save scene \"{}\": {}", scene.GetName(), exception.what()); spdlog::error("Cannot save scene \"{}\": {}", scene.GetName(), exception.what());
return false; return false;
@@ -183,6 +193,11 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result,
std::string context = "scene"; std::string context = "scene";
try { try {
if (root.contains("materials")) {
context = "materials";
MaterialSerialization::RegisterEmbedded(root.at("materials"));
context = "scene";
}
idMap = ObjectSerialization::BuildObjects(stagingScene, root.at("objects")).idMap; idMap = ObjectSerialization::BuildObjects(stagingScene, root.at("objects")).idMap;
// Move objects from pending to objects list without physics registration // Move objects from pending to objects list without physics registration
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<style>
* {
background-color: hotpink;
}
</style>
</body>
</html>
+55
View File
@@ -9,6 +9,7 @@
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/Scene/SceneManager.h> #include <destrum/Scene/SceneManager.h>
#include <destrum/Serialization/GlmJson.h> #include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/MaterialSerialization.h>
#include <destrum/Serialization/SceneSerializer.h> #include <destrum/Serialization/SceneSerializer.h>
#include <destrum/Components/CameraComponent.h> #include <destrum/Components/CameraComponent.h>
#include <destrum/Components/LightComponent.h> #include <destrum/Components/LightComponent.h>
@@ -281,6 +282,58 @@ namespace {
SceneManager::GetInstance().Destroy(); 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<std::string>();
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() void RunSerializationTests()
@@ -295,4 +348,6 @@ void RunSerializationTests()
testSceneLoadDropsMissingCamera(); testSceneLoadDropsMissingCamera();
testGlmJson(); testGlmJson();
testSceneFileIdsAreLocal(); testSceneFileIdsAreLocal();
testMaterialJsonRoundTrip();
testSceneValidatesEmbeddedMaterials();
} }