#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using json = nlohmann::json; namespace { using ValidationError = std::optional; constexpr int CurrentSceneVersion = 2; [[nodiscard]] bool IsFiniteNumber(const json& value) { if (!value.is_number()) { return false; } return std::isfinite(value.get()); } [[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 FindNonFinite(const json& value, const std::string& path) { if (value.is_number_float()) { if (!std::isfinite(value.get())) { 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() + "\""; } 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(); 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) { if (!root.is_object()) { return "root must be an object"; } if (!root.contains("objects") || !root.at("objects").is_array()) { return "root must contain an \"objects\" array"; } if (root.contains("name") && !root.at("name").is_string()) { return "\"name\" must be a string"; } int version = 1; if (root.contains("version")) { if (!root.at("version").is_number_integer()) { return "\"version\" must be an integer"; } version = root.at("version").get(); if (version < 1 || version > CurrentSceneVersion) { return "unsupported scene version " + std::to_string(version); } } if (root.contains("currentCamera") && !root.at("currentCamera").is_number_unsigned()) { return "\"currentCamera\" must be an object id"; } std::unordered_set ids; std::unordered_map parents; std::unordered_map 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(); if (id == InvalidObjectId || id >= std::numeric_limits::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(); } 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 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(); 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 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(); 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; } } bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { if (scene.IsIterating()) { spdlog::error("Cannot save a scene during an update or render phase: {}", scene.GetName()); return false; } RegisterEngineComponents(); try { scene.CommitPendingAdditions(); } catch (const std::exception& exception) { spdlog::error("Failed to commit scene additions before save: {}", exception.what()); return false; } json root; root["version"] = CurrentSceneVersion; root["name"] = scene.GetName(); root["objects"] = json::array(); if (scene.m_CurrentCameraId != InvalidObjectId) { root["currentCamera"] = scene.m_CurrentCameraId; } const SceneEnvironment& environment = scene.GetEnvironment(); root["environment"] = { {"ambientColor", environment.ambientColor}, {"ambientIntensity", environment.ambientIntensity}, {"fogColor", environment.fogColor}, {"fogDensity", environment.fogDensity}, {"skyboxRotationSpeed", environment.skyboxRotationSpeed}, {"skyboxRotation", environment.skyboxRotation}, }; for (const auto& objectPtr : scene.GetObjects()) { if (!objectPtr || objectPtr->IsBeingDestroyed()) { continue; } const GameObject& object = *objectPtr; if (object.GetComponent() != nullptr && object.GetComponent() == 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")) { spdlog::error("Cannot save scene \"{}\": {} is NaN or Inf", scene.GetName(), *bad); return false; } // Refuse to write anything Load would reject if (auto error = ValidateSceneJson(root)) { spdlog::error("Cannot save scene \"{}\", it would not load back: {}", scene.GetName(), *error); return false; } std::string contents; try { contents = root.dump(4); } catch (const std::exception& exception) { spdlog::error("Failed to encode scene \"{}\": {}", scene.GetName(), exception.what()); return false; } return WriteFileAtomically(path, contents); } SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem::path& path) { LoadResult result; RegisterEngineComponents(); std::ifstream file(path); if (!file.is_open()) { result.errorMessage = "Failed to open scene file for reading: " + path.string(); spdlog::error("{}", result.errorMessage); return result; } try { file >> result.root; } catch (const std::exception& exception) { result.errorMessage = "Failed to parse scene file " + path.string() + ": " + exception.what(); spdlog::error("{}", result.errorMessage); return result; } if (auto error = ValidateSceneJson(result.root)) { result.errorMessage = "Invalid scene data in " + path.string() + ": " + *error; spdlog::error("{}", result.errorMessage); return result; } ValidateAssetReferences(result.root, path); result.success = true; return result; } bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, std::function progressCallback) { if (scene.IsIterating()) { spdlog::error("Cannot load a scene during an update or render phase: {}", scene.GetName()); return false; } if (!result.success) { spdlog::error("Cannot construct scene from a failed load result: {}", result.errorMessage); return false; } if (scene.IsBeingUnloaded()) { spdlog::error("Cannot load a scene that is being unloaded: {}", scene.GetName()); return false; } RegisterEngineComponents(); if (progressCallback) progressCallback(0.35f); const json& root = result.root; // Build the replacement separately. The current scene is not touched // until all objects, transforms, and components have loaded successfully. // 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 idMap; std::vector objectJsonList; std::vector registeredObjects; std::string context = "scene"; try { for (const auto& objectJson : root.at("objects")) { const ObjectId id = objectJson.at("id").get(); 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 // (staging scene has no physics world). for (auto& pending : stagingScene.m_pendingAdditions) { if (pending) { stagingScene.m_objects.emplace_back(std::move(pending)); } } stagingScene.m_pendingAdditions.clear(); for (const json* objectJson : objectJsonList) { const ObjectId id = objectJson->at("id").get(); 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()); if (rotation.size() == 4) { object->GetTransform().SetLocalRotation(rotation.get()); } else { // Version 1 scene files used three Euler angles in // degrees. Continue reading that format safely. object->GetTransform().SetLocalRotation(rotation.get()); } object->GetTransform().SetLocalScale(transformJson.at("scale").get()); } 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()); if (!objectJson->contains("components")) { continue; } for (const auto& componentJson : objectJson->at("components")) { const std::string type = componentJson.at("type").get(); 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 // touching the current scene. If any body fails, the old scene and // its physics state can remain intact. for (const auto& objectPtr : stagingScene.GetObjects()) { if (objectPtr) { context = "\"" + objectPtr->GetName() + "\" physics"; if (objectPtr->GetComponent() != nullptr && objectPtr->GetComponent() == nullptr) { throw std::runtime_error("Rigidbody requires a collider"); } scene.GetPhysics().RegisterGameObject(*objectPtr); registeredObjects.push_back(objectPtr.get()); } } if (progressCallback) progressCallback(0.8f); } catch (const std::exception& exception) { spdlog::error("Failed to load scene ({}): {}", context, exception.what()); for (GameObject* object : registeredObjects) { if (object != nullptr) { scene.GetPhysics().UnregisterGameObject(*object); } } stagingScene.RemoveAll(); return false; } const bool bindingsWereLoaded = scene.m_bindingsLoaded; scene.UnloadBindings(); scene.RemoveAll(); scene.m_objects = std::move(stagingScene.m_objects); scene.m_pendingAdditions = std::move(stagingScene.m_pendingAdditions); if (root.contains("name")) { scene.m_name = root.at("name").get(); } scene.m_SelectedObjectId = InvalidObjectId; scene.m_CurrentCameraId = InvalidObjectId; if (const ObjectId cameraFileId = root.value("currentCamera", InvalidObjectId); cameraFileId != InvalidObjectId) { if (const auto it = idMap.find(cameraFileId); it != idMap.end()) { scene.m_CurrentCameraId = it->second->GetId(); } else { spdlog::warn("Scene \"{}\" current camera id {} does not exist, ignoring it", scene.m_name, cameraFileId); } } scene.m_Environment = SceneEnvironment{}; if (root.contains("environment") && root.at("environment").is_object()) { const auto& data = root.at("environment"); SceneEnvironment& environment = scene.m_Environment; try { environment.ambientColor = data.value("ambientColor", environment.ambientColor); environment.ambientIntensity = data.value("ambientIntensity", environment.ambientIntensity); environment.fogColor = data.value("fogColor", environment.fogColor); environment.fogDensity = data.value("fogDensity", environment.fogDensity); environment.skyboxRotationSpeed = data.value("skyboxRotationSpeed", environment.skyboxRotationSpeed); environment.skyboxRotation = data.value("skyboxRotation", environment.skyboxRotation); } catch (const std::exception& exception) { spdlog::warn("Scene \"{}\" has invalid environment settings, using defaults: {}", scene.m_name, exception.what()); environment = SceneEnvironment{}; } } if (progressCallback) progressCallback(0.9f); for (const auto& object : scene.m_objects) { if (object) { object->SetScene(&scene); } } for (const auto& object : scene.m_pendingAdditions) { if (object) { object->SetScene(&scene); } } if (bindingsWereLoaded) { try { scene.LoadBindings(); } catch (const std::exception& exception) { spdlog::error("Scene \"{}\" loaded but its bindings failed: {}", scene.m_name, exception.what()); } } if (progressCallback) progressCallback(1.0f); return true; } bool SceneSerializer::Load(Scene& scene, const std::filesystem::path& path, std::function progressCallback) { if (progressCallback) progressCallback(0.0f); auto result = LoadSceneFile(path); if (!result.success) { if (progressCallback) progressCallback(1.0f); return false; } if (progressCallback) progressCallback(0.3f); return ConstructScene(scene, result, std::move(progressCallback)); } std::future SceneSerializer::LoadSceneFileAsync( const std::filesystem::path& path) { return std::async(std::launch::async, [path]() { return LoadSceneFile(path); }); }