diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ad43e98..9574fb04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -164,6 +164,7 @@ set(SOURCES "audio/audiorenderer.cpp" "utilities/motiontelemetry.cpp" "utilities/utilities.cpp" +"utilities/AsyncFilePreloader.cpp" "application/uitranscripts.cpp" "world/station.cpp" "application/application.cpp" @@ -431,7 +432,9 @@ if (${CMAKE_CXX_COMPILER_ID} STREQUAL MSVC) entitysystem/systems/VehicleSyncSystem.cpp entitysystem/systems/VehicleSyncSystem.h entitysystem/systems/BillboardSystem.cpp - entitysystem/systems/BillboardSystem.h) + entitysystem/systems/BillboardSystem.h + entitysystem/ECSPersistence.cpp + entitysystem/ECSPersistence.h) add_compile_options("/utf-8") endif() diff --git a/application/driveruilayer.cpp b/application/driveruilayer.cpp index 9645e7a4..1547ab85 100644 --- a/application/driveruilayer.cpp +++ b/application/driveruilayer.cpp @@ -22,6 +22,7 @@ http://mozilla.org/MPL/2.0/. #include "entitysystem/ECScene.h" #include "entitysystem/components/BasicComponents.h" #include "entitysystem/components/RenderComponents.h" +#include "entitysystem/ECSPersistence.h" driver_ui::driver_ui() { @@ -366,6 +367,100 @@ void driver_ui::register_driver_commands() dev::Console.print_ok("ParticleEmitter added to: " + args[1]); }); + con.register_command("ecs.addspotlight", + "Spawn SpotLight at position: ecs.addspotlight [r g b] [intensity] [range]", + [](const dev::args_t &args) { + if (args.size() < 5) { dev::Console.print_error("Usage: ecs.addspotlight [r g b] [intensity] [range]"); return; } + ECScene *scene = Application.sceneManager.CurrentScene(); + if (!scene) { dev::Console.print_warn("No active scene"); return; } + ECWorld &world = scene->World(); + + auto entity = world.CreateEntity(); + + auto& id = world.AddComponent(entity); + id.Name = args[1]; + + auto& t = world.AddComponent(entity); + t.Position = glm::dvec3(std::stod(args[2]), std::stod(args[3]), std::stod(args[4])); + + auto& spot = world.AddComponent(entity); + if (args.size() > 7) { + spot.Color = glm::vec3(std::stof(args[5]), std::stof(args[6]), std::stof(args[7])); + } + if (args.size() > 8) spot.Intensity = std::stof(args[8]); + if (args.size() > 9) spot.Range = std::stof(args[9]); + spot.Enabled = true; + + dev::Console.print_ok("SpotLight '" + args[1] + "' spawned at (" + + args[2] + ", " + args[3] + ", " + args[4] + ")"); + }); + + con.register_command("ecs.clone", + "Clone entity with optional offset: ecs.clone [dx dy dz]", + [](const dev::args_t &args) { + if (args.size() < 3) { dev::Console.print_error("Usage: ecs.clone [dx dy dz]"); return; } + ECScene *scene = Application.sceneManager.CurrentScene(); + if (!scene) { dev::Console.print_warn("No active scene"); return; } + ECWorld &world = scene->World(); + + entt::entity src = world.FindEntityByName(args[1]); + if (src == entt::null) { dev::Console.print_error("Entity not found: " + args[1]); return; } + if (world.FindEntityByName(args[2]) != entt::null) { dev::Console.print_error("Name already exists: " + args[2]); return; } + + glm::dvec3 offset{0.0}; + if (args.size() >= 6) { + offset = glm::dvec3(std::stod(args[3]), std::stod(args[4]), std::stod(args[5])); + } + + auto dst = world.CreateEntity(); + auto &id = world.AddComponent(dst); + id.Name = args[2]; + + if (auto *t = world.GetComponent(src)) { + auto &nt = world.AddComponent(dst); + nt = *t; + nt.Position += offset; + } + if (auto *s = world.GetComponent(src)) + world.AddComponent(dst) = *s; + if (auto *p = world.GetComponent(src)) + world.AddComponent(dst) = *p; + if (auto *b = world.GetComponent(src)) + world.AddComponent(dst) = *b; + if (auto *l = world.GetComponent(src)) + world.AddComponent(dst) = *l; + if (auto *lod = world.GetComponent(src)) + world.AddComponent(dst) = *lod; + + dev::Console.print_ok("Cloned '" + args[1] + "' → '" + args[2] + "'"); + }); + + con.register_command("ecs.save", + "Save ECS entities to JSON: ecs.save [filename=ecs_save.json]", + [](const dev::args_t &args) { + ECScene *scene = Application.sceneManager.CurrentScene(); + if (!scene) { dev::Console.print_warn("No active scene"); return; } + std::string filename = (args.size() > 1) ? args[1] : "ecs_save.json"; + int n = ecs_persistence::save(scene->World(), filename); + if (n >= 0) + dev::Console.print_ok("Saved " + std::to_string(n) + " entities to " + filename); + else + dev::Console.print_error("Cannot write: " + filename); + }); + + con.register_command("ecs.load", + "Load ECS entities from JSON: ecs.load [filename=ecs_save.json]", + [](const dev::args_t &args) { + ECScene *scene = Application.sceneManager.CurrentScene(); + if (!scene) { dev::Console.print_warn("No active scene"); return; } + std::string filename = (args.size() > 1) ? args[1] : "ecs_save.json"; + int n = ecs_persistence::load(scene->World(), filename); + if (n >= 0) + dev::Console.print_ok("Loaded " + std::to_string(n) + " entities from " + filename); + else + dev::Console.print_error("Cannot read or parse: " + filename); + }); + con.print_info("Driver console ready. Type 'help' for commands."); } diff --git a/application/uilayer.cpp b/application/uilayer.cpp index e06fba84..88e4482b 100644 --- a/application/uilayer.cpp +++ b/application/uilayer.cpp @@ -579,18 +579,22 @@ void ui_layer::render_entity_hierarchy(ECWorld& world) { if (ImGui::CollapsingHeader("Transform", ImGuiTreeNodeFlags_DefaultOpen)) { - - float pos[3] = { static_cast(transform->Position.x), static_cast(transform->Position.y), static_cast(transform->Position.z) }; + float pos[3] = { + static_cast(transform->Position.x), + static_cast(transform->Position.y), + static_cast(transform->Position.z) }; + if (ImGui::DragFloat3("Position", pos, 0.1f)) { + transform->Position.x = pos[0]; + transform->Position.y = pos[1]; + transform->Position.z = pos[2]; + } - if(ImGui::DragFloat3("Position", pos, 0.1f)){ + glm::vec3 euler = glm::degrees(glm::eulerAngles(transform->Rotation)); + if (ImGui::DragFloat3("Rotation (deg)", &euler.x, 0.5f)) { + transform->Rotation = glm::quat(glm::radians(euler)); + } - } - - ImGui::DragFloat3("Rotation", - &transform->Rotation.x, 0.1, -360.0, 360.0); - - ImGui::DragFloat3("Scale", - &transform->Scale.x, 0.01, 0.0, 100000.0); + ImGui::DragFloat3("Scale", &transform->Scale.x, 0.01f, 0.0f, 100000.0f); } } @@ -599,13 +603,9 @@ void ui_layer::render_entity_hierarchy(ECWorld& world) { if (ImGui::CollapsingHeader("Velocity", ImGuiTreeNodeFlags_DefaultOpen)) { - ImGui::DragFloat3("Value", - &velocity->Value.x, 0.1); - - if (ImGui::Button("Stop")) - { - velocity->Value = glm::dvec3(0.0); - } + ImGui::DragFloat3("Value", &velocity->Value.x, 0.1f); + if (ImGui::Button("Stop")) + velocity->Value = glm::vec3(0.0f); } } @@ -614,10 +614,12 @@ void ui_layer::render_entity_hierarchy(ECWorld& world) { if (ImGui::CollapsingHeader("LODController", ImGuiTreeNodeFlags_DefaultOpen)) { - float range_min = lod->RangeMin; - float range_max = lod->RangeMax; - ImGui::DragFloat("Range Min", &range_min, 0.1, 0.0, 100000.0); - ImGui::DragFloat("Range Max", &range_max, 0.1, 0.0, 100000.0); + float range_min = static_cast(lod->RangeMin); + float range_max = static_cast(lod->RangeMax); + if (ImGui::DragFloat("Range Min", &range_min, 0.1f, 0.0f, 100000.0f)) + lod->RangeMin = range_min; + if (ImGui::DragFloat("Range Max", &range_max, 0.1f, 0.0f, 100000.0f)) + lod->RangeMax = range_max; } } @@ -646,7 +648,6 @@ void ui_layer::render_entity_hierarchy(ECWorld& world) } // SpotLight - if (auto* light = world.GetComponent(m_selected_entity)) { if (ImGui::CollapsingHeader("SpotLight", ImGuiTreeNodeFlags_DefaultOpen)) @@ -661,6 +662,58 @@ void ui_layer::render_entity_hierarchy(ECWorld& world) } } + // SunLight + if (auto* sun = world.GetComponent(m_selected_entity)) + { + if (ImGui::CollapsingHeader("SunLight", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Checkbox("Enabled", &sun->Enabled); + ImGui::ColorEdit3("Color", &sun->Color.x); + ImGui::DragFloat("Intensity", &sun->Intensity, 0.01f, 0.0f, 10.0f); + } + } + + // ParticleEmitter + if (auto* em = world.GetComponent(m_selected_entity)) + { + if (ImGui::CollapsingHeader("ParticleEmitter", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Checkbox("Active", &em->isActive); + ImGui::DragFloat("Spawn Rate", &em->spawnRate, 0.5f, 0.0f, 1000.0f); + ImGui::DragFloat("Lifetime", &em->particleLifetime, 0.1f, 0.1f, 60.0f); + ImGui::DragFloat("Air Resistance", &em->airResistance, 0.01f, 0.0f, 5.0f); + ImGui::DragFloat3("Gravity", &em->gravity.x, 0.01f); + ImGui::DragFloat4("Color Fade", &em->colorFade.x, 0.01f, -1.0f, 1.0f); + ImGui::Text("Particles alive: %zu", em->particles.size()); + if (ImGui::Button("Clear particles")) + em->particles.clear(); + } + } + + // Billboard + if (auto* bill = world.GetComponent(m_selected_entity)) + { + if (ImGui::CollapsingHeader("Billboard", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Checkbox("Active", &bill->active); + ImGui::DragFloat("Size", &bill->size, 0.05f, 0.01f, 100.0f); + ImGui::Text("Texture: %s", bill->texturePath.ToString().c_str()); + } + } + + // Line + if (auto* line = world.GetComponent(m_selected_entity)) + { + if (ImGui::CollapsingHeader("Line", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Checkbox("Active", &line->active); + ImGui::DragFloat3("Start", &line->start.x, 0.1f); + ImGui::DragFloat3("End", &line->end.x, 0.1f); + ImGui::ColorEdit3("Color", &line->color.x); + ImGui::DragFloat("Thickness", &line->thickness, 0.1f, 0.1f, 20.0f); + } + } + ImGui::EndChild(); ImGui::End(); } diff --git a/entitysystem/ECSPersistence.cpp b/entitysystem/ECSPersistence.cpp new file mode 100644 index 00000000..80a8adc9 --- /dev/null +++ b/entitysystem/ECSPersistence.cpp @@ -0,0 +1,172 @@ +#include "stdafx.h" +#include "ECSPersistence.h" + +#include "entitysystem/ECWorld.h" +#include "entitysystem/components/BasicComponents.h" +#include "entitysystem/components/RenderComponents.h" +#include "utilities/Logs.h" +#include "nlohmann/json.hpp" +#include + +namespace ecs_persistence +{ + +int save(ECWorld &world, const std::string &path) +{ + nlohmann::json root = nlohmann::json::array(); + + for (auto entity : world.GetEntities()) { + auto *idComp = world.GetComponent(entity); + if (!idComp) continue; + + nlohmann::json ent; + ent["name"] = idComp->Name.ToString(); + + if (auto *t = world.GetComponent(entity)) { + ent["Transform"] = { + {"px", t->Position.x}, {"py", t->Position.y}, {"pz", t->Position.z}, + {"rx", t->Rotation.x}, {"ry", t->Rotation.y}, {"rz", t->Rotation.z}, {"rw", t->Rotation.w}, + {"sx", t->Scale.x}, {"sy", t->Scale.y}, {"sz", t->Scale.z} + }; + } + if (auto *s = world.GetComponent(entity)) { + ent["SpotLight"] = { + {"r", s->Color.x}, {"g", s->Color.y}, {"b", s->Color.z}, + {"intensity", s->Intensity}, {"range", s->Range}, + {"innerAngle", s->InnerAngle}, {"outerAngle", s->OuterAngle}, + {"enabled", s->Enabled}, {"castShadows", s->CastShadows} + }; + } + if (auto *p = world.GetComponent(entity)) { + ent["ParticleEmitter"] = { + {"active", p->isActive}, {"spawnRate", p->spawnRate}, + {"lifetime", p->particleLifetime}, {"airResistance", p->airResistance}, + {"gx", p->gravity.x}, {"gy", p->gravity.y}, {"gz", p->gravity.z}, + {"fadex", p->colorFade.x}, {"fadey", p->colorFade.y}, + {"fadez", p->colorFade.z}, {"fadew", p->colorFade.w} + }; + } + if (auto *b = world.GetComponent(entity)) { + ent["Billboard"] = { + {"texture", b->texturePath.ToString()}, + {"size", b->size}, {"active", b->active} + }; + } + if (auto *l = world.GetComponent(entity)) { + ent["Line"] = { + {"sx", l->start.x}, {"sy", l->start.y}, {"sz", l->start.z}, + {"ex", l->end.x}, {"ey", l->end.y}, {"ez", l->end.z}, + {"r", l->color.x}, {"g", l->color.y}, {"b", l->color.z}, + {"thickness", l->thickness}, {"active", l->active} + }; + } + if (auto *lod = world.GetComponent(entity)) { + ent["LODController"] = {{"min", lod->RangeMin}, {"max", lod->RangeMax}}; + } + if (world.HasComponent(entity)) + ent["disabled"] = true; + + // skip entities that are purely runtime-managed (VehicleRef, MeshRenderer linked to legacy) + bool runtimeOnly = + world.HasComponent(entity) || + (world.HasComponent(entity) && + world.GetComponent(entity)->modelInstance != nullptr); + if (runtimeOnly) continue; + + root.push_back(ent); + } + + std::ofstream f(path); + if (!f) { + WriteLog("[ECS] ECSPersistence: cannot write to " + path); + return -1; + } + f << root.dump(2); + WriteLog("[ECS] ECSPersistence: saved " + std::to_string(root.size()) + " entities to " + path); + return static_cast(root.size()); +} + +int load(ECWorld &world, const std::string &path) +{ + std::ifstream f(path); + if (!f) return -1; // file doesn't exist — silently skip + + nlohmann::json root; + try { root = nlohmann::json::parse(f); } + catch (const std::exception &e) { + WriteLog("[ECS] ECSPersistence: JSON parse error in " + path + ": " + e.what()); + return -1; + } + + int created = 0; + for (auto const &ent : root) { + if (!ent.contains("name")) continue; + std::string name = ent["name"].get(); + if (world.FindEntityByName(name) != entt::null) continue; + + auto entity = world.CreateEntity(); + auto &id = world.AddComponent(entity); + id.Name = name; + + if (ent.contains("Transform")) { + auto &j = ent["Transform"]; + auto &t = world.AddComponent(entity); + t.Position = glm::dvec3(double(j["px"]), double(j["py"]), double(j["pz"])); + t.Rotation = glm::quat(float(j["rw"]), float(j["rx"]), float(j["ry"]), float(j["rz"])); + t.Scale = glm::vec3(float(j["sx"]), float(j["sy"]), float(j["sz"])); + } + if (ent.contains("SpotLight")) { + auto &j = ent["SpotLight"]; + auto &s = world.AddComponent(entity); + s.Color = glm::vec3(float(j["r"]), float(j["g"]), float(j["b"])); + s.Intensity = j["intensity"]; + s.Range = j["range"]; + s.InnerAngle = j["innerAngle"]; + s.OuterAngle = j["outerAngle"]; + s.Enabled = j["enabled"]; + s.CastShadows = j["castShadows"]; + } + if (ent.contains("ParticleEmitter")) { + auto &j = ent["ParticleEmitter"]; + auto &p = world.AddComponent(entity); + p.isActive = j["active"]; + p.spawnRate = j["spawnRate"]; + p.particleLifetime = j["lifetime"]; + p.airResistance = j["airResistance"]; + p.gravity = glm::vec3(float(j["gx"]), float(j["gy"]), float(j["gz"])); + p.colorFade = glm::vec4(float(j["fadex"]), float(j["fadey"]), + float(j["fadez"]), float(j["fadew"])); + } + if (ent.contains("Billboard")) { + auto &j = ent["Billboard"]; + auto &b = world.AddComponent(entity); + b.texturePath = FName(j["texture"].get()); + b.size = j["size"]; + b.active = j["active"]; + } + if (ent.contains("Line")) { + auto &j = ent["Line"]; + auto &l = world.AddComponent(entity); + l.start = glm::vec3(float(j["sx"]), float(j["sy"]), float(j["sz"])); + l.end = glm::vec3(float(j["ex"]), float(j["ey"]), float(j["ez"])); + l.color = glm::vec3(float(j["r"]), float(j["g"]), float(j["b"])); + l.thickness = j["thickness"]; + l.active = j["active"]; + } + if (ent.contains("LODController")) { + auto &j = ent["LODController"]; + auto &lod = world.AddComponent(entity); + lod.RangeMin = j["min"]; + lod.RangeMax = j["max"]; + } + if (ent.value("disabled", false)) + world.AddComponent(entity); + + ++created; + } + + WriteLog("[ECS] ECSPersistence: loaded " + std::to_string(created) + " entities from " + path); + return created; +} + +} // namespace ecs_persistence diff --git a/entitysystem/ECSPersistence.h b/entitysystem/ECSPersistence.h new file mode 100644 index 00000000..65ef85b9 --- /dev/null +++ b/entitysystem/ECSPersistence.h @@ -0,0 +1,12 @@ +#pragma once +#include + +class ECWorld; + +namespace ecs_persistence +{ + // Save all named entities to a JSON file. Returns number of entities saved, or -1 on error. + int save(ECWorld &world, const std::string &path); + // Load entities from a JSON file. Skips entities whose name already exists. Returns count loaded, or -1 on error. + int load(ECWorld &world, const std::string &path); +} diff --git a/entitysystem/ECScene.cpp b/entitysystem/ECScene.cpp index f1e16f95..21f1113a 100644 --- a/entitysystem/ECScene.cpp +++ b/entitysystem/ECScene.cpp @@ -4,6 +4,7 @@ #include "ECScene.h" #include "utilities/Logs.h" +#include "simulation/simulation.h" ECScene::ECScene() : m_loaded(false) @@ -39,6 +40,9 @@ void ECScene::Update(float dt) if (!m_loaded) return; + if (!simulation::is_ready) + return; + m_systems.Update(m_world, dt); OnUpdate(dt); } diff --git a/entitysystem/ECWorld.cpp b/entitysystem/ECWorld.cpp index 2468d39c..8c47d996 100644 --- a/entitysystem/ECWorld.cpp +++ b/entitysystem/ECWorld.cpp @@ -12,15 +12,24 @@ entt::entity ECWorld::CreateEntity() void ECWorld::DestroyEntity(entt::entity entity) { - if (m_registry.valid(entity)) + if (m_registry.valid(entity)) { + if (auto *id = m_registry.try_get(entity)) + m_nameIndex.erase(id->Name.ToString()); m_registry.destroy(entity); + } } void ECWorld::Clear() { + m_nameIndex.clear(); m_registry.clear(); } +void ECWorld::UpdateNameIndex(entt::entity entity, const std::string &name) +{ + m_nameIndex[name] = entity; +} + bool ECWorld::IsAlive(entt::entity entity) const { return m_registry.valid(entity); @@ -29,18 +38,8 @@ bool ECWorld::IsAlive(entt::entity entity) const entt::entity ECWorld::FindEntityByName(const std::string& name) const { - auto view = m_registry.view(); - - for (auto entity : view) - { - const auto& id = view.get(entity); - if (id.Name.ToString() == name) - { - return entity; - } - } - - return entt::null; + auto it = m_nameIndex.find(name); + return (it != m_nameIndex.end()) ? it->second : entt::null; } entt::registry& ECWorld::Registry() diff --git a/entitysystem/ECWorld.h b/entitysystem/ECWorld.h index f52ad044..1d5d6921 100644 --- a/entitysystem/ECWorld.h +++ b/entitysystem/ECWorld.h @@ -12,6 +12,14 @@ #include #include #include +#include +#include + +// Forward declaration so the name-index branch in AddComponent compiles in +// translation units that include ECWorld.h without BasicComponents.h. +// if constexpr discards the branch for non-Identification T, but the name must +// still resolve at parse time. +namespace ECSComponent { struct Identification; } class ECWorld { @@ -22,6 +30,8 @@ public: entt::entity CreateEntity(); void DestroyEntity(entt::entity entity); void Clear(); + // Updates the name→entity index after an Identification component's Name is changed at runtime. + void UpdateNameIndex(entt::entity entity, const std::string &name); bool IsAlive(entt::entity entity) const; @@ -105,6 +115,7 @@ public: private: entt::registry m_registry; + std::unordered_map m_nameIndex; }; template @@ -122,7 +133,10 @@ T& ECWorld::AddComponent(entt::entity entity, Args&&... args) static T dummy{}; return dummy; } else { - return m_registry.get(entity); + auto &comp = m_registry.get(entity); + if constexpr (std::is_same_v) + m_nameIndex[comp.Name.ToString()] = entity; + return comp; } } diff --git a/entitysystem/scenes/GameScene.cpp b/entitysystem/scenes/GameScene.cpp index f4fab26e..ba2608f8 100644 --- a/entitysystem/scenes/GameScene.cpp +++ b/entitysystem/scenes/GameScene.cpp @@ -16,6 +16,7 @@ #include "entitysystem/systems/LineSystem.h" #include "entitysystem/systems/VehicleSyncSystem.h" #include "entitysystem/systems/BillboardSystem.h" +#include "entitysystem/ECSPersistence.h" #include "utilities/Logs.h" GameScene::GameScene() = default; @@ -37,6 +38,8 @@ void GameScene::OnLoad() #ifdef _DEBUG CreateTestEntities(); #endif + + ecs_persistence::load(World(), "ecs_save.json"); } void GameScene::OnUpdate(float dt) @@ -45,6 +48,7 @@ void GameScene::OnUpdate(float dt) void GameScene::OnUnload() { + ecs_persistence::save(World(), "ecs_save.json"); World().Clear(); } diff --git a/entitysystem/systems/LightSystem.cpp b/entitysystem/systems/LightSystem.cpp index fd488109..f548a1f5 100644 --- a/entitysystem/systems/LightSystem.cpp +++ b/entitysystem/systems/LightSystem.cpp @@ -44,7 +44,7 @@ void LightSystem::Update(ECWorld& world, float dt) light_array::free_light_record rec; rec.position = transform.Position; - rec.direction = glm::vec3(0.f, -1.f, 0.f); // default downward; rotation support can be added later + rec.direction = glm::normalize(glm::mat3_cast(transform.Rotation) * glm::vec3(0.f, -1.f, 0.f)); rec.color = spot.Color; rec.intensity = spot.Intensity; rec.range = spot.Range; diff --git a/model/Model3d.cpp b/model/Model3d.cpp index d4132f9e..65693416 100644 --- a/model/Model3d.cpp +++ b/model/Model3d.cpp @@ -18,6 +18,7 @@ Copyright (C) 2001-2004 Marcin Wozniak, Maciej Czapkiewicz and others #include "utilities/Globals.h" #include "utilities/Logs.h" #include "utilities/utilities.h" +#include "utilities/AsyncFilePreloader.h" #include "rendering/renderer.h" #include "utilities/Timer.h" #include "simulation/simulation.h" @@ -2357,10 +2358,34 @@ bool TSubModel::HasAnyVertexUserData() const return false; }; +// Helper: a read-only streambuf backed by an existing memory buffer. +struct membuf : std::streambuf { + membuf(const char* begin, const char* end) { + setg(const_cast(begin), const_cast(begin), const_cast(end)); + } +}; + void TModel3d::LoadFromBinFile(std::string const &FileName, bool dynamic) { // wczytanie modelu z pliku binarnego WriteLog("Loading binary format 3d model data from \"" + FileName + "\"...", logtype::model); + auto preloaded = GModelPreloader.get(FileName); + if (preloaded && !preloaded->empty()) { + // Fast path: file already in RAM — parse directly from memory, no disk I/O. + membuf mb(preloaded->data(), preloaded->data() + preloaded->size()); + std::istream stream(&mb); + uint32_t type = sn_utils::ld_uint32(stream); + uint32_t size = sn_utils::ld_uint32(stream) - 8; + if (type == MAKE_ID4('E', '3', 'D', '0')) { + deserialize(stream, size, dynamic); + WriteLog("Finished loading 3d model data from \"" + FileName + "\"", logtype::model); + } else { + ErrorLog("Bad model: unknown main chunk in file \"" + FileName + "\"", logtype::model); + } + return; + } + + // Slow path: synchronous disk read. std::ifstream file(FileName, std::ios::binary); uint32_t type = sn_utils::ld_uint32(file); @@ -2375,7 +2400,6 @@ void TModel3d::LoadFromBinFile(std::string const &FileName, bool dynamic) } else { - // throw std::runtime_error("e3d: unknown main chunk"); ErrorLog("Bad model: unknown main chunk in file \"" + FileName + "\"", logtype::model); file.close(); } @@ -2414,7 +2438,13 @@ void TModel3d::LoadFromTextFile(std::string const &FileName, bool dynamic) { // wczytanie submodelu z pliku tekstowego WriteLog("Loading text format 3d model data from \"" + FileName + "\"...", logtype::model); iFlags |= 0x0200; // wczytano z pliku tekstowego (właścicielami tablic są submodle) - cParser parser(FileName, cParser::buffer_FILE); // Ra: tu powinno być "models/"... + + auto preloaded = GModelPreloader.get(FileName); + cParser parser( + preloaded && !preloaded->empty() + ? std::string(preloaded->data(), preloaded->size()) + : FileName, + preloaded && !preloaded->empty() ? cParser::buffer_TEXT : cParser::buffer_FILE); // Ra: tu powinno być "models/"... TSubModel *SubModel; std::string token = parser.getToken(); while (token != "" || parser.eof()) diff --git a/simulation/simulationstateserializer.cpp b/simulation/simulationstateserializer.cpp index c4593ac4..16b1848f 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -30,9 +30,69 @@ http://mozilla.org/MPL/2.0/. #include "entitysystem/components/RenderComponents.h" #include "rendering/renderer.h" #include "utilities/Logs.h" +#include "utilities/AsyncFilePreloader.h" namespace simulation { +// Resolve a raw scenario token to the exact model file path that +// TModel3d::LoadFromFile will eventually open, or "" if it isn't a model. +// Mirrors TModelsManager::find_on_disk + TModel3d::LoadFromFile path logic so +// the preloader caches under the same key the loader later looks up. +static std::string resolve_model_path( std::string name ) { + erase_extension( name ); + name = ToLower( name ); + + std::string base; + for( auto const &ext : { std::string(".e3d"), std::string(".t3d") } ) { + if( FileExists( name + ext ) ) { base = name; break; } + if( FileExists( paths::models + name + ext ) ) { base = paths::models + name; break; } + } + if( base.empty() ) + return {}; + + if( Global.priorityLoadText3D && FileExists( base + ".t3d" ) ) + return base + ".t3d"; + if( FileExists( base + ".e3d" ) ) + return base + ".e3d"; + if( FileExists( base + ".t3d" ) ) + return base + ".t3d"; + return {}; +} + +// Runs on a background thread: tokenizes the scenario (includes expanded by +// cParser) ahead of the main parse and queues any token that resolves to a +// model file. Best-effort — false positives fail to load harmlessly and +// missed models simply fall back to synchronous disk reads. +static void prefetch_scan_worker( std::string scenariofile, std::string scenerypath, + bool loadtraction, std::atomic *cancel ) { + try { + cParser scan( scenariofile, cParser::buffer_FILE, scenerypath, loadtraction ); + std::string tok; + while( !( tok = scan.getToken( false ) ).empty() ) { + if( cancel->load( std::memory_order_relaxed ) ) + return; + // cheap filter: model references carry a path separator or extension; + // skips the millions of numeric vertex/coordinate tokens instantly. + if( tok.size() < 4 ) + continue; + bool pathlike = + tok.find( '/' ) != std::string::npos + || tok.find( '\\' ) != std::string::npos + || ends_with( ToLower( tok ), ".t3d" ) + || ends_with( ToLower( tok ), ".e3d" ); + if( !pathlike ) + continue; + + std::string resolved = resolve_model_path( tok ); + if( !resolved.empty() ) + GModelPreloader.queue( resolved ); + } + } + catch( ... ) { + // best-effort prefetch — never let a scan failure affect loading + } +} + std::shared_ptr state_serializer::deserialize_begin( std::string const &Scenariofile ) { @@ -112,6 +172,14 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { state->input.injectString(Global.prepend_scn); } + // kick off background disk preloading: a scan thread races ahead of the main + // parse discovering model files, worker threads read them into RAM, and + // LoadFromBinFile/LoadFromTextFile parse straight from memory on the main thread. + GModelPreloader.start(); + state->prefetch_thread = std::thread( + prefetch_scan_worker, Scenariofile, Global.asCurrentSceneryPath, + Global.bLoadTraction, &state->prefetch_cancel ); + return state; } @@ -161,6 +229,13 @@ state_serializer::deserialize_continue(std::shared_ptr state Region->serialize( state->scenariofile ); } + // loading finished — stop preloader threads and free the cached file buffers + state->prefetch_cancel = true; + if( state->prefetch_thread.joinable() ) + state->prefetch_thread.join(); + GModelPreloader.stop(); + GModelPreloader.clear(); + return false; } @@ -435,24 +510,38 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch return; ECWorld& world = currentScene->World(); - entt::entity entity = world.CreateEntity(); + // Only create ECS entities for node types that actually use them. + // Geometry (triangles, lines) and infrastructure (track, traction) go through + // the legacy pipeline exclusively — creating entities for them wastes time and memory. + static const std::unordered_set ecs_node_types = { + "dynamic", "model", "sound" + }; + const bool needs_entity = ecs_node_types.count(nodedata.type) > 0; - auto& transformComponent = world.AddComponent(entity); - transformComponent.Position = Scratchpad.location.offset.empty() ? glm::dvec3(0.0) : Scratchpad.location.offset.top(); - transformComponent.Rotation = Scratchpad.location.rotation; + entt::entity entity = entt::null; + if (needs_entity) { + entity = world.CreateEntity(); - if (nodedata.range_max > 0.0 || nodedata.range_min >= 0.0) { - auto& lodComponent = world.AddComponent(entity); - lodComponent.RangeMin = nodedata.range_min; - lodComponent.RangeMax = nodedata.range_max; + auto& transformComponent = world.AddComponent(entity); + transformComponent.Position = Scratchpad.location.offset.empty() ? glm::dvec3(0.0) : Scratchpad.location.offset.top(); + transformComponent.Rotation = Scratchpad.location.rotation; + + if (nodedata.range_max > 0.0 || nodedata.range_min >= 0.0) { + auto& lodComponent = world.AddComponent(entity); + lodComponent.RangeMin = nodedata.range_min; + lodComponent.RangeMax = nodedata.range_max; + } + + if (nodedata.name == "none") { + nodedata.name.clear(); + } else { + auto& id = world.AddComponent(entity); + id.Name = nodedata.name; + } + } else { + if (nodedata.name == "none") + nodedata.name.clear(); } - - if( nodedata.name == "none" ) { - nodedata.name.clear(); - } else { - auto& id = world.AddComponent(entity); - id.Name = nodedata.name; - } // type-based deserialization. not elegant but it'll do if( nodedata.type == "dynamic" ) { @@ -588,7 +677,7 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch } scene::Groups.insert( scene::Groups.handle(), instance ); simulation::Region->insert( instance ); - + { entt::entity modelEntity = world.FindEntityByName(instance->name()); if (modelEntity != entt::null) { @@ -601,7 +690,6 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch t->Rotation = { instance->Angles().x, instance->Angles().y, instance->Angles().z, 1.0f }; } - // smoke sources on static models if (instance->Model() != nullptr) { for (auto const &smokesource : instance->Model()->smoke_sources()) { auto& emitter = world.AddComponent(modelEntity); @@ -609,6 +697,25 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch emitter.isActive = true; } } + + { + std::string lname = instance->name(); + std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower); + bool isLamp = lname.find("lamp") != std::string::npos + || lname.find("latarni") != std::string::npos + || lname.find("reflektor") != std::string::npos + || lname.find("swiatlo") != std::string::npos + || lname.find("light") != std::string::npos; + if (isLamp && !world.HasComponent(modelEntity)) { + auto& spot = world.AddComponent(modelEntity); + spot.Color = glm::vec3(1.0f, 0.92f, 0.75f); + spot.Intensity = 1.5f; + spot.Range = 40.0f; + spot.InnerAngle = 20.0f; + spot.OuterAngle = 45.0f; + spot.Enabled = true; + } + } } } diff --git a/simulation/simulationstateserializer.h b/simulation/simulationstateserializer.h index fb0dd4ed..e9dcc163 100644 --- a/simulation/simulationstateserializer.h +++ b/simulation/simulationstateserializer.h @@ -11,6 +11,10 @@ http://mozilla.org/MPL/2.0/. #include "utilities/parser.h" #include "scene/scene.h" +#include "utilities/AsyncFilePreloader.h" + +#include +#include namespace simulation { @@ -23,8 +27,23 @@ struct deserializer_state { std::string, deserializefunctionbind> functionmap; + // background thread that scans the scenario ahead of the main parse and + // queues model files for asynchronous disk preloading + std::thread prefetch_thread; + std::atomic prefetch_cancel { false }; + deserializer_state(std::string const &File, cParser::buffertype const Type, const std::string &Path, bool const Loadtraction) : scenariofile(File), input(File, Type, Path, Loadtraction) { } + + ~deserializer_state() { + // safety net for the aborted-load path; on success deserialize_continue + // has already joined and stopped the preloader (idempotent here) + prefetch_cancel = true; + if (prefetch_thread.joinable()) + prefetch_thread.join(); + GModelPreloader.stop(); + GModelPreloader.clear(); + } }; class state_serializer { diff --git a/utilities/AsyncFilePreloader.cpp b/utilities/AsyncFilePreloader.cpp new file mode 100644 index 00000000..8cc1957f --- /dev/null +++ b/utilities/AsyncFilePreloader.cpp @@ -0,0 +1,86 @@ +#include "stdafx.h" +#include "AsyncFilePreloader.h" +#include +#include +#include + +AsyncFilePreloader GModelPreloader; + +void AsyncFilePreloader::start(int thread_count) { + if (m_running.load()) return; + + int n = thread_count > 0 ? thread_count + : (int)std::thread::hardware_concurrency(); + n = std::clamp(n, 1, 8); + + m_running = true; + m_workers.reserve(n); + for (int i = 0; i < n; ++i) + m_workers.emplace_back([this] { worker(); }); +} + +void AsyncFilePreloader::stop() { + { + std::lock_guard lk(m_mutex); + m_running = false; + // drain the queue so workers don't pick up more work + while (!m_pending.empty()) m_pending.pop(); + } + m_cv.notify_all(); + for (auto& t : m_workers) + if (t.joinable()) t.join(); + m_workers.clear(); +} + +void AsyncFilePreloader::queue(std::string path) { + std::lock_guard lk(m_mutex); + if (!m_running) return; + if (!m_queued.insert(path).second) return; // already queued / cached + m_pending.push(std::move(path)); + m_cv.notify_one(); +} + +AsyncFilePreloader::Buffer AsyncFilePreloader::get(const std::string& path) const { + std::lock_guard lk(m_mutex); + auto it = m_cache.find(path); + return (it != m_cache.end()) ? it->second : nullptr; +} + +void AsyncFilePreloader::clear() { + std::lock_guard lk(m_mutex); + m_cache.clear(); + m_queued.clear(); +} + +void AsyncFilePreloader::worker() { + while (true) { + std::string path; + { + std::unique_lock lk(m_mutex); + m_cv.wait(lk, [this] { + return !m_pending.empty() || !m_running.load(std::memory_order_relaxed); + }); + if (!m_running && m_pending.empty()) return; + if (m_pending.empty()) continue; + path = std::move(m_pending.front()); + m_pending.pop(); + } + + auto buf = std::make_shared>(); + { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (f) { + auto sz = static_cast(f.tellg()); + f.seekg(0); + buf->resize(static_cast(sz)); + f.read(buf->data(), sz); + if (!f) buf->clear(); // read error — fall back to sync load + } + } + + { + std::lock_guard lk(m_mutex); + m_cache[path] = std::move(buf); + } + } +} diff --git a/utilities/AsyncFilePreloader.h b/utilities/AsyncFilePreloader.h new file mode 100644 index 00000000..cc9baccf --- /dev/null +++ b/utilities/AsyncFilePreloader.h @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Thread pool that reads files from disk into memory so the main thread can +// skip disk I/O and parse directly from RAM. Only covers the binary path +// (E3D); text models fall back to synchronous load. +class AsyncFilePreloader { +public: + using Buffer = std::shared_ptr>; + + AsyncFilePreloader() = default; + ~AsyncFilePreloader() { stop(); } + + AsyncFilePreloader(const AsyncFilePreloader&) = delete; + AsyncFilePreloader& operator=(const AsyncFilePreloader&) = delete; + + // Start N worker threads (clamped to [1, hardware_concurrency]). + void start(int thread_count = 0); + + // Signal workers to stop and join them. Clears the queue but keeps + // already-loaded buffers so late callers still get cache hits. + void stop(); + + // Queue a file for background loading. Silently skips duplicates. + void queue(std::string path); + + // Return the preloaded buffer for `path`, or nullptr if not ready yet. + // Does NOT block — callers must fall back to synchronous I/O if nullptr. + Buffer get(const std::string& path) const; + + // Drop all cached buffers (call after loading finishes to free memory). + void clear(); + + bool is_running() const { return m_running.load(std::memory_order_relaxed); } + +private: + void worker(); + + mutable std::mutex m_mutex; + std::condition_variable m_cv; + std::queue m_pending; + std::unordered_set m_queued; // prevents duplicate queuing + std::unordered_map m_cache; + std::vector m_workers; + std::atomic m_running { false }; +}; + +extern AsyncFilePreloader GModelPreloader;