feat: add prefabs
This commit is contained in:
@@ -109,6 +109,9 @@ set(SRC_FILES
|
||||
"src/Assets/AssetManager.cpp"
|
||||
|
||||
"src/Serialization/SceneSerializer.cpp"
|
||||
"src/Serialization/ObjectSerialization.cpp"
|
||||
"src/Serialization/PrefabSerializer.cpp"
|
||||
"src/Serialization/PrefabCache.cpp"
|
||||
"src/Serialization/ComponentRegistry.cpp"
|
||||
|
||||
"src/Util/DeltaTime.cpp"
|
||||
|
||||
@@ -27,11 +27,13 @@ struct SceneEnvironment {
|
||||
float skyboxRotation{0.0f};
|
||||
};
|
||||
class SceneSerializer;
|
||||
class PrefabSerializer;
|
||||
class PhysicsWorld;
|
||||
|
||||
class Scene final {
|
||||
friend Scene& SceneManager::CreateScene(const std::string& name);
|
||||
friend class SceneSerializer;
|
||||
friend class PrefabSerializer;
|
||||
|
||||
public:
|
||||
// void Add(std::shared_ptr<GameObject> object);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#ifndef DESTRUM_OBJECTSERIALIZATION_H
|
||||
#define DESTRUM_OBJECTSERIALIZATION_H
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <destrum/ObjectModel/ObjectId.h>
|
||||
|
||||
class GameObject;
|
||||
class Scene;
|
||||
|
||||
namespace ObjectSerialization {
|
||||
using ObjectMap = std::unordered_map<ObjectId, GameObject*>;
|
||||
using ValidationError = std::optional<std::string>;
|
||||
|
||||
constexpr int CurrentObjectVersion = 2;
|
||||
|
||||
[[nodiscard]] std::optional<std::string> FindNonFinite(const nlohmann::json& value, const std::string& path);
|
||||
|
||||
[[nodiscard]] ValidationError ValidateObjects(const nlohmann::json& objects, int version);
|
||||
|
||||
void WarnMissingAssets(const nlohmann::json& objects, const std::filesystem::path& source);
|
||||
|
||||
[[nodiscard]] nlohmann::json SerializeObjects(const std::vector<const GameObject*>& objects);
|
||||
|
||||
[[nodiscard]] std::vector<const GameObject*> CollectHierarchy(const GameObject& root);
|
||||
|
||||
struct BuiltObjects {
|
||||
std::vector<GameObject*> objects;
|
||||
ObjectMap idMap;
|
||||
};
|
||||
|
||||
[[nodiscard]] BuiltObjects BuildObjects(Scene& scene, const nlohmann::json& objects,
|
||||
const ObjectMap& externalObjects = {});
|
||||
|
||||
[[nodiscard]] bool WriteFileAtomically(const std::filesystem::path& path, const std::string& contents);
|
||||
}
|
||||
|
||||
#endif //DESTRUM_OBJECTSERIALIZATION_H
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef DESTRUM_PREFABCACHE_H
|
||||
#define DESTRUM_PREFABCACHE_H
|
||||
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <destrum/Singleton.h>
|
||||
|
||||
class PrefabCache final : public Singleton<PrefabCache> {
|
||||
friend class Singleton<PrefabCache>;
|
||||
|
||||
public:
|
||||
[[nodiscard]] std::shared_ptr<const nlohmann::json> Get(std::string_view path);
|
||||
void Invalidate(const std::filesystem::path& path);
|
||||
void Clear();
|
||||
|
||||
[[nodiscard]] static std::filesystem::path ResolvePath(std::string_view path);
|
||||
|
||||
private:
|
||||
PrefabCache() = default;
|
||||
|
||||
struct Entry {
|
||||
std::shared_ptr<const nlohmann::json> prefab;
|
||||
std::filesystem::file_time_type writeTime;
|
||||
};
|
||||
|
||||
[[nodiscard]] static std::string MakeKey(const std::filesystem::path& path);
|
||||
|
||||
std::unordered_map<std::string, Entry> m_Prefabs;
|
||||
};
|
||||
|
||||
#endif //DESTRUM_PREFABCACHE_H
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef DESTRUM_PREFABSERIALIZER_H
|
||||
#define DESTRUM_PREFABSERIALIZER_H
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
class GameObject;
|
||||
class Scene;
|
||||
class Transform;
|
||||
|
||||
class PrefabSerializer final {
|
||||
public:
|
||||
static constexpr int CurrentPrefabVersion = 1;
|
||||
|
||||
[[nodiscard]] static std::optional<nlohmann::json> Capture(const GameObject& root);
|
||||
static bool Save(const GameObject& root, const std::filesystem::path& path);
|
||||
|
||||
[[nodiscard]] static std::optional<nlohmann::json> LoadFile(const std::filesystem::path& path);
|
||||
[[nodiscard]] static std::optional<std::string> Validate(const nlohmann::json& prefab);
|
||||
|
||||
static GameObject* InstantiateFromJson(Scene& scene, const nlohmann::json& prefab, Transform* parent = nullptr);
|
||||
static GameObject* Instantiate(Scene& scene, std::string_view path, Transform* parent = nullptr);
|
||||
|
||||
static GameObject* Duplicate(GameObject& object);
|
||||
};
|
||||
|
||||
#endif //DESTRUM_PREFABSERIALIZER_H
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user