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
+3
View File
@@ -109,6 +109,9 @@ set(SRC_FILES
"src/Assets/AssetManager.cpp" "src/Assets/AssetManager.cpp"
"src/Serialization/SceneSerializer.cpp" "src/Serialization/SceneSerializer.cpp"
"src/Serialization/ObjectSerialization.cpp"
"src/Serialization/PrefabSerializer.cpp"
"src/Serialization/PrefabCache.cpp"
"src/Serialization/ComponentRegistry.cpp" "src/Serialization/ComponentRegistry.cpp"
"src/Util/DeltaTime.cpp" "src/Util/DeltaTime.cpp"
+2
View File
@@ -27,11 +27,13 @@ struct SceneEnvironment {
float skyboxRotation{0.0f}; float skyboxRotation{0.0f};
}; };
class SceneSerializer; class SceneSerializer;
class PrefabSerializer;
class PhysicsWorld; class PhysicsWorld;
class Scene final { class Scene final {
friend Scene& SceneManager::CreateScene(const std::string& name); friend Scene& SceneManager::CreateScene(const std::string& name);
friend class SceneSerializer; friend class SceneSerializer;
friend class PrefabSerializer;
public: public:
// void Add(std::shared_ptr<GameObject> object); // 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;
}
}
+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);
}
+16 -471
View File
@@ -1,171 +1,26 @@
#include <destrum/Serialization/SceneSerializer.h> #include <destrum/Serialization/SceneSerializer.h>
#include <cmath>
#include <algorithm>
#include <fstream> #include <fstream>
#include <limits>
#include <optional>
#include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h> #include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/ObjectId.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/Collider.h>
#include <destrum/Components/Physics/Rigidbody.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/ComponentRegistry.h>
#include <destrum/Serialization/GlmJson.h> #include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/ObjectSerialization.h>
using json = nlohmann::json; using json = nlohmann::json;
namespace { namespace {
using ValidationError = std::optional<std::string>; using ObjectSerialization::ValidationError;
constexpr int CurrentSceneVersion = 2; constexpr int CurrentSceneVersion = ObjectSerialization::CurrentObjectVersion;
[[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;
}
[[nodiscard]] ValidationError ValidateSceneJson(const json& root) { [[nodiscard]] ValidationError ValidateSceneJson(const json& root) {
if (!root.is_object()) { if (!root.is_object()) {
@@ -194,186 +49,7 @@ namespace {
return "\"currentCamera\" must be an object id"; return "\"currentCamera\" must be an object id";
} }
std::unordered_set<ObjectId> ids; return ObjectSerialization::ValidateObjects(root.at("objects"), version);
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;
} }
} }
@@ -408,72 +84,22 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) {
{"skyboxRotation", environment.skyboxRotation}, {"skyboxRotation", environment.skyboxRotation},
}; };
std::vector<const GameObject*> objects;
objects.reserve(scene.GetObjects().size());
for (const auto& objectPtr : scene.GetObjects()) { for (const auto& objectPtr : scene.GetObjects()) {
if (!objectPtr || objectPtr->IsBeingDestroyed()) { if (objectPtr && !objectPtr->IsBeingDestroyed()) {
continue; 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 { try {
data = component.Serialize(); root["objects"] = ObjectSerialization::SerializeObjects(objects);
} catch (const std::exception& exception) { } catch (const std::exception& exception) {
spdlog::error("Failed to serialize {} on \"{}\": {}", spdlog::error("Cannot save scene \"{}\": {}", scene.GetName(), exception.what());
component.GetTypeName(), object.GetName(), exception.what());
return false; return false;
} }
if (data.is_null()) { if (auto bad = ObjectSerialization::FindNonFinite(root, "scene")) {
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")) {
spdlog::error("Cannot save scene \"{}\": {} is NaN or Inf", scene.GetName(), *bad); spdlog::error("Cannot save scene \"{}\": {} is NaN or Inf", scene.GetName(), *bad);
return false; return false;
} }
@@ -492,7 +118,7 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) {
return false; return false;
} }
return WriteFileAtomically(path, contents); return ObjectSerialization::WriteFileAtomically(path, contents);
} }
SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem::path& path) { SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem::path& path) {
@@ -520,7 +146,7 @@ SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem
return result; return result;
} }
ValidateAssetReferences(result.root, path); ObjectSerialization::WarnMissingAssets(result.root.at("objects"), path);
result.success = true; result.success = true;
return result; 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 // No physics world is created for the staging scene - physics bodies are
// registered directly in the live scene's world once loading succeeds. // registered directly in the live scene's world once loading succeeds.
Scene stagingScene(scene.GetName(), nullptr); Scene stagingScene(scene.GetName(), nullptr);
std::unordered_map<ObjectId, GameObject*> idMap; ObjectSerialization::ObjectMap idMap;
std::vector<const json*> objectJsonList;
std::vector<GameObject*> registeredObjects; std::vector<GameObject*> registeredObjects;
std::string context = "scene"; std::string context = "scene";
try { try {
for (const auto& objectJson : root.at("objects")) { idMap = ObjectSerialization::BuildObjects(stagingScene, root.at("objects")).idMap;
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);
// Move objects from pending to objects list without physics registration // Move objects from pending to objects list without physics registration
// (staging scene has no physics world). // (staging scene has no physics world).
@@ -582,73 +194,6 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result,
} }
stagingScene.m_pendingAdditions.clear(); 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); if (progressCallback) progressCallback(0.7f);
// Register the replacement bodies in the live physics world before // Register the replacement bodies in the live physics world before
+9
View File
@@ -17,6 +17,7 @@
#include <destrum/ObjectModel/Transform.h> #include <destrum/ObjectModel/Transform.h>
#include <destrum/Serialization/ComponentFactory.h> #include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/ComponentRegistry.h> #include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/PrefabSerializer.h>
namespace { namespace {
[[nodiscard]] bool IsInspectableObject(const GameObject* object) { [[nodiscard]] bool IsInspectableObject(const GameObject* object) {
@@ -242,6 +243,14 @@ namespace ImGuiUtils {
if (ImGui::Checkbox("Active", &active)) { if (ImGui::Checkbox("Active", &active)) {
selectedObject->SetActive(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(); const Transform* parent = selectedObject->GetTransform().GetParent();
if (parent != nullptr && parent->GetOwner() != nullptr) { if (parent != nullptr && parent->GetOwner() != nullptr) {
+3
View File
@@ -47,6 +47,8 @@ private:
void saveCurrentScene(); void saveCurrentScene();
void loadScene(); void loadScene();
void createEmptyScene(); void createEmptyScene();
void saveSelectedPrefab();
void instantiatePrefab();
void findPlayer(); void findPlayer();
void submitDebugDraws(); void submitDebugDraws();
void submitColliderDebugLines(); void submitColliderDebugLines();
@@ -62,6 +64,7 @@ private:
char scenePath[512]{"scenes/debug_scene.json"}; char scenePath[512]{"scenes/debug_scene.json"};
char newSceneName[128]{"EmptyScene"}; char newSceneName[128]{"EmptyScene"};
char prefabPath[512]{"prefabs/selected.prefab"};
std::string sceneStatus; std::string sceneStatus;
bool renderColliders{false}; bool renderColliders{false};
bool render2DShapes{true}; bool render2DShapes{true};
+53
View File
@@ -9,7 +9,9 @@
#include <destrum/Graphics/Managers/LineRenderingManager.h> #include <destrum/Graphics/Managers/LineRenderingManager.h>
#include <destrum/Graphics/Managers/Renderer2D.h> #include <destrum/Graphics/Managers/Renderer2D.h>
#include <destrum/Input/InputManager.h> #include <destrum/Input/InputManager.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/Serialization/PrefabSerializer.h>
#include <destrum/Serialization/SceneSerializer.h> #include <destrum/Serialization/SceneSerializer.h>
#include <components/FirstPersonController.h> #include <components/FirstPersonController.h>
@@ -120,6 +122,20 @@ void LightKeeper::drawDebugMenuBar()
saveCurrentScene(); saveCurrentScene();
} }
ImGui::Separator();
ImGui::TextDisabled("Prefab");
ImGui::SetNextItemWidth(320.0f);
ImGui::InputText("Prefab path", prefabPath, sizeof(prefabPath));
const bool hasSelection = SceneManager::GetInstance().GetCurrentScene().GetSelectedObject() != nullptr;
if (ImGui::MenuItem("Save Selected as Prefab", nullptr, false, hasSelection)) {
saveSelectedPrefab();
}
if (ImGui::MenuItem("Instantiate Prefab")) {
instantiatePrefab();
}
if (!sceneStatus.empty()) { if (!sceneStatus.empty()) {
ImGui::Separator(); ImGui::Separator();
ImGui::TextWrapped("%s", sceneStatus.c_str()); ImGui::TextWrapped("%s", sceneStatus.c_str());
@@ -270,6 +286,43 @@ void LightKeeper::saveCurrentScene()
} }
} }
void LightKeeper::saveSelectedPrefab()
{
try {
const GameObject* selected = SceneManager::GetInstance().GetCurrentScene().GetSelectedObject();
if (selected == nullptr) {
sceneStatus = "Select an object to save as a prefab";
return;
}
const auto path = ResolveScenePath(prefabPath, m_params.exeDir);
const auto parent = path.parent_path();
if (!parent.empty()) {
std::filesystem::create_directories(parent);
}
sceneStatus = PrefabSerializer::Save(*selected, path)
? "Saved prefab \"" + selected->GetName() + "\" to " + path.string()
: "Failed to save prefab to " + path.string();
} catch (const std::exception& exception) {
sceneStatus = std::string{"Prefab save failed: "} + exception.what();
}
}
void LightKeeper::instantiatePrefab()
{
try {
const auto path = ResolveScenePath(prefabPath, m_params.exeDir);
const GameObject* instance = PrefabSerializer::Instantiate(
SceneManager::GetInstance().GetCurrentScene(), path.string());
sceneStatus = instance != nullptr
? "Instantiated \"" + instance->GetName() + "\" from " + path.string()
: "Failed to instantiate prefab " + path.string();
} catch (const std::exception& exception) {
sceneStatus = std::string{"Prefab instantiate failed: "} + exception.what();
}
}
void LightKeeper::loadScene() void LightKeeper::loadScene()
{ {
try { try {
+36 -16
View File
@@ -31,6 +31,7 @@
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector>
namespace { namespace {
// The visible ground plane is at y = -1 // The visible ground plane is at y = -1
@@ -268,11 +269,12 @@ namespace {
float top; float top;
}; };
void CreateWall(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources, const std::string& name, std::vector<GameObject*> CreateWall(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources, const std::string& name,
const glm::vec3& floorCenter, float length, float height, bool alongX, MaterialID materialID, const glm::vec3& floorCenter, float length, float height, bool alongX, MaterialID materialID,
const std::optional<WallOpening>& opening = std::nullopt) const std::optional<WallOpening>& opening = std::nullopt)
{ {
constexpr float thickness = 0.3f; constexpr float thickness = 0.3f;
std::vector<GameObject*> pieces;
const auto createPiece = [&](const std::string& suffix, float from, float to, float bottom, float top) const auto createPiece = [&](const std::string& suffix, float from, float to, float bottom, float top)
{ {
@@ -285,13 +287,14 @@ namespace {
const glm::vec3 size = alongX const glm::vec3 size = alongX
? glm::vec3{to - from, top - bottom, thickness} ? glm::vec3{to - from, top - bottom, thickness}
: glm::vec3{thickness, top - bottom, to - from}; : glm::vec3{thickness, top - bottom, to - from};
CreateBox(scene, gfxDevice, resources, name + suffix, center, size, materialID, RigidbodyType::Static); pieces.push_back(
CreateBox(scene, gfxDevice, resources, name + suffix, center, size, materialID, RigidbodyType::Static));
}; };
const float halfLength = length * 0.5f; const float halfLength = length * 0.5f;
if (!opening) { if (!opening) {
createPiece("", -halfLength, halfLength, 0.0f, height); createPiece("", -halfLength, halfLength, 0.0f, height);
return; return pieces;
} }
const float openingStart = opening->offset - opening->width * 0.5f; const float openingStart = opening->offset - opening->width * 0.5f;
@@ -300,6 +303,7 @@ namespace {
createPiece("_Right", openingEnd, halfLength, 0.0f, height); createPiece("_Right", openingEnd, halfLength, 0.0f, height);
createPiece("_Below", openingStart, openingEnd, 0.0f, opening->bottom); createPiece("_Below", openingStart, openingEnd, 0.0f, opening->bottom);
createPiece("_Above", openingStart, openingEnd, opening->top, height); createPiece("_Above", openingStart, openingEnd, opening->top, height);
return pieces;
} }
GameObject* CreateCeilingLight(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources, GameObject* CreateCeilingLight(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources,
@@ -336,26 +340,40 @@ namespace {
constexpr float doorOffset = -1.5f; constexpr float doorOffset = -1.5f;
constexpr float doorWidth = 1.2f; constexpr float doorWidth = 1.2f;
CreateBox(scene, gfxDevice, resources, "House_Floor", center + glm::vec3{0.0f, 0.03f, 0.0f}, GameObject* house = scene.CreateGameObject("House");
{width, 0.06f, depth}, floorMaterial, RigidbodyType::Static); house->GetTransform().SetWorldPosition(center);
const auto addToHouse = [&](GameObject* object)
{
object->GetTransform().SetParent(&house->GetTransform(), true);
};
const auto addWallToHouse = [&](const std::vector<GameObject*>& pieces)
{
for (GameObject* piece : pieces) {
addToHouse(piece);
}
};
CreateWall(scene, gfxDevice, resources, "House_FrontWall", center + glm::vec3{0.0f, 0.0f, depth * 0.5f}, addToHouse(CreateBox(scene, gfxDevice, resources, "House_Floor", center + glm::vec3{0.0f, 0.03f, 0.0f},
{width, 0.06f, depth}, floorMaterial, RigidbodyType::Static));
addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_FrontWall", center + glm::vec3{0.0f, 0.0f, depth * 0.5f},
width + 0.3f, wallHeight, true, wallMaterial, width + 0.3f, wallHeight, true, wallMaterial,
WallOpening{.offset = doorOffset, .width = doorWidth, .bottom = 0.0f, .top = 2.2f}); WallOpening{.offset = doorOffset, .width = doorWidth, .bottom = 0.0f, .top = 2.2f}));
CreateWall(scene, gfxDevice, resources, "House_BackWall", center - glm::vec3{0.0f, 0.0f, depth * 0.5f}, addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_BackWall", center - glm::vec3{0.0f, 0.0f, depth * 0.5f},
width + 0.3f, wallHeight, true, wallMaterial, width + 0.3f, wallHeight, true, wallMaterial,
WallOpening{.offset = 1.5f, .width = 1.4f, .bottom = 1.0f, .top = 2.0f}); WallOpening{.offset = 1.5f, .width = 1.4f, .bottom = 1.0f, .top = 2.0f}));
CreateWall(scene, gfxDevice, resources, "House_LeftWall", center - glm::vec3{width * 0.5f, 0.0f, 0.0f}, addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_LeftWall", center - glm::vec3{width * 0.5f, 0.0f, 0.0f},
depth - 0.3f, wallHeight, false, wallMaterial); depth - 0.3f, wallHeight, false, wallMaterial));
CreateWall(scene, gfxDevice, resources, "House_RightWall", center + glm::vec3{width * 0.5f, 0.0f, 0.0f}, addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_RightWall", center + glm::vec3{width * 0.5f, 0.0f, 0.0f},
depth - 0.3f, wallHeight, false, wallMaterial, depth - 0.3f, wallHeight, false, wallMaterial,
WallOpening{.offset = 0.0f, .width = 1.6f, .bottom = 1.0f, .top = 2.0f}); WallOpening{.offset = 0.0f, .width = 1.6f, .bottom = 1.0f, .top = 2.0f}));
CreateBox(scene, gfxDevice, resources, "House_Roof", center + glm::vec3{0.0f, wallHeight + 0.15f, 0.0f}, addToHouse(CreateBox(scene, gfxDevice, resources, "House_Roof", center + glm::vec3{0.0f, wallHeight + 0.15f, 0.0f},
{width + 0.8f, 0.3f, depth + 0.8f}, roofMaterial, RigidbodyType::Static); {width + 0.8f, 0.3f, depth + 0.8f}, roofMaterial, RigidbodyType::Static));
const GameObject* ceilingLight = GameObject* ceilingLight =
CreateCeilingLight(scene, gfxDevice, resources, center + glm::vec3{0.0f, wallHeight - 0.06f, 0.0f}); CreateCeilingLight(scene, gfxDevice, resources, center + glm::vec3{0.0f, wallHeight - 0.06f, 0.0f});
addToHouse(ceilingLight);
// Inside, next to the door // Inside, next to the door
Material switchMaterial = Material::SimpleColor(glm::vec3{0.9f}, "LightSwitchMaterial"); Material switchMaterial = Material::SimpleColor(glm::vec3{0.9f}, "LightSwitchMaterial");
@@ -369,6 +387,7 @@ namespace {
auto* switchButton = lightSwitch->AddComponent<InteractableButton>(); auto* switchButton = lightSwitch->AddComponent<InteractableButton>();
switchButton->SetCycleColors(false); switchButton->SetCycleColors(false);
switchButton->AddTarget(*ceilingLight); switchButton->AddTarget(*ceilingLight);
addToHouse(lightSwitch);
GameObject* sofa = ModelSpawner::SpawnStaticModel( GameObject* sofa = ModelSpawner::SpawnStaticModel(
scene, gfxDevice, resources, "game://sofa.glb", StaticModelOptions(), scene, gfxDevice, resources, "game://sofa.glb", StaticModelOptions(),
@@ -377,6 +396,7 @@ namespace {
sofa->GetTransform().SetWorldPosition({-3.7, -1, -17.5}); sofa->GetTransform().SetWorldPosition({-3.7, -1, -17.5});
sofa->GetTransform().SetWorldRotation({180, 0, 180}); sofa->GetTransform().SetWorldRotation({180, 0, 180});
sofa->GetTransform().SetWorldScale({3, 3, 3}); sofa->GetTransform().SetWorldScale({3, 3, 3});
addToHouse(sofa);
} }
void CreateFlightHelmet(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources) void CreateFlightHelmet(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources)
+133
View File
@@ -25,6 +25,8 @@
#include <destrum/Serialization/ComponentFactory.h> #include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/ComponentRegistry.h> #include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/GlmJson.h> #include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/PrefabCache.h>
#include <destrum/Serialization/PrefabSerializer.h>
#include <destrum/Serialization/SceneSerializer.h> #include <destrum/Serialization/SceneSerializer.h>
#include <destrum/Physics/SimplePhysicsWorld.h> #include <destrum/Physics/SimplePhysicsWorld.h>
#include <destrum/Physics/JoltPhysicsWorld.h> #include <destrum/Physics/JoltPhysicsWorld.h>
@@ -1615,6 +1617,134 @@ namespace {
SceneManager::GetInstance().Destroy(); SceneManager::GetInstance().Destroy();
} }
GameObject* buildTurret(Scene& scene, const GameObject& door)
{
GameObject* turret = scene.CreateGameObject("turret");
turret->GetTransform().SetLocalPosition(glm::vec3{5.0f, 0.0f, 0.0f});
GameObject* barrel = scene.CreateGameObject("barrel");
barrel->GetTransform().SetParent(&turret->GetTransform(), false);
barrel->GetTransform().SetLocalPosition(glm::vec3{0.0f, 1.0f, 0.0f});
barrel->AddComponent<TestActivatable>();
auto* button = turret->AddComponent<InteractableButton>();
button->SetCycleColors(false);
button->AddTarget(*barrel);
button->AddTarget(door);
return turret;
}
GameObject* onlyChild(GameObject& object)
{
const auto& children = object.GetTransform().GetChildren();
return children.size() == 1 ? children.front()->GetOwner() : nullptr;
}
void testPrefabRoundTrip()
{
ComponentFactory::Register<InteractableButton>("InteractableButton");
ComponentFactory::Register<TestActivatable>("TestActivatable");
Scene& source = SceneManager::GetInstance().CreateScene("prefab source");
GameObject* door = source.CreateGameObject("door");
door->AddComponent<TestActivatable>();
GameObject* turret = buildTurret(source, *door);
source.CommitPendingAdditions();
const auto path = std::filesystem::temp_directory_path() / "destrum_turret.prefab";
check(PrefabSerializer::Save(*turret, path), "a hierarchy must save as a prefab");
Scene& target = SceneManager::GetInstance().CreateScene("prefab target");
GameObject* holder = target.CreateGameObject("holder");
GameObject* first = PrefabSerializer::Instantiate(target, path.string());
GameObject* second = PrefabSerializer::Instantiate(target, path.string(), &holder->GetTransform());
target.CommitPendingAdditions();
check(first != nullptr && second != nullptr, "a saved prefab must instantiate");
check(target.GetObjects().size() == 5, "each instance must create the root and its child");
check(first != second && first->GetId() != turret->GetId(), "instances must get fresh objects");
check(first->GetName() == "turret" && first->GetTransform().GetParent() == nullptr,
"an instance without a parent must be a scene root");
check(second->GetTransform().GetParent() == &holder->GetTransform(),
"an instance must be parented to the given transform");
check(close(first->GetTransform().GetLocalPosition().x, 5.0f),
"the root transform must come from the prefab");
for (GameObject* instance : {first, second}) {
GameObject* barrel = onlyChild(*instance);
check(barrel != nullptr && barrel->GetName() == "barrel" &&
close(barrel->GetTransform().GetLocalPosition().y, 1.0f),
"prefab children must keep their local transform");
const auto* button = instance->GetComponent<InteractableButton>();
check(button != nullptr && button->GetTargets().size() == 1 &&
button->GetTargets().front().Get(target) == barrel,
"references inside the prefab must point at the instance's own objects, outside ones are dropped");
}
check(PrefabCache::GetInstance().Get(path.string()) == PrefabCache::GetInstance().Get(path.string()),
"a cached prefab must be parsed once");
const auto before = PrefabCache::GetInstance().Get(path.string());
turret->SetName("tower");
check(PrefabSerializer::Save(*turret, path), "a prefab must save over an existing file");
const auto after = PrefabCache::GetInstance().Get(path.string());
check(after != before && after->at("name") == "tower", "saving a prefab must refresh the cache");
std::filesystem::remove(path);
check(PrefabSerializer::Instantiate(target, path.string()) == nullptr,
"instantiating a missing prefab must fail");
PrefabCache::GetInstance().Clear();
SceneManager::GetInstance().Destroy();
}
void testPrefabDuplicate()
{
ComponentFactory::Register<InteractableButton>("InteractableButton");
ComponentFactory::Register<TestActivatable>("TestActivatable");
Scene& scene = SceneManager::GetInstance().CreateScene("duplicate");
GameObject* door = scene.CreateGameObject("door");
door->AddComponent<TestActivatable>();
GameObject* holder = scene.CreateGameObject("holder");
GameObject* turret = buildTurret(scene, *door);
turret->GetTransform().SetParent(&holder->GetTransform(), false);
GameObject* copy = PrefabSerializer::Duplicate(*turret);
scene.CommitPendingAdditions();
check(copy != nullptr && copy != turret && copy->GetName() == "turret",
"duplicate must create a new object");
check(copy->GetTransform().GetParent() == &holder->GetTransform(),
"a duplicate must keep the original's parent");
GameObject* barrel = onlyChild(*copy);
check(barrel != nullptr && barrel != onlyChild(*turret), "a duplicate must copy children");
const auto* button = copy->GetComponent<InteractableButton>();
check(button != nullptr && button->GetTargets().size() == 2 &&
button->GetTargets()[0].Get(scene) == barrel &&
button->GetTargets()[1].Get(scene) == door,
"a duplicate must remap inner references and keep outer ones");
SceneManager::GetInstance().Destroy();
}
void testPrefabValidation()
{
using nlohmann::json;
check(PrefabSerializer::Validate(json::object()).has_value(), "an empty prefab must be rejected");
const json twoRoots = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2}]})");
check(PrefabSerializer::Validate(twoRoots).has_value(), "a prefab with two roots must be rejected");
const json childFirst = json::parse(R"({"version":1,"objects":[{"id":2,"parent":1},{"id":1}]})");
check(PrefabSerializer::Validate(childFirst).has_value(), "the prefab root must come first");
const json valid = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2,"parent":1}]})");
check(!PrefabSerializer::Validate(valid).has_value(), "a single-root prefab must validate");
const json future = json::parse(R"({"version":99,"objects":[{"id":1}]})");
check(PrefabSerializer::Validate(future).has_value(), "an unknown prefab version must be rejected");
}
} }
int main() int main()
@@ -1663,6 +1793,9 @@ int main()
testSceneFileIdsAreLocal(); testSceneFileIdsAreLocal();
testButtonTargetsRoundTrip(); testButtonTargetsRoundTrip();
testButtonDropsMissingTargets(); testButtonDropsMissingTargets();
testPrefabRoundTrip();
testPrefabDuplicate();
testPrefabValidation();
std::cout << "destrum tests passed\n"; std::cout << "destrum tests passed\n";
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }