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
+13
View File
@@ -201,6 +201,19 @@ std::filesystem::path AssetFS::GetCookedPathForFile(std::string_view vpath) cons
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::ifstream file(fullPath, std::ios::binary);
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) {
if (id >= images.size() || images[id].image == VK_NULL_HANDLE) {
return;
@@ -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<MaterialData*>(materialDataBuffer.info.pMappedData);
@@ -71,10 +71,9 @@ MaterialID MaterialCache::addMaterial(Material material)
sizeof(MaterialData));
const auto materialId = static_cast<MaterialID>(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;
@@ -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/Scene/Scene.h>
#include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/MaterialSerialization.h>
#include <destrum/Serialization/ObjectSerialization.h>
#include <destrum/Serialization/PrefabCache.h>
@@ -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<json> 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<std::string> 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;
@@ -13,6 +13,7 @@
#include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/MaterialSerialization.h>
#include <destrum/Serialization/ObjectSerialization.h>
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