feat: add prefabs

This commit is contained in:
2026-10-18 17:51:56 +02:00
parent d21c2ccfd7
commit 7df65f52b5
14 changed files with 1154 additions and 494 deletions
@@ -0,0 +1,531 @@
#include <destrum/Serialization/ObjectSerialization.h>
#include <cmath>
#include <fstream>
#include <functional>
#include <limits>
#include <stdexcept>
#include <unordered_set>
#include <spdlog/spdlog.h>
#include <destrum/Assets/AssetReference.h>
#include <destrum/Components/Physics/Collider.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/GlmJson.h>
using json = nlohmann::json;
namespace {
using ObjectSerialization::ValidationError;
[[nodiscard]] bool IsFiniteNumber(const json& value) {
if (!value.is_number()) {
return false;
}
return std::isfinite(value.get<double>());
}
[[nodiscard]] bool IsFiniteArray(const json& value, std::size_t size) {
if (!value.is_array() || value.size() != size) {
return false;
}
for (const auto& element : value) {
if (!IsFiniteNumber(element)) {
return false;
}
}
return true;
}
[[nodiscard]] std::string DescribeObject(std::size_t index, const json& objectJson) {
std::string description = "objects[" + std::to_string(index) + "]";
if (objectJson.is_object() && objectJson.contains("name") && objectJson.at("name").is_string()) {
description += " \"" + objectJson.at("name").get<std::string>() + "\"";
}
return description;
}
[[nodiscard]] ValidationError ValidateTransform(const json& objectJson, int version) {
if (!objectJson.contains("transform")) {
return std::nullopt;
}
const auto& transform = objectJson.at("transform");
if (!transform.is_object()) {
return "transform must be an object";
}
// Version 1 wrote an empty placeholder transform object.
if (version <= 1 && transform.empty()) {
return std::nullopt;
}
for (const char* key : {"position", "rotation", "scale"}) {
if (!transform.contains(key)) {
return std::string{"transform is missing \""} + key + "\"";
}
}
if (!IsFiniteArray(transform.at("position"), 3)) {
return "transform.position must be 3 finite numbers";
}
if (!IsFiniteArray(transform.at("scale"), 3)) {
return "transform.scale must be 3 finite numbers";
}
const auto& rotation = transform.at("rotation");
if (!IsFiniteArray(rotation, 3) && !IsFiniteArray(rotation, 4)) {
return "transform.rotation must be 3 (euler) or 4 (quaternion) finite numbers";
}
return std::nullopt;
}
[[nodiscard]] ValidationError ValidateComponents(const json& objectJson) {
if (!objectJson.contains("components")) {
return std::nullopt;
}
const auto& components = objectJson.at("components");
if (!components.is_array()) {
return "components must be an array";
}
for (std::size_t i = 0; i < components.size(); ++i) {
const auto& componentJson = components.at(i);
const std::string where = "components[" + std::to_string(i) + "]";
if (!componentJson.is_object() || !componentJson.contains("type") ||
!componentJson.at("type").is_string()) {
return where + " must be an object with a string \"type\"";
}
const std::string type = componentJson.at("type").get<std::string>();
const std::string named = where + " " + type;
if (componentJson.contains("enabled") && !componentJson.at("enabled").is_boolean()) {
return named + ": \"enabled\" must be a boolean";
}
if (componentJson.contains("data")) {
const auto& data = componentJson.at("data");
if (!data.is_object()) {
return named + ": \"data\" must be an object, got " + std::string{data.type_name()};
}
if (auto bad = ObjectSerialization::FindNonFinite(data, "data")) {
return named + ": " + *bad + " is not a finite number";
}
}
if (!ComponentFactory::IsRegistered(type)) {
return named + ": unknown component type";
}
}
return std::nullopt;
}
[[nodiscard]] bool AssetFileExists(const std::string& path) {
if (path.empty() || PrimitiveMeshes::IsPrimitiveKey(path)) {
return true;
}
std::filesystem::path fullPath;
if (path.find("://") != std::string::npos) {
try {
fullPath = AssetFS::GetInstance().GetFullPath(path);
} catch (const std::exception&) {
return false;
}
} else {
fullPath = path;
}
return std::filesystem::exists(fullPath);
}
void CollectHierarchyRecursive(const GameObject& object, std::vector<const GameObject*>& out,
std::unordered_set<const GameObject*>& visited) {
if (object.IsBeingDestroyed() || !visited.insert(&object).second) {
return;
}
out.push_back(&object);
for (const Transform* child : object.GetTransform().GetChildren()) {
if (child != nullptr && child->GetOwner() != nullptr) {
CollectHierarchyRecursive(*child->GetOwner(), out, visited);
}
}
}
}
namespace ObjectSerialization {
// Returns the JSON path of the first NaN/Inf, nlohmann would write it as null
std::optional<std::string> FindNonFinite(const json& value, const std::string& path) {
if (value.is_number_float()) {
if (!std::isfinite(value.get<double>())) {
return path;
}
return std::nullopt;
}
if (value.is_array()) {
for (std::size_t i = 0; i < value.size(); ++i) {
if (auto bad = FindNonFinite(value.at(i), path + "[" + std::to_string(i) + "]")) {
return bad;
}
}
return std::nullopt;
}
if (value.is_object()) {
for (auto it = value.begin(); it != value.end(); ++it) {
if (auto bad = FindNonFinite(it.value(), path + "." + it.key())) {
return bad;
}
}
}
return std::nullopt;
}
ValidationError ValidateObjects(const json& objects, int version) {
if (!objects.is_array()) {
return "\"objects\" must be an array";
}
std::unordered_set<ObjectId> ids;
std::unordered_map<ObjectId, ObjectId> parents;
std::unordered_map<ObjectId, std::string> descriptions;
for (std::size_t index = 0; index < objects.size(); ++index) {
const auto& objectJson = objects.at(index);
const std::string where = DescribeObject(index, objectJson);
if (!objectJson.is_object()) {
return where + " must be an object";
}
if (!objectJson.contains("id") || !objectJson.at("id").is_number_unsigned()) {
return where + " must have a non-negative integer \"id\"";
}
const ObjectId id = objectJson.at("id").get<ObjectId>();
if (id == InvalidObjectId || id >= std::numeric_limits<ObjectId>::max() - 1) {
return where + " uses reserved id " + std::to_string(id);
}
if (!ids.insert(id).second) {
return where + " reuses id " + std::to_string(id);
}
descriptions.emplace(id, where);
if (objectJson.contains("name") && !objectJson.at("name").is_string()) {
return where + ": \"name\" must be a string";
}
if (objectJson.contains("active") && !objectJson.at("active").is_boolean()) {
return where + ": \"active\" must be a boolean";
}
if (auto error = ValidateTransform(objectJson, version)) {
return where + ": " + *error;
}
ObjectId parent = InvalidObjectId;
if (objectJson.contains("parent")) {
if (!objectJson.at("parent").is_number_unsigned()) {
return where + ": \"parent\" must be an object id";
}
parent = objectJson.at("parent").get<ObjectId>();
}
if (parent == id) {
return where + " is its own parent";
}
parents.emplace(id, parent);
if (auto error = ValidateComponents(objectJson)) {
return where + " " + *error;
}
}
for (const auto& [id, parent] : parents) {
if (parent != InvalidObjectId && !ids.contains(parent)) {
return descriptions.at(id) + " has missing parent id " + std::to_string(parent);
}
std::unordered_set<ObjectId> visited;
ObjectId current = id;
while (current != InvalidObjectId) {
if (!visited.insert(current).second) {
return descriptions.at(id) + " is part of a parent cycle";
}
const auto parentIt = parents.find(current);
if (parentIt == parents.end()) {
return descriptions.at(id) + " has missing ancestor id " + std::to_string(current);
}
current = parentIt->second;
}
}
return std::nullopt;
}
void WarnMissingAssets(const json& objects, const std::filesystem::path& source) {
const auto report = [&](const std::string& objectName, const std::string& detail) {
spdlog::warn("Asset warning ({}): {} references \"{}\" which could not be found",
source.string(), objectName, detail);
};
for (const auto& objectJson : objects) {
const std::string objectName = objectJson.value("name", "GameObject");
if (!objectJson.contains("components")) {
continue;
}
for (const auto& componentJson : objectJson.at("components")) {
if (!componentJson.contains("data")) {
continue;
}
const auto& data = componentJson.at("data");
// MeshRendererComponent: meshKey / materialKey are asset cache keys.
for (const char* key : {"meshKey", "materialKey"}) {
if (data.contains(key) && data.at(key).is_string()) {
const std::string cacheKey = data.at(key).get<std::string>();
if (cacheKey.empty()) continue;
const auto asset = AssetReference::fromCacheKey(cacheKey);
// Runtime-only keys like "BulbMaterial#2" are not file paths
const bool looksLikePath = asset &&
asset->path.find_first_of(":/\\") != std::string::npos;
if (looksLikePath && !AssetFileExists(asset->path)) {
report(objectName, cacheKey);
}
}
}
// Generic: any nested object with a "path" field is an AssetReference.
std::function<void(const json&)> walk = [&](const json& node) {
if (node.is_object()) {
if (node.contains("path") && node.at("path").is_string()) {
const std::string path = node.at("path").get<std::string>();
if (!path.empty() && !AssetFileExists(path)) {
report(objectName, path);
}
}
for (auto it = node.begin(); it != node.end(); ++it) {
walk(it.value());
}
} else if (node.is_array()) {
for (const auto& element : node) {
walk(element);
}
}
};
walk(data);
}
}
}
std::vector<const GameObject*> CollectHierarchy(const GameObject& root) {
std::vector<const GameObject*> objects;
std::unordered_set<const GameObject*> visited;
CollectHierarchyRecursive(root, objects, visited);
return objects;
}
json SerializeObjects(const std::vector<const GameObject*>& objects) {
const std::unordered_set<const GameObject*> included(objects.begin(), objects.end());
json result = json::array();
for (const GameObject* objectPtr : objects) {
if (objectPtr == nullptr || objectPtr->IsBeingDestroyed()) {
continue;
}
const GameObject& object = *objectPtr;
if (object.GetComponent<Rigidbody>() != nullptr &&
object.GetComponent<Collider>() == nullptr) {
throw std::runtime_error("\"" + object.GetName() + "\" has a Rigidbody but no Collider");
}
const Transform& transform = object.GetTransform();
json objectJson;
objectJson["id"] = object.GetId();
objectJson["name"] = object.GetName();
objectJson["active"] = object.IsActive();
objectJson["transform"] = {
{"position", transform.GetLocalPosition()},
{"rotation", transform.GetLocalRotation()},
{"scale", transform.GetLocalScale()}
};
const Transform* parent = transform.GetParent();
const GameObject* parentObject = parent != nullptr ? parent->GetOwner() : nullptr;
objectJson["parent"] = parentObject != nullptr && included.contains(parentObject) &&
!parentObject->IsBeingDestroyed()
? parentObject->GetId()
: InvalidObjectId;
objectJson["components"] = json::array();
for (const auto& componentPtr : object.GetComponents()) {
if (!componentPtr || componentPtr->IsBeingDestroyed()) {
continue;
}
const Component& component = *componentPtr;
json data;
try {
data = component.Serialize();
} catch (const std::exception& exception) {
throw std::runtime_error("failed to serialize " + component.GetTypeName() +
" on \"" + object.GetName() + "\": " + exception.what());
}
if (data.is_null()) {
data = json::object();
}
if (auto bad = FindNonFinite(data, "data")) {
throw std::runtime_error(component.GetTypeName() + " on \"" + object.GetName() +
"\": " + *bad + " is NaN or Inf");
}
objectJson["components"].push_back({
{"type", component.GetTypeName()},
{"enabled", component.isEnabled()},
{"data", std::move(data)}
});
}
result.push_back(std::move(objectJson));
}
return result;
}
BuiltObjects BuildObjects(Scene& scene, const json& objects, const ObjectMap& externalObjects) {
BuiltObjects built;
std::vector<const json*> objectJsonList;
std::string context = "objects";
try {
for (const auto& objectJson : objects) {
const ObjectId id = objectJson.at("id").get<ObjectId>();
const std::string name = objectJson.value("name", "GameObject");
const bool active = objectJson.value("active", true);
// File ids are local to the file, every loaded object gets a fresh runtime id
GameObject* object = scene.CreateGameObject(name);
object->SetActive(active);
built.objects.push_back(object);
built.idMap.emplace(id, object);
objectJsonList.push_back(&objectJson);
}
for (std::size_t i = 0; i < objectJsonList.size(); ++i) {
const json& objectJson = *objectJsonList[i];
GameObject* object = built.objects[i];
context = "\"" + object->GetName() + "\" transform";
if (objectJson.contains("transform") &&
!objectJson.at("transform").empty()) {
const auto& transformJson = objectJson.at("transform");
const auto& rotation = transformJson.at("rotation");
object->GetTransform().SetLocalPosition(transformJson.at("position").get<glm::vec3>());
if (rotation.size() == 4) {
object->GetTransform().SetLocalRotation(rotation.get<glm::quat>());
} else {
// Version 1 scene files used three Euler angles in
// degrees. Continue reading that format safely.
object->GetTransform().SetLocalRotation(rotation.get<glm::vec3>());
}
object->GetTransform().SetLocalScale(transformJson.at("scale").get<glm::vec3>());
}
const ObjectId parentId = objectJson.value("parent", InvalidObjectId);
if (parentId != InvalidObjectId) {
object->GetTransform().SetParent(&built.idMap.at(parentId)->GetTransform(), false);
}
}
for (std::size_t i = 0; i < objectJsonList.size(); ++i) {
const json& objectJson = *objectJsonList[i];
GameObject* object = built.objects[i];
if (!objectJson.contains("components")) {
continue;
}
for (const auto& componentJson : objectJson.at("components")) {
const std::string type = componentJson.at("type").get<std::string>();
context = type + " on \"" + object->GetName() + "\"";
Component* component = ComponentFactory::Create(type, *object);
if (component == nullptr) {
throw std::runtime_error("unknown component type");
}
component->SetEnabled(componentJson.value("enabled", true));
if (componentJson.contains("data")) {
component->Deserialize(componentJson.at("data"));
}
}
}
ObjectMap referenceMap = built.idMap;
referenceMap.insert(externalObjects.begin(), externalObjects.end());
for (GameObject* object : built.objects) {
for (const auto& componentPtr : object->GetComponents()) {
if (componentPtr) {
context = componentPtr->GetTypeName() + " on \"" + object->GetName() + "\" references";
componentPtr->ResolveReferences(referenceMap);
}
}
}
} catch (const std::exception& exception) {
for (GameObject* object : built.objects) {
object->Destroy();
}
throw std::runtime_error(context + ": " + exception.what());
}
return built;
}
bool WriteFileAtomically(const std::filesystem::path& path, const std::string& contents) {
std::filesystem::path tempPath = path;
tempPath += ".tmp";
{
std::ofstream file(tempPath, std::ios::binary | std::ios::trunc);
if (!file.is_open()) {
spdlog::error("Failed to open file for writing: {}", tempPath.string());
return false;
}
file << contents;
file.flush();
if (!file.good()) {
spdlog::error("Failed to write file: {}", tempPath.string());
file.close();
std::error_code ignored;
std::filesystem::remove(tempPath, ignored);
return false;
}
}
std::error_code error;
std::filesystem::rename(tempPath, path, error);
if (error) {
spdlog::error("Failed to replace file {}: {}", path.string(), error.message());
std::error_code ignored;
std::filesystem::remove(tempPath, ignored);
return false;
}
return true;
}
}
+59
View File
@@ -0,0 +1,59 @@
#include <destrum/Serialization/PrefabCache.h>
#include <spdlog/spdlog.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Serialization/PrefabSerializer.h>
std::shared_ptr<const nlohmann::json> PrefabCache::Get(std::string_view path) {
std::filesystem::path fullPath;
try {
fullPath = ResolvePath(path);
} catch (const std::exception& exception) {
spdlog::error("Cannot resolve prefab path \"{}\": {}", path, exception.what());
return nullptr;
}
std::error_code error;
const auto writeTime = std::filesystem::last_write_time(fullPath, error);
if (error) {
spdlog::error("Prefab file not found: {}", fullPath.string());
return nullptr;
}
const std::string key = MakeKey(fullPath);
if (const auto it = m_Prefabs.find(key); it != m_Prefabs.end() && it->second.writeTime == writeTime) {
return it->second.prefab;
}
auto prefab = PrefabSerializer::LoadFile(fullPath);
if (!prefab) {
m_Prefabs.erase(key);
return nullptr;
}
auto shared = std::make_shared<const nlohmann::json>(std::move(*prefab));
m_Prefabs[key] = Entry{shared, writeTime};
return shared;
}
void PrefabCache::Invalidate(const std::filesystem::path& path) {
m_Prefabs.erase(MakeKey(path));
}
void PrefabCache::Clear() {
m_Prefabs.clear();
}
std::filesystem::path PrefabCache::ResolvePath(std::string_view path) {
if (path.find("://") != std::string_view::npos) {
return AssetFS::GetInstance().GetFullPath(path);
}
return std::filesystem::path{path};
}
std::string PrefabCache::MakeKey(const std::filesystem::path& path) {
std::error_code error;
const auto absolute = std::filesystem::absolute(path, error);
return (error ? path : absolute).lexically_normal().generic_string();
}
@@ -0,0 +1,190 @@
#include <destrum/Serialization/PrefabSerializer.h>
#include <fstream>
#include <spdlog/spdlog.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/ObjectSerialization.h>
#include <destrum/Serialization/PrefabCache.h>
using json = nlohmann::json;
namespace {
GameObject* InstantiateWith(Scene& scene, const json& prefab, Transform* parent,
const ObjectSerialization::ObjectMap& externalObjects) {
if (scene.IsBeingUnloaded()) {
spdlog::error("Cannot instantiate a prefab into a scene that is being unloaded: {}", scene.GetName());
return nullptr;
}
if (auto error = PrefabSerializer::Validate(prefab)) {
spdlog::error("Cannot instantiate invalid prefab: {}", *error);
return nullptr;
}
GameObject* parentObject = parent != nullptr ? parent->GetOwner() : nullptr;
if (parentObject != nullptr && parentObject->GetScene() != &scene) {
spdlog::error("Cannot instantiate a prefab under \"{}\", it belongs to another scene",
parentObject->GetName());
return nullptr;
}
try {
auto built = ObjectSerialization::BuildObjects(scene, prefab.at("objects"), externalObjects);
GameObject* root = built.objects.front();
if (parent != nullptr) {
root->GetTransform().SetParent(parent, false);
}
return root;
} catch (const std::exception& exception) {
spdlog::error("Failed to instantiate prefab \"{}\" ({})",
prefab.value("name", "Prefab"), exception.what());
return nullptr;
}
}
}
std::optional<json> PrefabSerializer::Capture(const GameObject& root) {
if (root.IsBeingDestroyed()) {
spdlog::error("Cannot capture a prefab from destroyed object \"{}\"", root.GetName());
return std::nullopt;
}
json prefab;
prefab["version"] = CurrentPrefabVersion;
prefab["name"] = root.GetName();
try {
prefab["objects"] = ObjectSerialization::SerializeObjects(ObjectSerialization::CollectHierarchy(root));
} catch (const std::exception& exception) {
spdlog::error("Cannot capture prefab \"{}\": {}", root.GetName(), exception.what());
return std::nullopt;
}
if (auto error = Validate(prefab)) {
spdlog::error("Cannot capture prefab \"{}\", it would not load back: {}", root.GetName(), *error);
return std::nullopt;
}
return prefab;
}
bool PrefabSerializer::Save(const GameObject& root, const std::filesystem::path& path) {
const auto prefab = Capture(root);
if (!prefab) {
return false;
}
std::string contents;
try {
contents = prefab->dump(4);
} catch (const std::exception& exception) {
spdlog::error("Failed to encode prefab \"{}\": {}", root.GetName(), exception.what());
return false;
}
if (!ObjectSerialization::WriteFileAtomically(path, contents)) {
return false;
}
PrefabCache::GetInstance().Invalidate(path);
return true;
}
std::optional<json> PrefabSerializer::LoadFile(const std::filesystem::path& path) {
std::ifstream file(path);
if (!file.is_open()) {
spdlog::error("Failed to open prefab file for reading: {}", path.string());
return std::nullopt;
}
json prefab;
try {
file >> prefab;
} catch (const std::exception& exception) {
spdlog::error("Failed to parse prefab file {}: {}", path.string(), exception.what());
return std::nullopt;
}
if (auto error = Validate(prefab)) {
spdlog::error("Invalid prefab data in {}: {}", path.string(), *error);
return std::nullopt;
}
ObjectSerialization::WarnMissingAssets(prefab.at("objects"), path);
return prefab;
}
std::optional<std::string> PrefabSerializer::Validate(const json& prefab) {
RegisterEngineComponents();
if (!prefab.is_object()) {
return "root must be an object";
}
if (!prefab.contains("version") || !prefab.at("version").is_number_integer()) {
return "\"version\" must be an integer";
}
const int version = prefab.at("version").get<int>();
if (version < 1 || version > CurrentPrefabVersion) {
return "unsupported prefab version " + std::to_string(version);
}
if (prefab.contains("name") && !prefab.at("name").is_string()) {
return "\"name\" must be a string";
}
if (!prefab.contains("objects") || !prefab.at("objects").is_array() || prefab.at("objects").empty()) {
return "prefab must contain a non-empty \"objects\" array";
}
const auto& objects = prefab.at("objects");
if (auto error = ObjectSerialization::ValidateObjects(objects, ObjectSerialization::CurrentObjectVersion)) {
return error;
}
for (std::size_t i = 0; i < objects.size(); ++i) {
const bool isRoot = objects.at(i).value("parent", InvalidObjectId) == InvalidObjectId;
if (i == 0 && !isRoot) {
return "objects[0] must be the prefab root";
}
if (i != 0 && isRoot) {
return "objects[" + std::to_string(i) + "] is a second root, a prefab has exactly one";
}
}
return std::nullopt;
}
GameObject* PrefabSerializer::InstantiateFromJson(Scene& scene, const json& prefab, Transform* parent) {
return InstantiateWith(scene, prefab, parent, {});
}
GameObject* PrefabSerializer::Instantiate(Scene& scene, std::string_view path, Transform* parent) {
const auto prefab = PrefabCache::GetInstance().Get(path);
if (!prefab) {
return nullptr;
}
return InstantiateWith(scene, *prefab, parent, {});
}
GameObject* PrefabSerializer::Duplicate(GameObject& object) {
Scene* scene = object.GetScene();
if (scene == nullptr) {
spdlog::error("Cannot duplicate \"{}\", it is not in a scene", object.GetName());
return nullptr;
}
const auto prefab = Capture(object);
if (!prefab) {
return nullptr;
}
// References to objects outside the copied hierarchy keep pointing at the originals
ObjectSerialization::ObjectMap sceneObjects;
for (const auto* list : {&scene->m_objects, &scene->m_pendingAdditions}) {
for (const auto& sceneObject : *list) {
if (sceneObject && !sceneObject->IsBeingDestroyed()) {
sceneObjects.emplace(sceneObject->GetId(), sceneObject.get());
}
}
}
return InstantiateWith(*scene, *prefab, object.GetTransform().GetParent(), sceneObjects);
}
+20 -475
View File
@@ -1,171 +1,26 @@
#include <destrum/Serialization/SceneSerializer.h>
#include <cmath>
#include <algorithm>
#include <fstream>
#include <limits>
#include <optional>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <destrum/Scene/Scene.h>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/ObjectId.h>
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Assets/AssetReference.h>
#include <destrum/Components/Physics/Collider.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
#include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/ObjectSerialization.h>
using json = nlohmann::json;
namespace {
using ValidationError = std::optional<std::string>;
using ObjectSerialization::ValidationError;
constexpr int CurrentSceneVersion = 2;
[[nodiscard]] bool IsFiniteNumber(const json& value) {
if (!value.is_number()) {
return false;
}
return std::isfinite(value.get<double>());
}
[[nodiscard]] bool IsFiniteArray(const json& value, std::size_t size) {
if (!value.is_array() || value.size() != size) {
return false;
}
for (const auto& element : value) {
if (!IsFiniteNumber(element)) {
return false;
}
}
return true;
}
// Returns the JSON path of the first NaN/Inf, nlohmann would write it as null
[[nodiscard]] std::optional<std::string> FindNonFinite(const json& value, const std::string& path) {
if (value.is_number_float()) {
if (!std::isfinite(value.get<double>())) {
return path;
}
return std::nullopt;
}
if (value.is_array()) {
for (std::size_t i = 0; i < value.size(); ++i) {
if (auto bad = FindNonFinite(value.at(i), path + "[" + std::to_string(i) + "]")) {
return bad;
}
}
return std::nullopt;
}
if (value.is_object()) {
for (auto it = value.begin(); it != value.end(); ++it) {
if (auto bad = FindNonFinite(it.value(), path + "." + it.key())) {
return bad;
}
}
}
return std::nullopt;
}
[[nodiscard]] std::string DescribeObject(std::size_t index, const json& objectJson) {
std::string description = "objects[" + std::to_string(index) + "]";
if (objectJson.is_object() && objectJson.contains("name") && objectJson.at("name").is_string()) {
description += " \"" + objectJson.at("name").get<std::string>() + "\"";
}
return description;
}
[[nodiscard]] ValidationError ValidateTransform(const json& objectJson, int version) {
if (!objectJson.contains("transform")) {
return std::nullopt;
}
const auto& transform = objectJson.at("transform");
if (!transform.is_object()) {
return "transform must be an object";
}
// Version 1 wrote an empty placeholder transform object.
if (version <= 1 && transform.empty()) {
return std::nullopt;
}
for (const char* key : {"position", "rotation", "scale"}) {
if (!transform.contains(key)) {
return std::string{"transform is missing \""} + key + "\"";
}
}
if (!IsFiniteArray(transform.at("position"), 3)) {
return "transform.position must be 3 finite numbers";
}
if (!IsFiniteArray(transform.at("scale"), 3)) {
return "transform.scale must be 3 finite numbers";
}
const auto& rotation = transform.at("rotation");
if (!IsFiniteArray(rotation, 3) && !IsFiniteArray(rotation, 4)) {
return "transform.rotation must be 3 (euler) or 4 (quaternion) finite numbers";
}
return std::nullopt;
}
[[nodiscard]] ValidationError ValidateComponents(const json& objectJson) {
if (!objectJson.contains("components")) {
return std::nullopt;
}
const auto& components = objectJson.at("components");
if (!components.is_array()) {
return "components must be an array";
}
for (std::size_t i = 0; i < components.size(); ++i) {
const auto& componentJson = components.at(i);
const std::string where = "components[" + std::to_string(i) + "]";
if (!componentJson.is_object() || !componentJson.contains("type") ||
!componentJson.at("type").is_string()) {
return where + " must be an object with a string \"type\"";
}
const std::string type = componentJson.at("type").get<std::string>();
const std::string named = where + " " + type;
if (componentJson.contains("enabled") && !componentJson.at("enabled").is_boolean()) {
return named + ": \"enabled\" must be a boolean";
}
if (componentJson.contains("data")) {
const auto& data = componentJson.at("data");
if (!data.is_object()) {
return named + ": \"data\" must be an object, got " + std::string{data.type_name()};
}
if (auto bad = FindNonFinite(data, "data")) {
return named + ": " + *bad + " is not a finite number";
}
}
if (!ComponentFactory::IsRegistered(type)) {
return named + ": unknown component type";
}
}
return std::nullopt;
}
constexpr int CurrentSceneVersion = ObjectSerialization::CurrentObjectVersion;
[[nodiscard]] ValidationError ValidateSceneJson(const json& root) {
if (!root.is_object()) {
@@ -194,186 +49,7 @@ namespace {
return "\"currentCamera\" must be an object id";
}
std::unordered_set<ObjectId> ids;
std::unordered_map<ObjectId, ObjectId> parents;
std::unordered_map<ObjectId, std::string> descriptions;
const auto& objects = root.at("objects");
for (std::size_t index = 0; index < objects.size(); ++index) {
const auto& objectJson = objects.at(index);
const std::string where = DescribeObject(index, objectJson);
if (!objectJson.is_object()) {
return where + " must be an object";
}
if (!objectJson.contains("id") || !objectJson.at("id").is_number_unsigned()) {
return where + " must have a non-negative integer \"id\"";
}
const ObjectId id = objectJson.at("id").get<ObjectId>();
if (id == InvalidObjectId || id >= std::numeric_limits<ObjectId>::max() - 1) {
return where + " uses reserved id " + std::to_string(id);
}
if (!ids.insert(id).second) {
return where + " reuses id " + std::to_string(id);
}
descriptions.emplace(id, where);
if (objectJson.contains("name") && !objectJson.at("name").is_string()) {
return where + ": \"name\" must be a string";
}
if (objectJson.contains("active") && !objectJson.at("active").is_boolean()) {
return where + ": \"active\" must be a boolean";
}
if (auto error = ValidateTransform(objectJson, version)) {
return where + ": " + *error;
}
ObjectId parent = InvalidObjectId;
if (objectJson.contains("parent")) {
if (!objectJson.at("parent").is_number_unsigned()) {
return where + ": \"parent\" must be an object id";
}
parent = objectJson.at("parent").get<ObjectId>();
}
if (parent == id) {
return where + " is its own parent";
}
parents.emplace(id, parent);
if (auto error = ValidateComponents(objectJson)) {
return where + " " + *error;
}
}
for (const auto& [id, parent] : parents) {
if (parent != InvalidObjectId && !ids.contains(parent)) {
return descriptions.at(id) + " has missing parent id " + std::to_string(parent);
}
std::unordered_set<ObjectId> visited;
ObjectId current = id;
while (current != InvalidObjectId) {
if (!visited.insert(current).second) {
return descriptions.at(id) + " is part of a parent cycle";
}
const auto parentIt = parents.find(current);
if (parentIt == parents.end()) {
return descriptions.at(id) + " has missing ancestor id " + std::to_string(current);
}
current = parentIt->second;
}
}
return std::nullopt;
}
[[nodiscard]] bool AssetFileExists(const std::string& path) {
if (path.empty() || PrimitiveMeshes::IsPrimitiveKey(path)) {
return true;
}
std::filesystem::path fullPath;
if (path.find("://") != std::string::npos) {
try {
fullPath = AssetFS::GetInstance().GetFullPath(path);
} catch (const std::exception&) {
return false;
}
} else {
fullPath = path;
}
return std::filesystem::exists(fullPath);
}
void ValidateAssetReferences(const json& root, const std::filesystem::path& scenePath) {
const auto report = [&](const std::string& objectName, const std::string& detail) {
spdlog::warn("Scene asset warning ({}): {} references \"{}\" which could not be found",
scenePath.string(), objectName, detail);
};
for (const auto& objectJson : root.at("objects")) {
const std::string objectName = objectJson.value("name", "GameObject");
if (!objectJson.contains("components")) {
continue;
}
for (const auto& componentJson : objectJson.at("components")) {
if (!componentJson.contains("data")) {
continue;
}
const auto& data = componentJson.at("data");
// MeshRendererComponent: meshKey / materialKey are asset cache keys.
for (const char* key : {"meshKey", "materialKey"}) {
if (data.contains(key) && data.at(key).is_string()) {
const std::string cacheKey = data.at(key).get<std::string>();
if (cacheKey.empty()) continue;
const auto asset = AssetReference::fromCacheKey(cacheKey);
// Runtime-only keys like "BulbMaterial#2" are not file paths
const bool looksLikePath = asset &&
asset->path.find_first_of(":/\\") != std::string::npos;
if (looksLikePath && !AssetFileExists(asset->path)) {
report(objectName, cacheKey);
}
}
}
// Generic: any nested object with a "path" field is an AssetReference.
std::function<void(const json&)> walk = [&](const json& node) {
if (node.is_object()) {
if (node.contains("path") && node.at("path").is_string()) {
const std::string path = node.at("path").get<std::string>();
if (!path.empty() && !AssetFileExists(path)) {
report(objectName, path);
}
}
for (auto it = node.begin(); it != node.end(); ++it) {
walk(it.value());
}
} else if (node.is_array()) {
for (const auto& element : node) {
walk(element);
}
}
};
walk(data);
}
}
}
[[nodiscard]] bool WriteFileAtomically(const std::filesystem::path& path, const std::string& contents) {
std::filesystem::path tempPath = path;
tempPath += ".tmp";
{
std::ofstream file(tempPath, std::ios::binary | std::ios::trunc);
if (!file.is_open()) {
spdlog::error("Failed to open scene file for writing: {}", tempPath.string());
return false;
}
file << contents;
file.flush();
if (!file.good()) {
spdlog::error("Failed to write scene file: {}", tempPath.string());
file.close();
std::error_code ignored;
std::filesystem::remove(tempPath, ignored);
return false;
}
}
std::error_code error;
std::filesystem::rename(tempPath, path, error);
if (error) {
spdlog::error("Failed to replace scene file {}: {}", path.string(), error.message());
std::error_code ignored;
std::filesystem::remove(tempPath, ignored);
return false;
}
return true;
return ObjectSerialization::ValidateObjects(root.at("objects"), version);
}
}
@@ -408,72 +84,22 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) {
{"skyboxRotation", environment.skyboxRotation},
};
std::vector<const GameObject*> objects;
objects.reserve(scene.GetObjects().size());
for (const auto& objectPtr : scene.GetObjects()) {
if (!objectPtr || objectPtr->IsBeingDestroyed()) {
continue;
if (objectPtr && !objectPtr->IsBeingDestroyed()) {
objects.push_back(objectPtr.get());
}
const GameObject& object = *objectPtr;
if (object.GetComponent<Rigidbody>() != nullptr &&
object.GetComponent<Collider>() == nullptr) {
spdlog::error("Cannot save object with a Rigidbody but no Collider: {}", object.GetName());
return false;
}
const Transform& transform = object.GetTransform();
json objectJson;
objectJson["id"] = object.GetId();
objectJson["name"] = object.GetName();
objectJson["active"] = object.IsActive();
objectJson["transform"] = {
{"position", transform.GetLocalPosition()},
{"rotation", transform.GetLocalRotation()},
{"scale", transform.GetLocalScale()}
};
const Transform* parent = transform.GetParent();
objectJson["parent"] = parent != nullptr && parent->GetOwner() != nullptr &&
parent->GetOwner()->GetScene() == &scene &&
!parent->GetOwner()->IsBeingDestroyed()
? parent->GetOwner()->GetId()
: InvalidObjectId;
objectJson["components"] = json::array();
for (const auto& componentPtr : object.GetComponents()) {
if (!componentPtr || componentPtr->IsBeingDestroyed()) {
continue;
}
const Component& component = *componentPtr;
json data;
try {
data = component.Serialize();
} catch (const std::exception& exception) {
spdlog::error("Failed to serialize {} on \"{}\": {}",
component.GetTypeName(), object.GetName(), exception.what());
return false;
}
if (data.is_null()) {
data = json::object();
}
if (auto bad = FindNonFinite(data, "data")) {
spdlog::error("Cannot save {} on \"{}\": {} is NaN or Inf",
component.GetTypeName(), object.GetName(), *bad);
return false;
}
objectJson["components"].push_back({
{"type", component.GetTypeName()},
{"enabled", component.isEnabled()},
{"data", std::move(data)}
});
}
root["objects"].push_back(std::move(objectJson));
}
if (auto bad = FindNonFinite(root, "scene")) {
try {
root["objects"] = ObjectSerialization::SerializeObjects(objects);
} catch (const std::exception& exception) {
spdlog::error("Cannot save scene \"{}\": {}", scene.GetName(), exception.what());
return false;
}
if (auto bad = ObjectSerialization::FindNonFinite(root, "scene")) {
spdlog::error("Cannot save scene \"{}\": {} is NaN or Inf", scene.GetName(), *bad);
return false;
}
@@ -492,7 +118,7 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) {
return false;
}
return WriteFileAtomically(path, contents);
return ObjectSerialization::WriteFileAtomically(path, contents);
}
SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem::path& path) {
@@ -520,7 +146,7 @@ SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem
return result;
}
ValidateAssetReferences(result.root, path);
ObjectSerialization::WarnMissingAssets(result.root.at("objects"), path);
result.success = true;
return result;
}
@@ -552,26 +178,12 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result,
// No physics world is created for the staging scene - physics bodies are
// registered directly in the live scene's world once loading succeeds.
Scene stagingScene(scene.GetName(), nullptr);
std::unordered_map<ObjectId, GameObject*> idMap;
std::vector<const json*> objectJsonList;
ObjectSerialization::ObjectMap idMap;
std::vector<GameObject*> registeredObjects;
std::string context = "scene";
try {
for (const auto& objectJson : root.at("objects")) {
const ObjectId id = objectJson.at("id").get<ObjectId>();
const std::string name = objectJson.value("name", "GameObject");
const bool active = objectJson.value("active", true);
// File ids are local to the file, every loaded object gets a fresh runtime id
GameObject* object = stagingScene.CreateGameObject(name);
object->SetActive(active);
idMap.emplace(id, object);
objectJsonList.push_back(&objectJson);
}
if (progressCallback) progressCallback(0.4f);
idMap = ObjectSerialization::BuildObjects(stagingScene, root.at("objects")).idMap;
// Move objects from pending to objects list without physics registration
// (staging scene has no physics world).
@@ -582,73 +194,6 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result,
}
stagingScene.m_pendingAdditions.clear();
for (const json* objectJson : objectJsonList) {
const ObjectId id = objectJson->at("id").get<ObjectId>();
GameObject* object = idMap.at(id);
context = "\"" + object->GetName() + "\" transform";
if (objectJson->contains("transform") &&
!objectJson->at("transform").empty()) {
const auto& transformJson = objectJson->at("transform");
const auto& rotation = transformJson.at("rotation");
object->GetTransform().SetLocalPosition(transformJson.at("position").get<glm::vec3>());
if (rotation.size() == 4) {
object->GetTransform().SetLocalRotation(rotation.get<glm::quat>());
} else {
// Version 1 scene files used three Euler angles in
// degrees. Continue reading that format safely.
object->GetTransform().SetLocalRotation(rotation.get<glm::vec3>());
}
object->GetTransform().SetLocalScale(transformJson.at("scale").get<glm::vec3>());
}
const ObjectId parentId = objectJson->value("parent", InvalidObjectId);
if (parentId != InvalidObjectId) {
object->GetTransform().SetParent(&idMap.at(parentId)->GetTransform(), false);
}
}
if (progressCallback) progressCallback(0.5f);
for (const json* objectJson : objectJsonList) {
GameObject* object = idMap.at(objectJson->at("id").get<ObjectId>());
if (!objectJson->contains("components")) {
continue;
}
for (const auto& componentJson : objectJson->at("components")) {
const std::string type = componentJson.at("type").get<std::string>();
context = type + " on \"" + object->GetName() + "\"";
Component* component = ComponentFactory::Create(type, *object);
if (component == nullptr) {
throw std::runtime_error("unknown component type");
}
component->SetEnabled(componentJson.value("enabled", true));
if (componentJson.contains("data")) {
component->Deserialize(componentJson.at("data"));
}
}
}
if (progressCallback) progressCallback(0.6f);
for (const auto& objectPtr : stagingScene.GetObjects()) {
if (!objectPtr) {
continue;
}
for (const auto& componentPtr : objectPtr->GetComponents()) {
if (componentPtr) {
context = componentPtr->GetTypeName() + " on \"" + objectPtr->GetName() + "\" references";
componentPtr->ResolveReferences(idMap);
}
}
}
if (progressCallback) progressCallback(0.7f);
// Register the replacement bodies in the live physics world before
+9
View File
@@ -17,6 +17,7 @@
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/PrefabSerializer.h>
namespace {
[[nodiscard]] bool IsInspectableObject(const GameObject* object) {
@@ -242,6 +243,14 @@ namespace ImGuiUtils {
if (ImGui::Checkbox("Active", &active)) {
selectedObject->SetActive(active);
}
ImGui::SameLine();
const bool duplicateShortcut = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) &&
ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_D);
if (ImGui::Button("Duplicate") || duplicateShortcut) {
if (GameObject* duplicate = PrefabSerializer::Duplicate(*selectedObject)) {
selectedObjectId = duplicate->GetId();
}
}
const Transform* parent = selectedObject->GetTransform().GetParent();
if (parent != nullptr && parent->GetOwner() != nullptr) {